Files
galaxy/app/src/settings/cloud_preferences_syncer_tests.rs
T

1755 lines
67 KiB
Rust

use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use chrono::{DateTime, Utc};
use cloud_object_client::MockObjectClient;
use galaxy_core::settings::macros::define_settings_group;
use galaxy_core::settings::{RespectUserSyncSetting, Setting, SupportedPlatforms, SyncToCloud};
use galaxy_core::user_preferences::GetUserPreferences;
use galaxyui::{App, SingletonEntity};
use super::{
initialize_cloud_preferences_syncer, ClientIdProvider, CloudPreferencesSyncer,
ForceCloudToMatchLocal, SETTINGS_FILE_LAST_SYNCED_HASH_KEY,
};
use crate::auth::auth_state::AuthState;
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::{
BulkCreateCloudObjectResult, CreatedCloudObject, GenericStringObjectFormat,
GenericStringObjectUniqueKey, JsonObjectType, ObjectDeleteResult, ObjectIdType, Owner,
Revision, RevisionAndLastEditor, ServerMetadata, ServerObject, ServerPermissions,
ServerPreference, UniquePer, UpdateCloudObjectResult,
};
use crate::server::cloud_objects::fake_object_client::FakeObjectClient;
use crate::server::cloud_objects::test_utils::{
create_update_manager_struct, initialize_app, UpdateManagerStruct,
};
use crate::server::cloud_objects::update_manager::{InitialLoadResponse, UpdateManager};
use crate::server::ids::{ClientId, ServerId, ServerIdAndType, SyncId};
use crate::server::sync_queue::SyncQueue;
use crate::settings::cloud_preferences::{
CloudPreferenceModel, CloudPreferencesSettings, Platform,
};
use crate::ASSETS;
define_settings_group!(TestSettings, settings: [
all_platforms_cloud_setting: AllPlatforms {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
all_platforms_always_sync_cloud_setting: AllPlatformsAlwaysSync {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::No),
private: true,
},
mac_only_cloud_setting: MacOnly {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::MAC,
sync_to_cloud: SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes),
private: true,
},
linux_only_cloud_setting: LinuxOnly {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::LINUX,
sync_to_cloud: SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes),
private: true,
},
platform_specific_cloud_setting: PlatformSpecific {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::PerPlatform(RespectUserSyncSetting::Yes),
private: true,
},
non_value_syncable_setting: NonValueSyncable {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
non_cloud_setting: NonCloud {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Never,
private: true,
},
hashset_cloud_setting: HashSetSetting {
type: HashSet<String>,
default: HashSet::default(),
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: true,
},
]);
impl NonValueSyncable {
fn current_value_is_syncable(&self) -> bool {
false
}
}
struct SettingToLoad {
id: GenericStringObjectId,
serialized_preference: String,
}
struct TestClientIdProvider {
client_ids: Mutex<VecDeque<ClientId>>,
}
impl TestClientIdProvider {
fn new(client_ids: Vec<ClientId>) -> Self {
Self {
client_ids: Mutex::new(client_ids.into()),
}
}
}
impl ClientIdProvider for TestClientIdProvider {
fn next_client_id(&self) -> ClientId {
self.client_ids
.lock()
.unwrap()
.pop_front()
.expect("no client ids left")
}
}
fn initialize_settings(app: &mut App) {
initialize_app(app);
TestSettings::register(app);
CloudPreferencesSettings::register(app);
}
fn enable_settings_sync(app: &mut App) {
app.update(|ctx| {
CloudPreferencesSettings::handle(ctx).update(ctx, |prefs_settings, ctx| {
let _ = prefs_settings.settings_sync_enabled.set_value(true, ctx);
});
});
}
fn initial_load_response_with_cloud_settings(
settings_to_load: Vec<SettingToLoad>,
) -> InitialLoadResponse {
let mut initial_load_response = InitialLoadResponse::default();
let settings = settings_to_load
.iter()
.map(|setting| {
let id = setting.id;
let metadata = ServerMetadata {
uid: ServerId::default(),
revision: Revision::now(),
metadata_last_updated_ts: Utc::now().into(),
trashed_ts: None,
folder_id: None,
is_welcome_object: false,
creator_uid: None,
last_editor_uid: None,
current_editor_uid: None,
};
let cloud_setting = ServerPreference::new(
SyncId::ServerId(id.into()),
CloudPreferenceModel::deserialize_owned(&setting.serialized_preference)
.expect("error creating preference"),
metadata,
ServerPermissions {
space: Owner::mock_current_user(),
guests: Vec::new(),
anyone_link_sharing: None,
permissions_last_updated_ts: Utc::now().into(),
},
);
Box::new(cloud_setting) as Box<dyn ServerObject>
})
.collect::<Vec<Box<dyn ServerObject>>>();
initial_load_response.updated_generic_string_objects.insert(
GenericStringObjectFormat::Json(JsonObjectType::Preference),
settings,
);
initial_load_response
}
fn expect_sync_preferences_setting(server_api: &mut MockObjectClient) -> Vec<ClientId> {
expect_bulk_create_generic_string_objects(server_api, 1)
}
fn expect_sync_server_stored_privacy_settings(server_api: &mut MockObjectClient) -> Vec<ClientId> {
expect_bulk_create_generic_string_objects(server_api, 4)
}
async fn spawned_sync_queue_future_at_index(app: &mut App, index: usize) {
SyncQueue::handle(app)
.update(app, |sync_queue, ctx| {
ctx.await_spawned_future(sync_queue.spawned_futures()[index])
})
.await
}
async fn wait_for_num_spawned_futures(app: &mut App, expected_num: usize, message: &str) {
for _ in 0..50 {
let num_spawned_futures =
SyncQueue::handle(app).read(app, |sync_queue, _ctx| sync_queue.spawned_futures().len());
if num_spawned_futures == expected_num {
return;
}
warpui::r#async::Timer::after(Duration::from_millis(100)).await;
}
assert_num_spawned_futures(app, expected_num, message);
}
async fn await_spawned_futures(app: &mut App, num_futures: usize, message: &str) {
wait_for_num_spawned_futures(app, num_futures, message).await;
for _ in 0..num_futures {
spawned_sync_queue_future_at_index(app, 0).await;
}
}
fn assert_num_spawned_futures(app: &mut App, expected_num: usize, message: &str) {
SyncQueue::handle(app).read(app, |sync_queue, _ctx| {
assert_eq!(
expected_num,
sync_queue.spawned_futures().len(),
"{message}"
);
});
}
fn random_server_id() -> ServerId {
rand::random::<i64>().into()
}
/// Creates a MockObjectClient with base expectations needed for tests that call mock_initial_load.
fn mock_object_client_with_base_expectations() -> MockObjectClient {
let mut server_api = MockObjectClient::new();
// Mock environment timestamps fetch - called during mock_initial_load
server_api
.expect_fetch_environment_last_task_run_timestamps()
.returning(|| Ok(HashMap::new()));
server_api
}
/// Expects a bulk create request for the given number of settings and returns the client ids
/// used to create those objects. The reason we need to return the client ids is so the
/// cloud prefs sycner can use them to create objects, and the mock server responses match
/// those created objects. If the client ids don't match, then object updates for those objects will fail
/// because the sync queue is blocked waiting for dependent requests to complete.
fn expect_bulk_create_generic_string_objects(
server_api: &mut MockObjectClient,
num_objects: usize,
) -> Vec<ClientId> {
let client_ids: Vec<ClientId> = (0..num_objects).map(|_| ClientId::new()).collect();
let client_ids_clone = client_ids.clone();
server_api
.expect_bulk_create_generic_string_objects()
.times(1)
.return_once(move |_, _| {
let res = client_ids_clone
.into_iter()
.map(|client_id| CreatedCloudObject {
// Note that the client id is set here in the mock response.
client_id,
revision_and_editor: RevisionAndLastEditor {
revision: Revision::now(),
last_editor_uid: Some("34jkaosdfss".to_string()),
},
metadata_ts: DateTime::<Utc>::default().into(),
server_id_and_type: ServerIdAndType {
id: random_server_id(),
id_type: ObjectIdType::GenericStringObject,
},
creator_uid: None,
permissions: ServerPermissions::mock_personal(),
})
.collect();
Ok(BulkCreateCloudObjectResult::Success {
created_cloud_objects: res,
})
});
client_ids
}
#[test]
fn test_sync_local_pref_to_cloud_after_initial_sync_creates_prefs_setting() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
// Expect an initial create request for CloudPreferencesSetting
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
enable_settings_sync(&mut app);
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new_for_test(
ctx,
Arc::new(TestClientIdProvider::new(all_client_ids)),
);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
app.update(|ctx| {
// Force initial load
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(InitialLoadResponse::default(), ctx);
});
});
// Spend time waiting for the initial load to finish etc.
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
// Wait for the syncer to create the preferences and privacy settings.
await_spawned_futures(
&mut app,
3,
"expect the syncer to create the preferences and privacy settings",
)
.await;
})
}
#[test]
fn test_sync_local_pref_to_cloud_after_initial_sync() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let is_mac = cfg!(all(not(target_family = "wasm"), target_os = "macos"));
let is_linux = cfg!(all(
not(target_family = "wasm"),
any(target_os = "linux", target_os = "freebsd")
));
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
// Expect the creation of one or two cloud settings in separate requests depending on the platform
all_client_ids.append(&mut expect_bulk_create_generic_string_objects(
&mut server_api,
1,
));
if is_mac || is_linux {
let per_platform_client_id = ClientId::new();
all_client_ids.push(per_platform_client_id);
server_api
.expect_bulk_create_generic_string_objects()
.times(1)
.return_once(move |_, objects| {
assert_eq!(1, objects.len());
if is_mac {
assert_eq!(
&objects[0].uniqueness_key,
&Some(GenericStringObjectUniqueKey {
key: format!(
"{}_{}",
Platform::Mac,
MacOnly::storage_key().to_owned()
),
unique_per: UniquePer::User
})
);
} else if is_linux {
assert_eq!(
&objects[0].uniqueness_key,
&Some(GenericStringObjectUniqueKey {
key: format!(
"{}_{}",
Platform::Linux,
LinuxOnly::storage_key().to_owned()
),
unique_per: UniquePer::User
})
);
}
Ok(BulkCreateCloudObjectResult::Success {
created_cloud_objects: vec![CreatedCloudObject {
client_id: per_platform_client_id,
revision_and_editor: RevisionAndLastEditor {
revision: Revision::now(),
last_editor_uid: Some("34jkaosdfk".to_string()),
},
metadata_ts: DateTime::<Utc>::default().into(),
server_id_and_type: ServerIdAndType {
id: random_server_id(),
id_type: ObjectIdType::GenericStringObject,
},
creator_uid: None,
permissions: ServerPermissions::mock_personal(),
}],
})
});
}
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
enable_settings_sync(&mut app);
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new_for_test(
ctx,
Arc::new(TestClientIdProvider::new(all_client_ids)),
);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
app.update(|ctx| {
// Force initial load
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(InitialLoadResponse::default(), ctx);
});
});
// Spend time waiting for the initial load to finish etc.
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
app.update(|ctx| {
// And then update all settings forcing the create requests
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings
.all_platforms_cloud_setting
.set_value(true, ctx);
if cfg!(all(not(target_family = "wasm"), target_os = "macos")) {
let _ = test_settings.mac_only_cloud_setting.set_value(true, ctx);
} else if cfg!(all(
not(target_family = "wasm"),
any(target_os = "linux", target_os = "freebsd")
)) {
let _ = test_settings.linux_only_cloud_setting.set_value(true, ctx);
}
let _ = test_settings.non_cloud_setting.set_value(true, ctx);
let _ = test_settings
.non_value_syncable_setting
.set_value(true, ctx);
})
});
let expected_num_create_requests = if is_mac || is_linux { 5 } else { 4 };
await_spawned_futures(
&mut app,
expected_num_create_requests,
"expect the syncer to create the settings",
)
.await;
})
}
fn run_initial_sync_test(is_onboarded: bool) {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let is_mac = cfg!(all(not(target_family = "wasm"), target_os = "macos"));
let is_linux = cfg!(all(
not(target_family = "wasm"),
any(target_os = "linux", target_os = "freebsd")
));
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
if !is_onboarded {
// Only sync other settings if the user isn't onboarded (e.g. is a first time user)
let expected_num_other_settings_client_ids = if is_mac || is_linux { 6 } else { 5 };
all_client_ids.append(&mut expect_bulk_create_generic_string_objects(
&mut server_api,
expected_num_other_settings_client_ids,
));
};
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
if !is_onboarded {
server_api
.expect_update_generic_string_object()
.times(1)
.return_once(move |_, _, _| {
Ok(UpdateCloudObjectResult::<Box<dyn ServerObject>>::Success {
revision_and_editor: RevisionAndLastEditor {
revision: Revision::now(),
last_editor_uid: None,
},
})
});
}
if !is_onboarded {
server_api
.expect_fetch_changed_objects()
.times(1)
.return_once(move |_, _| Ok(InitialLoadResponse::default()));
}
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
app.update(|ctx| {
// Force initial load with no cloud objects
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(InitialLoadResponse::default(), ctx);
});
// And then update both settings
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
// Make sure to sync at least one setting
let _ = test_settings
.all_platforms_always_sync_cloud_setting
.set_value(true, ctx);
let _ = test_settings
.all_platforms_cloud_setting
.set_value(true, ctx);
if is_mac {
let _ = test_settings.mac_only_cloud_setting.set_value(true, ctx);
} else if is_linux {
let _ = test_settings.linux_only_cloud_setting.set_value(true, ctx);
}
let _ = test_settings.non_cloud_setting.set_value(true, ctx);
});
});
// Do the initial load after the settings have been updated locally
app.add_singleton_model(|ctx| {
let mut syncer = CloudPreferencesSyncer::new_for_test(
ctx,
Arc::new(TestClientIdProvider::new(all_client_ids)),
);
let auth_state = AuthState::new_for_test();
auth_state.set_is_onboarded(is_onboarded);
syncer.handle_user_fetched(Arc::new(auth_state), ctx);
syncer
});
// Spend time waiting for the initial load to finish etc.
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
let expected_num_spawned_futures = if !is_onboarded { 4 } else { 3 };
await_spawned_futures(
&mut app,
expected_num_spawned_futures,
"expect the syncer to create the settings",
)
.await;
app.update(|ctx| {
// Now update the settings again to ensure the syncer only syncs for non-onboarded users.
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
// Sync a cloud only setting by changing the value we had set earlier.
let _ = test_settings
.all_platforms_cloud_setting
.set_value(false, ctx);
});
});
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
assert_eq!(
is_onboarded,
!app.read(|ctx| {
*CloudPreferencesSettings::handle(ctx)
.as_ref(ctx)
.settings_sync_enabled
}),
"settings sync enabled should be opposite of onboarded"
);
if !is_onboarded {
// If the user isn't onboarded, they should have been opted into sync and sent another request.
assert_num_spawned_futures(
&mut app,
expected_num_spawned_futures + 1,
"expect an additional update request",
);
spawned_sync_queue_future_at_index(&mut app, expected_num_spawned_futures).await;
}
})
}
#[test]
fn test_sync_local_pref_to_cloud_on_initial_sync_for_first_time_user() {
run_initial_sync_test(false);
}
#[test]
fn test_sync_local_pref_to_cloud_on_initial_sync_for_returning_user() {
run_initial_sync_test(true);
}
#[test]
fn test_sync_local_pref_to_cloud_updates_existing_pref() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
// Add missing fetch_changed_objects expectation
server_api
.expect_fetch_changed_objects()
.times(1)
.return_once(move |_, _| Ok(InitialLoadResponse::default()));
// Expect updating a single cloud setting
server_api
.expect_update_generic_string_object()
.times(1)
.return_once(move |_, _, _| {
Ok(UpdateCloudObjectResult::<Box<dyn ServerObject>>::Success {
revision_and_editor: RevisionAndLastEditor {
revision: Revision::now(),
last_editor_uid: None,
},
})
});
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
enable_settings_sync(&mut app);
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new_for_test(
ctx,
Arc::new(TestClientIdProvider::new(all_client_ids)),
);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
let generic_object_id: GenericStringObjectId = 123.into();
app.update(|ctx| {
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(
initial_load_response_with_cloud_settings(vec![SettingToLoad {
id: generic_object_id,
serialized_preference: "{\"storage_key\":\"AllPlatforms\",\"value\":true,\"platform\":\"Global\"}".to_owned(),
}]),
ctx,
);
});
});
// Give the initial load time to complete
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
// complete the create request for the cloud settings and the telemetry/crash reporting settings
await_spawned_futures(
&mut app,
3,
"expect the syncer to create the initial settings",
)
.await;
app.update(|ctx| {
// And then update settings - should trigger an update server call since
// the value has changed (just for the cloud synced setting)
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings
.all_platforms_cloud_setting
.set_value(false, ctx);
let _ = test_settings.non_cloud_setting.set_value(true, ctx);
});
});
// Give the update time to spawn futures
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
assert_num_spawned_futures(&mut app, 4, "expect the syncer to send an update request");
spawned_sync_queue_future_at_index(&mut app, 3).await;
})
}
#[test]
fn test_sync_cloud_pref_to_local_on_initial_load_or_collab_update() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
let generic_object_id_1: GenericStringObjectId = 123.into();
let generic_object_id_2: GenericStringObjectId = 345.into();
let generic_object_id_3: GenericStringObjectId = 456.into();
let generic_object_id_4: GenericStringObjectId = 567.into();
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
app.update(|ctx| {
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(
initial_load_response_with_cloud_settings(vec![
SettingToLoad {
id: generic_object_id_1,
serialized_preference: "{\"storage_key\":\"AllPlatforms\",\"value\":true,\"platform\":\"Global\"}".to_owned(),
},
SettingToLoad {
id: generic_object_id_2,
serialized_preference: "{\"storage_key\":\"MacOnly\",\"value\":true,\"platform\":\"Mac\"}".to_owned(),
},
SettingToLoad {
id: generic_object_id_3,
serialized_preference: "{\"storage_key\":\"LinuxOnly\",\"value\":true,\"platform\":\"Linux\"}".to_owned(),
},
SettingToLoad {
id: generic_object_id_4,
serialized_preference: "{\"storage_key\":\"PlatformSpecific\",\"value\":true,\"platform\":\"Linux\"}".to_owned(),
},
]),
ctx,
);
});
});
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
!settings.all_platforms_cloud_setting.inner,
"setting not set locally"
);
});
enable_settings_sync(&mut app);
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new(false, std::path::PathBuf::new(), ctx);
// This should sync the cloud preferences at this point
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
// Spend time waiting for the initial load to finish etc.
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
// complete the create request for the cloud settings and the telemetry/crash reporting settings
await_spawned_futures(
&mut app,
3,
"expect the syncer to create the initial settings",
)
.await;
let is_mac = cfg!(all(not(target_family = "wasm"), target_os = "macos"));
let is_linux = cfg!(all(
not(target_family = "wasm"),
any(target_os = "linux", target_os = "freebsd")
));
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
settings.all_platforms_cloud_setting.inner,
"setting set locally"
);
if is_mac {
assert!(
settings.mac_only_cloud_setting.inner,
"mac setting set locally"
);
assert!(
!settings.platform_specific_cloud_setting.inner,
"platform specific setting should not be set locally on mac"
);
assert!(
!settings.linux_only_cloud_setting.inner,
"linux setting not set locally"
);
} else if is_linux {
assert!(
settings.linux_only_cloud_setting.inner,
"linux setting set locally"
);
assert!(
settings.platform_specific_cloud_setting.inner,
"platform specific setting set locally"
);
assert!(
!settings.mac_only_cloud_setting.inner,
"mac setting not set locally"
);
}
});
})
}
#[test]
fn test_cloud_preferences_setting_initial_load_skipped_when_setting_is_off() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let settings_object_id: GenericStringObjectId = 123.into();
let generic_object_id_1: GenericStringObjectId = 123.into();
let generic_object_id_2: GenericStringObjectId = 345.into();
let generic_object_id_3: GenericStringObjectId = 456.into();
let generic_object_id_4: GenericStringObjectId = 567.into();
// Expect creating the cloud settings after the setting is enabled
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
app.update(|ctx| {
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(
initial_load_response_with_cloud_settings(vec![
SettingToLoad {
id: settings_object_id,
serialized_preference: "{\"storage_key\":\"IsSettingsSyncEnabled\",\"value\":false,\"platform\":\"Global\"}".to_owned(),
},
SettingToLoad {
id: generic_object_id_1,
serialized_preference: "{\"storage_key\":\"AllPlatforms\",\"value\":true,\"platform\":\"Global\"}".to_owned(),
},
SettingToLoad {
id: generic_object_id_2,
serialized_preference: "{\"storage_key\":\"MacOnly\",\"value\":true,\"platform\":\"Mac\"}".to_owned(),
},
SettingToLoad {
id: generic_object_id_3,
serialized_preference: "{\"storage_key\":\"LinuxOnly\",\"value\":true,\"platform\":\"Linux\"}".to_owned(),
},
SettingToLoad {
id: generic_object_id_4,
serialized_preference: "{\"storage_key\":\"PlatformSpecific\",\"value\":true,\"platform\":\"Linux\"}".to_owned(),
},
]),
ctx,
);
});
});
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
!settings.all_platforms_cloud_setting.inner,
"setting not set locally"
);
});
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new_for_test(
ctx,
Arc::new(TestClientIdProvider::new(all_client_ids)),
);
// No syncing should happen since the setting is disabled.
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
let is_mac = cfg!(all(not(target_family = "wasm"), target_os = "macos"));
let is_linux = cfg!(all(
not(target_family = "wasm"),
any(target_os = "linux", target_os = "freebsd")
));
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
!settings.all_platforms_cloud_setting.inner,
"setting set locally"
);
if is_mac {
assert!(
!settings.mac_only_cloud_setting.inner,
"mac setting set locally"
);
assert!(
!settings.platform_specific_cloud_setting.inner,
"platform specific setting should not be set locally on mac"
);
assert!(
!settings.linux_only_cloud_setting.inner,
"linux setting not set locally"
);
} else if is_linux {
assert!(
!settings.linux_only_cloud_setting.inner,
"linux setting set locally"
);
assert!(
!settings.platform_specific_cloud_setting.inner,
"platform specific setting set locally"
);
assert!(
!settings.mac_only_cloud_setting.inner,
"mac setting not set locally"
);
}
});
// Now enable the setting and make sure everything syncs
enable_settings_sync(&mut app);
// Spend time waiting for the initial load to finish etc.
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
// complete the create request for the cloud settings and the telemetry/crash reporting settings
await_spawned_futures(
&mut app,
3,
"expect the syncer to create the initial settings",
)
.await;
})
}
#[test]
fn test_sync_local_pref_to_cloud_doesnt_update_equal_pref() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let settings_object_id: GenericStringObjectId = 123.into();
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
// Expect updating a single cloud setting when we extend the hashset
server_api
.expect_update_generic_string_object()
.times(1)
.return_once(move |_, _, _| {
Ok(UpdateCloudObjectResult::<Box<dyn ServerObject>>::Success {
revision_and_editor: RevisionAndLastEditor {
revision: Revision::now(),
last_editor_uid: None,
},
})
});
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
app.update(|ctx| {
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(
initial_load_response_with_cloud_settings(vec![
SettingToLoad {
id: settings_object_id,
// use a hashset setting that deserializes as a json array in a non-deterministic order
serialized_preference: "{\"storage_key\":\"HashSetSetting\",\"value\":[\"foo\", \"bar\"],\"platform\":\"Global\"}".to_owned(),
},
]),
ctx,
);
});
});
enable_settings_sync(&mut app);
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new(false, std::path::PathBuf::new(), ctx);
// This should sync the cloud preferences at this point
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
// Spend time waiting for the initial load to finish etc.
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
// Complete the create request for cloud prefs syncing
await_spawned_futures(
&mut app,
3,
"expect the syncer to create the initial settings",
)
.await;
// After the initial load the settings should be set.
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert_eq!(2, settings.hashset_cloud_setting.value().len());
assert!(settings.hashset_cloud_setting.value().contains("foo"));
assert!(settings.hashset_cloud_setting.value().contains("bar"));
});
// This should not trigger an update request since the values are the same.
app.update(|ctx| {
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings.hashset_cloud_setting.set_value(
// Reverse the order and make sure the settings are still considered equal
HashSet::from_iter(vec!["bar".to_string(), "foo".to_string()]),
ctx,
);
});
});
assert_num_spawned_futures(&mut app, 3, "should not be any additional requests");
app.update(|ctx| {
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings.hashset_cloud_setting.set_value(
// Add an element and make sure the settings are considered different
HashSet::from_iter(vec![
"bar".to_string(),
"foo".to_string(),
"zed".to_string(),
]),
ctx,
);
});
});
// Complete the second update request (fourth total request)
assert_num_spawned_futures(&mut app, 4, "should be an update request");
spawned_sync_queue_future_at_index(&mut app, 3).await;
})
}
#[test]
fn test_cloud_preferences_setting_enabling_setting_syncs_prefs() {
App::test(ASSETS, |mut app| async move {
// Start with cloud prefs disabled
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
// Expect creating a single generic string object as an additional synced setting.
all_client_ids.append(&mut expect_bulk_create_generic_string_objects(
&mut server_api,
1,
));
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
// Now enable settings sync
enable_settings_sync(&mut app);
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new(false, std::path::PathBuf::new(), ctx);
// This should sync the cloud preferences at this point
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
app.update(|ctx| {
// Force initial load
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(InitialLoadResponse::default(), ctx);
});
});
// Spend time waiting for the initial load to finish etc.
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
await_spawned_futures(
&mut app,
3,
"expect the syncer to create the initial settings",
)
.await;
// update a different setting
app.update(|ctx| {
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings
.all_platforms_cloud_setting
.set_value(true, ctx);
});
});
// complete the create request for the new synced setting
assert_num_spawned_futures(&mut app, 4, "should be an additional create request");
spawned_sync_queue_future_at_index(&mut app, 3).await;
})
}
#[test]
fn test_cloud_pref_not_synced_when_current_value_not_syncable() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
// Create a cloud preference for non_value_syncable_setting with true
let settings_object_id: GenericStringObjectId = 123.into();
let initial_load = initial_load_response_with_cloud_settings(vec![SettingToLoad {
id: settings_object_id,
serialized_preference: "{\"storage_key\":\"non_value_syncable_setting\",\"value\":true,\"platform\":\"Global\"}".to_owned(),
}]);
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
// Set local value to false
app.update(|ctx| {
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings
.non_value_syncable_setting
.set_value(false, ctx);
});
});
// Enable settings sync
enable_settings_sync(&mut app);
app.update(|ctx| {
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(initial_load, ctx);
});
});
// Add the syncer and trigger sync
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new(false, std::path::PathBuf::new(), ctx);
// This should sync the cloud preferences at this point
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
// Run any spawned futures
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
await_spawned_futures(&mut app, 3, "initial load").await;
// Verify that the local value remains false and wasn't synced from cloud's true
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
!settings.non_value_syncable_setting.value(),
"Local value should remain false since current value is not syncable"
);
});
})
}
#[test]
fn test_ensure_no_duplicate_cloud_prefs() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
let mut server_api = mock_object_client_with_base_expectations();
let mut all_client_ids = expect_sync_preferences_setting(&mut server_api);
all_client_ids.append(&mut expect_sync_server_stored_privacy_settings(
&mut server_api,
));
// Set up delete expectations for the duplicate preferences
server_api.expect_delete_object().times(3).returning(|_| {
Ok(ObjectDeleteResult::Success {
deleted_ids: Vec::new(),
})
});
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(server_api));
// Create three duplicate preferences for the same setting with different IDs
// One matching the local value (true) and two with different values
let duplicate_prefs = vec![
SettingToLoad {
id: 123.into(),
serialized_preference:
"{\"storage_key\":\"AllPlatforms\",\"value\":false,\"platform\":\"Global\"}"
.to_owned(),
},
SettingToLoad {
id: 456.into(),
serialized_preference:
"{\"storage_key\":\"AllPlatforms\",\"value\":true,\"platform\":\"Global\"}"
.to_owned(),
},
SettingToLoad {
id: 789.into(),
serialized_preference:
"{\"storage_key\":\"AllPlatforms\",\"value\":false,\"platform\":\"Global\"}"
.to_owned(),
},
];
enable_settings_sync(&mut app);
app.update(|ctx| {
update_manager.update(ctx, |update_manager, ctx| {
update_manager.mock_initial_load(
initial_load_response_with_cloud_settings(duplicate_prefs),
ctx,
);
});
});
app.add_singleton_model(|ctx| {
let syncer = CloudPreferencesSyncer::new_for_test(
ctx,
Arc::new(TestClientIdProvider::new(all_client_ids)),
);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
// Give time for the initial load and deduplication to complete
galaxyui::r#async::Timer::after(Duration::from_secs(1)).await;
// Wait for the initial creation of cloud settings and telemetry settings
await_spawned_futures(
&mut app,
3,
"expect the syncer to create the initial settings",
)
.await;
// After the initial operations complete, expect 3 delete operations for duplicates
// plus 1 for the timestamps fetch = 4 total on UpdateManager
UpdateManager::handle(&app).read(&app, |update_manager, _ctx| {
assert_eq!(
4,
update_manager.spawned_futures().len(),
"expect three delete operations for duplicate preferences plus timestamps fetch"
);
});
for i in 0..4 {
// Complete the delete operations and timestamps fetch
UpdateManager::handle(&app)
.update(&mut app, |update_manager, ctx| {
ctx.await_spawned_future(update_manager.spawned_futures()[i])
})
.await;
}
})
}
// ============================================================================
// End-to-end tests for the force-local-wins-on-startup hash check
// (QUALITY-474)
//
// These tests exercise the full path from hash comparison through syncer
// construction to cloud upload, using a real temporary TOML file and the
// stateful `FakeObjectClient`. They use the production entry point
// `initialize_cloud_preferences_syncer` so the hash-check logic is
// exercised end-to-end rather than hard-coded.
//
// Because the existing test harness installs an in-memory
// `PublicPreferences` backend (not a real `TomlBackedUserPreferences`),
// the temp settings file is only consulted for its hash; local setting
// values still live in the in-memory store. Tests manually set the local
// value via `TestSettings::set_value` to simulate "this is what the file
// says".
// ============================================================================
/// Drains all currently-pending `SyncQueue` futures to completion, then
/// waits a short timer to give the syncer time to spawn any follow-up
/// work before the test asserts on cloud state.
async fn drain_sync_queue(app: &mut App) {
// The syncer spawns futures for bulk creates, updates, and deletes
// through the sync queue. We drain them in a loop because draining
// one can cause others to be spawned (e.g. an update in response
// to a cloud change).
for _ in 0..5 {
galaxyui::r#async::Timer::after(Duration::from_millis(200)).await;
let num = SyncQueue::handle(app).read(app, |sq, _| sq.spawned_futures().len());
if num == 0 {
return;
}
for _ in 0..num {
spawned_sync_queue_future_at_index(app, 0).await;
}
}
}
/// Writes a fresh `settings.toml` file at the given path and returns
/// its `file_content_hash` so tests can reason about what value the
/// syncer will compare against.
fn write_settings_file_with_content(path: &std::path::Path, content: &str) -> String {
std::fs::write(path, content).expect("write temp settings file");
galaxyui_extras::user_preferences::toml_backed::TomlBackedUserPreferences::file_content_hash(
path,
)
.expect("hash should be Some for non-empty file")
}
fn read_stored_hash(app: &App) -> Option<String> {
app.read(|ctx| {
ctx.private_user_preferences()
.read_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY)
.unwrap_or_default()
})
}
fn write_stored_hash(app: &App, value: &str) {
app.read(|ctx| {
ctx.private_user_preferences()
.write_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY, value.to_string())
.expect("write stored hash to in-memory private prefs");
});
}
#[test]
fn test_force_local_wins_on_startup_uploads_local_to_cloud() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
// Step 1: create a real temp settings.toml. The file's hash is
// what the syncer will compare against the stored hash.
let tmp = tempfile::tempdir().unwrap();
let toml_path = tmp.path().join("settings.toml");
let file_hash = write_settings_file_with_content(
&toml_path,
"# user edited this offline\nsome_key = \"new_value\"\n",
);
// Step 2: seed the stored hash with a DIFFERENT value so the
// comparison detects divergence. "0" is guaranteed not to
// match a real SHA-256 hex digest.
assert_ne!("0", file_hash);
write_stored_hash(&app, "0");
// Step 3: simulate the user's in-memory local values matching
// "what the file says". The file contents aren't actually read
// by the in-memory prefs backend, so tests set the local value
// explicitly here.
app.update(|ctx| {
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings
.all_platforms_cloud_setting
.set_value(true, ctx);
});
});
// Step 4: seed the fake cloud with a stale value. If the
// syncer runs in normal "cloud wins" mode, it would overwrite
// the local true with this false; the force-local path should
// instead upload the local true over this false.
let fake = FakeObjectClient::default();
fake.seed_preference("AllPlatforms", "false", Platform::Global);
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(fake.clone()));
enable_settings_sync(&mut app);
// Step 5: construct the syncer via the production entry point
// with the real file path. This is the seam where the hash
// comparison runs.
let toml_path_for_closure = toml_path.clone();
app.add_singleton_model(move |ctx| {
let syncer = initialize_cloud_preferences_syncer(
toml_path_for_closure,
None, // no parse error
ctx,
);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
// Step 6: trigger the initial load with the fake's current
// snapshot. This kicks off handle_initial_load, which sees the
// force_local_wins_on_startup flag and uploads local to cloud.
let initial_load = fake.snapshot_as_initial_load_response();
app.update(|ctx| {
update_manager.update(ctx, |um, ctx| {
um.mock_initial_load(initial_load, ctx);
});
});
drain_sync_queue(&mut app).await;
// Assertion 1: the cloud now reflects the local value. If the
// hash check or the override path were broken, the cloud would
// still hold "false".
assert_eq!(
Some("true".to_string()),
fake.cloud_value("AllPlatforms", Platform::Global),
"local value should have been uploaded to cloud"
);
// Assertion 2: the stored hash is no longer "0" and now
// matches the current file's hash. This covers the
// `update_stored_settings_hash` call at the end of
// `handle_initial_load`.
let new_stored = read_stored_hash(&app);
assert_eq!(
Some(file_hash),
new_stored,
"stored hash should match current file hash after sync",
);
})
}
#[test]
fn test_no_force_local_when_hashes_match() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
// Create a file and seed the stored hash with its exact value
// so the comparison yields "no divergence".
let tmp = tempfile::tempdir().unwrap();
let toml_path = tmp.path().join("settings.toml");
let file_hash =
write_settings_file_with_content(&toml_path, "# in-sync with cloud\nkey = \"value\"\n");
write_stored_hash(&app, &file_hash);
// Start with local = false (the default for the test setting).
// Seed cloud with true. Normal "cloud wins" semantics should
// apply the cloud true to local.
let fake = FakeObjectClient::default();
fake.seed_preference("AllPlatforms", "true", Platform::Global);
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(fake.clone()));
enable_settings_sync(&mut app);
let toml_path_for_closure = toml_path.clone();
app.add_singleton_model(move |ctx| {
let syncer = initialize_cloud_preferences_syncer(toml_path_for_closure, None, ctx);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
let initial_load = fake.snapshot_as_initial_load_response();
app.update(|ctx| {
update_manager.update(ctx, |um, ctx| {
um.mock_initial_load(initial_load, ctx);
});
});
drain_sync_queue(&mut app).await;
// With hashes matching, force-local-wins should NOT trigger.
// The syncer runs in normal ForceCloudToMatchLocal::No mode,
// and the seeded cloud value (true) is applied to local.
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
settings.all_platforms_cloud_setting.inner,
"cloud value should have been applied to local"
);
});
// The cloud value should be unchanged.
assert_eq!(
Some("true".to_string()),
fake.cloud_value("AllPlatforms", Platform::Global),
);
})
}
#[test]
fn test_force_local_suppressed_when_file_is_broken() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
// Create a file whose contents happen to hash to something,
// seed a different stored hash so the comparison would
// normally detect divergence, but also pass a non-None
// `startup_toml_parse_error` to simulate "file is broken".
// The broken-file guard should suppress force-local-wins.
let tmp = tempfile::tempdir().unwrap();
let toml_path = tmp.path().join("settings.toml");
write_settings_file_with_content(&toml_path, "broken [toml");
write_stored_hash(&app, "0");
// Local value set to true, cloud seeded with false. If the
// broken-file guard fails and force-local triggers anyway,
// the cloud would be overwritten with true.
app.update(|ctx| {
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings
.all_platforms_cloud_setting
.set_value(true, ctx);
});
});
let fake = FakeObjectClient::default();
fake.seed_preference("AllPlatforms", "false", Platform::Global);
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(fake.clone()));
enable_settings_sync(&mut app);
let toml_path_for_closure = toml_path.clone();
app.add_singleton_model(move |ctx| {
let syncer = initialize_cloud_preferences_syncer(
toml_path_for_closure,
Some("simulated TOML parse error"),
ctx,
);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
let initial_load = fake.snapshot_as_initial_load_response();
app.update(|ctx| {
update_manager.update(ctx, |um, ctx| {
um.mock_initial_load(initial_load, ctx);
});
});
drain_sync_queue(&mut app).await;
// The broken-file guard should have suppressed force-local.
// Normal cloud-wins semantics restore the seeded cloud value
// (false) into local state.
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
!settings.all_platforms_cloud_setting.inner,
"cloud value should have been applied to local despite divergence",
);
});
assert_eq!(
Some("false".to_string()),
fake.cloud_value("AllPlatforms", Platform::Global),
"cloud value should not have been overwritten with local value",
);
})
}
#[test]
fn test_file_missing_with_stored_hash_lets_cloud_win() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
// Use a path that doesn't exist: the user has deleted their
// settings.toml entirely. A stored hash is present (from a
// previous session). The `(None, Some(_))` arm of the match
// should choose cloud-wins to avoid wiping the cloud with
// defaults.
let tmp = tempfile::tempdir().unwrap();
let missing_path = tmp.path().join("settings_does_not_exist.toml");
assert!(!missing_path.exists());
write_stored_hash(&app, "12345");
let fake = FakeObjectClient::default();
fake.seed_preference("AllPlatforms", "true", Platform::Global);
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(fake.clone()));
enable_settings_sync(&mut app);
app.add_singleton_model(move |ctx| {
let syncer = initialize_cloud_preferences_syncer(missing_path, None, ctx);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
let initial_load = fake.snapshot_as_initial_load_response();
app.update(|ctx| {
update_manager.update(ctx, |um, ctx| {
um.mock_initial_load(initial_load, ctx);
});
});
drain_sync_queue(&mut app).await;
// Cloud should win, so local is updated to true.
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
settings.all_platforms_cloud_setting.inner,
"cloud should win when file is missing"
);
});
// Cloud state is preserved (not wiped by local defaults).
assert_eq!(
Some("true".to_string()),
fake.cloud_value("AllPlatforms", Platform::Global),
);
})
}
#[test]
fn test_first_launch_with_no_stored_hash_lets_cloud_win() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
// Fresh install: a settings.toml exists but there is no
// stored hash yet. The `(Some(_), None)` arm should choose
// cloud-wins.
let tmp = tempfile::tempdir().unwrap();
let toml_path = tmp.path().join("settings.toml");
write_settings_file_with_content(&toml_path, "# fresh install\n");
// Deliberately NO call to write_stored_hash.
assert_eq!(None, read_stored_hash(&app));
let fake = FakeObjectClient::default();
fake.seed_preference("AllPlatforms", "true", Platform::Global);
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(fake.clone()));
enable_settings_sync(&mut app);
let toml_path_for_closure = toml_path.clone();
app.add_singleton_model(move |ctx| {
let syncer = initialize_cloud_preferences_syncer(toml_path_for_closure, None, ctx);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
let initial_load = fake.snapshot_as_initial_load_response();
app.update(|ctx| {
update_manager.update(ctx, |um, ctx| {
um.mock_initial_load(initial_load, ctx);
});
});
drain_sync_queue(&mut app).await;
// Cloud wins: local setting is updated to cloud's true.
app.read(|ctx| {
let settings = TestSettings::as_ref(ctx);
assert!(
settings.all_platforms_cloud_setting.inner,
"cloud should win on first launch with no stored hash"
);
});
// After the sync completes, the stored hash is populated so
// future startups can detect divergence from this baseline.
let stored_after_sync = read_stored_hash(&app);
assert!(
stored_after_sync.is_some(),
"stored hash should be populated after first sync",
);
})
}
#[test]
fn test_offline_ui_change_does_not_update_hash_until_sync_succeeds() {
App::test(ASSETS, |mut app| async move {
initialize_settings(&mut app);
// Phase 1: normal startup. File and stored hash match, cloud
// has a value, initial load completes. After reconciliation the
// stored hash reflects the file.
let tmp = tempfile::tempdir().unwrap();
let toml_path = tmp.path().join("settings.toml");
let initial_hash = write_settings_file_with_content(&toml_path, "# in sync with cloud\n");
write_stored_hash(&app, &initial_hash);
let fake = FakeObjectClient::default();
fake.seed_preference("AllPlatforms", "false", Platform::Global);
let UpdateManagerStruct { update_manager, .. } =
create_update_manager_struct(&mut app, Arc::new(fake.clone()));
enable_settings_sync(&mut app);
let toml_path_for_closure = toml_path.clone();
app.add_singleton_model(move |ctx| {
let syncer = initialize_cloud_preferences_syncer(toml_path_for_closure, None, ctx);
syncer.sync(ForceCloudToMatchLocal::No, ctx);
syncer
});
let initial_load = fake.snapshot_as_initial_load_response();
app.update(|ctx| {
update_manager.update(ctx, |um, ctx| {
um.mock_initial_load(initial_load, ctx);
});
});
drain_sync_queue(&mut app).await;
// Sanity: stored hash matches the initial file after the
// initial load completes.
let hash_after_initial_load = read_stored_hash(&app);
assert_eq!(
Some(initial_hash.clone()),
hash_after_initial_load,
"stored hash should match file after initial load",
);
// Phase 2: simulate an "offline UI change".
//
// Stop the SyncQueue from processing new items. This models
// the device being offline: the syncer will enqueue uploads
// but the queue won't actually send them to the server.
SyncQueue::handle(&app).update(&mut app, |sq, _| sq.stop_dequeueing());
// Write new file content (simulates what the TOML backend
// does when the setting model calls write_value).
let new_hash = write_settings_file_with_content(
&toml_path,
"# user changed a setting offline\nall_platforms = true\n",
);
assert_ne!(initial_hash, new_hash);
// Change the in-memory setting. In the #[cfg(test)] path this
// synchronously calls maybe_sync_local_prefs_to_cloud, which
// enqueues an upload to the SyncQueue — but the queue is
// stopped, so the upload won't be processed.
app.update(|ctx| {
TestSettings::handle(ctx).update(ctx, |test_settings, ctx| {
let _ = test_settings
.all_platforms_cloud_setting
.set_value(true, ctx);
});
});
// Give the syncer a moment to finish handling the setting
// change event.
galaxyui::r#async::Timer::after(Duration::from_millis(200)).await;
// CRITICAL ASSERTION: the stored hash must NOT have been
// updated. The upload is enqueued but the SyncQueue is
// stopped, so no ObjectUpdateSuccessful event fires and the
// hash stays at the pre-change value. This is the bug that
// was fixed — previously the hash was updated synchronously
// in maybe_sync_local_prefs_to_cloud, which caused the next
// startup to think the file was in sync with cloud.
assert_eq!(
Some(initial_hash),
read_stored_hash(&app),
"stored hash must NOT update before the upload succeeds",
);
// Verify that a hypothetical restart right now would detect
// divergence: current file hash != stored hash.
assert_ne!(
Some(new_hash.clone()),
read_stored_hash(&app),
"file hash should differ from stored hash while upload is pending",
);
// Phase 3: "come back online" — restart the sync queue so
// enqueued items get processed.
SyncQueue::handle(&app).update(&mut app, |sq, ctx| sq.start_dequeueing(ctx));
drain_sync_queue(&mut app).await;
// Now the stored hash should be updated to match the new file,
// because the SyncQueue success event fired and the syncer's
// subscription updated the hash.
assert_eq!(
Some(new_hash),
read_stored_hash(&app),
"stored hash should match new file after successful upload",
);
})
}