use serde::{Deserialize, Serialize};
use tauri::Runtime;
use tokio_util::sync::CancellationToken;
use crate::error::ServiceError;
use crate::notifier::Notifier;
pub const VALID_FOREGROUND_SERVICE_TYPES: &[&str] = &[
"dataSync",
"mediaPlayback",
"phoneCall",
"location",
"connectedDevice",
"mediaProjection",
"camera",
"microphone",
"health",
"remoteMessaging",
"systemExempted",
"shortService",
"specialUse",
"mediaProcessing",
];
pub fn validate_foreground_service_type(t: &str) -> Result<(), ServiceError> {
if VALID_FOREGROUND_SERVICE_TYPES.contains(&t) {
Ok(())
} else {
Err(ServiceError::Platform(format!(
"invalid foreground_service_type '{}'. Valid types: {:?}",
t, VALID_FOREGROUND_SERVICE_TYPES
)))
}
}
pub struct ServiceContext<R: Runtime> {
pub notifier: Notifier<R>,
pub app: tauri::AppHandle<R>,
pub shutdown: CancellationToken,
#[cfg(mobile)]
pub service_label: String,
#[cfg(mobile)]
pub foreground_service_type: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StartConfig {
#[serde(default = "default_label")]
pub service_label: String,
#[serde(default = "default_foreground_service_type")]
pub foreground_service_type: String,
}
fn default_label() -> String {
"Service running".into()
}
fn default_foreground_service_type() -> String {
"dataSync".into()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginConfig {
#[serde(default = "default_ios_safety_timeout")]
pub ios_safety_timeout_secs: f64,
#[serde(default = "default_ios_cancel_listener_timeout_secs")]
pub ios_cancel_listener_timeout_secs: u64,
#[serde(default = "default_ios_processing_safety_timeout_secs")]
pub ios_processing_safety_timeout_secs: f64,
#[serde(default = "default_ios_earliest_refresh_begin_minutes")]
pub ios_earliest_refresh_begin_minutes: f64,
#[serde(default = "default_ios_earliest_processing_begin_minutes")]
pub ios_earliest_processing_begin_minutes: f64,
#[serde(default)]
pub ios_requires_external_power: bool,
#[serde(default)]
pub ios_requires_network_connectivity: bool,
#[serde(default = "default_channel_capacity")]
pub channel_capacity: usize,
#[serde(default = "default_android_foreground_service_types")]
pub android_foreground_service_types: Vec<String>,
#[serde(default = "default_true")]
pub android_validate_foreground_service_type: bool,
#[serde(default = "default_android_on_timeout")]
pub android_on_timeout: String,
#[serde(default = "default_android_notification_channel_id")]
pub android_notification_channel_id: String,
#[serde(default = "default_android_notification_channel_name")]
pub android_notification_channel_name: String,
#[serde(default = "default_android_notification_id")]
pub android_notification_id: u32,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub android_notification_small_icon: Option<String>,
#[serde(default = "default_true")]
pub android_show_stop_action: bool,
#[cfg(feature = "desktop-service")]
#[serde(default = "default_desktop_service_mode")]
pub desktop_service_mode: String,
#[cfg(feature = "desktop-service")]
#[serde(default)]
pub desktop_service_label: Option<String>,
#[cfg(feature = "desktop-service")]
#[serde(default)]
pub desktop_service_autostart: bool,
#[cfg(feature = "desktop-service")]
#[serde(default)]
pub desktop_start_service_if_missing: bool,
#[cfg(feature = "desktop-service")]
#[serde(default = "default_desktop_service_start_timeout_ms")]
pub desktop_service_start_timeout_ms: u64,
}
fn default_ios_safety_timeout() -> f64 {
28.0
}
fn default_ios_cancel_listener_timeout_secs() -> u64 {
14400
}
fn default_ios_processing_safety_timeout_secs() -> f64 {
0.0
}
fn default_ios_earliest_refresh_begin_minutes() -> f64 {
15.0
}
fn default_ios_earliest_processing_begin_minutes() -> f64 {
15.0
}
fn default_android_foreground_service_types() -> Vec<String> {
vec!["dataSync".into()]
}
fn default_android_on_timeout() -> String {
"notifyUser".into()
}
fn default_android_notification_channel_id() -> String {
"bg_service".into()
}
fn default_android_notification_channel_name() -> String {
"Background Service".into()
}
fn default_android_notification_id() -> u32 {
9001
}
fn default_true() -> bool {
true
}
fn default_channel_capacity() -> usize {
16
}
#[cfg(feature = "desktop-service")]
fn default_desktop_service_mode() -> String {
"inProcess".into()
}
#[cfg(feature = "desktop-service")]
fn default_desktop_service_start_timeout_ms() -> u64 {
5000
}
impl Default for StartConfig {
fn default() -> Self {
Self {
service_label: default_label(),
foreground_service_type: default_foreground_service_type(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum ServiceState {
Idle,
Initializing,
Running,
Stopped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum NativeState {
Idle,
Starting,
Running,
Stopping,
Timeout,
Expired,
Recovering,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServiceStatus {
pub state: ServiceState,
pub last_error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub desired_running: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub native_state: Option<NativeState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform_mode: Option<LifecycleMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_start_config: Option<StartConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_heartbeat_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub restart_attempt: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub recovery_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform_error: Option<String>,
}
impl Default for ServiceStatus {
fn default() -> Self {
Self {
state: ServiceState::Idle,
last_error: None,
desired_running: None,
native_state: None,
platform_mode: None,
last_start_config: None,
last_heartbeat_at: None,
restart_attempt: None,
recovery_reason: None,
platform_error: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum Platform {
Android,
Ios,
Windows,
Macos,
Linux,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum LifecycleMode {
AndroidForegroundService,
IosBgTaskScheduler,
DesktopInProcess,
DesktopOsService,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum LifecycleGuarantee {
Guaranteed,
BestEffort,
Unsupported,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PlatformCapabilities {
pub platform: Platform,
pub lifecycle_mode: LifecycleMode,
pub survives_app_close: LifecycleGuarantee,
pub survives_reboot: LifecycleGuarantee,
pub survives_force_quit: LifecycleGuarantee,
pub background_execution: LifecycleGuarantee,
pub limitations: Vec<String>,
pub required_setup: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum OsServiceInstallState {
NotInstalled,
Installed,
Running,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct OsServiceStatus {
pub label: String,
pub mode: String,
pub installed: OsServiceInstallState,
pub ipc_connected: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub socket_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
#[non_exhaustive]
pub enum PluginEvent {
Started,
Stopped { reason: String },
Error { message: String },
}
impl Default for PluginConfig {
fn default() -> Self {
Self {
ios_safety_timeout_secs: default_ios_safety_timeout(),
ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
ios_requires_external_power: false,
ios_requires_network_connectivity: false,
channel_capacity: default_channel_capacity(),
android_foreground_service_types: default_android_foreground_service_types(),
android_validate_foreground_service_type: default_true(),
android_on_timeout: default_android_on_timeout(),
android_notification_channel_id: default_android_notification_channel_id(),
android_notification_channel_name: default_android_notification_channel_name(),
android_notification_id: default_android_notification_id(),
android_notification_small_icon: None,
android_show_stop_action: default_true(),
#[cfg(feature = "desktop-service")]
desktop_service_mode: default_desktop_service_mode(),
#[cfg(feature = "desktop-service")]
desktop_service_label: None,
#[cfg(feature = "desktop-service")]
desktop_service_autostart: false,
#[cfg(feature = "desktop-service")]
desktop_start_service_if_missing: false,
#[cfg(feature = "desktop-service")]
desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
}
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub(crate) struct StartKeepaliveArgs<'a> {
pub label: &'a str,
pub foreground_service_type: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub ios_safety_timeout_secs: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ios_processing_safety_timeout_secs: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ios_earliest_refresh_begin_minutes: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ios_earliest_processing_begin_minutes: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ios_requires_external_power: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ios_requires_network_connectivity: Option<bool>,
}
#[doc(hidden)]
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AutoStartConfig {
pub pending: bool,
pub label: Option<String>,
pub service_type: Option<String>,
}
impl AutoStartConfig {
pub fn into_start_config(self) -> Option<StartConfig> {
if self.pending {
self.label.map(|label| StartConfig {
service_label: label,
foreground_service_type: self
.service_type
.unwrap_or_else(default_foreground_service_type),
})
} else {
None
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PendingTaskInfo {
pub task_kind: String,
pub identifier: String,
pub received_at: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct IOSSchedulingStatus {
pub refresh_scheduled: bool,
pub processing_scheduled: bool,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub refresh_error: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub processing_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SetupIssue {
pub code: String,
pub message: String,
pub platform: Platform,
#[serde(skip_serializing_if = "Option::is_none")]
pub fix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct SetupValidationReport {
pub ok: bool,
pub errors: Vec<SetupIssue>,
pub warnings: Vec<SetupIssue>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn start_config_default_label() {
let config = StartConfig::default();
assert_eq!(config.service_label, "Service running");
}
#[test]
fn start_config_custom_label() {
let config = StartConfig {
service_label: "Syncing data".into(),
..Default::default()
};
assert_eq!(config.service_label, "Syncing data");
}
#[test]
fn start_config_serde_roundtrip_default() {
let config = StartConfig::default();
let json = serde_json::to_string(&config).unwrap();
let de: StartConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.service_label, config.service_label);
}
#[test]
fn start_config_serde_roundtrip_custom() {
let config = StartConfig {
service_label: "My service".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: StartConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.service_label, "My service");
}
#[test]
fn start_config_deserialize_missing_field_uses_default() {
let json = "{}";
let de: StartConfig = serde_json::from_str(json).unwrap();
assert_eq!(de.service_label, "Service running");
}
#[test]
fn start_config_json_key_is_camel_case() {
let config = StartConfig {
service_label: "test".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("serviceLabel"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_event_started_serde_roundtrip() {
let event = PluginEvent::Started;
let json = serde_json::to_string(&event).unwrap();
let de: PluginEvent = serde_json::from_str(&json).unwrap();
assert!(matches!(de, PluginEvent::Started));
}
#[test]
fn plugin_event_stopped_serde_roundtrip() {
let event = PluginEvent::Stopped {
reason: "cancelled".into(),
};
let json = serde_json::to_string(&event).unwrap();
let de: PluginEvent = serde_json::from_str(&json).unwrap();
match de {
PluginEvent::Stopped { reason } => assert_eq!(reason, "cancelled"),
other => panic!("Expected Stopped, got {other:?}"),
}
}
#[test]
fn plugin_event_error_serde_roundtrip() {
let event = PluginEvent::Error {
message: "init failed".into(),
};
let json = serde_json::to_string(&event).unwrap();
let de: PluginEvent = serde_json::from_str(&json).unwrap();
match de {
PluginEvent::Error { message } => assert_eq!(message, "init failed"),
other => panic!("Expected Error, got {other:?}"),
}
}
#[test]
fn plugin_event_tagged_json_format() {
let event = PluginEvent::Started;
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
}
#[test]
fn plugin_event_stopped_json_keys_camel_case() {
let event = PluginEvent::Stopped {
reason: "done".into(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
assert!(json.contains("\"reason\":\"done\""), "Reason: {json}");
}
#[test]
fn plugin_event_error_json_keys_camel_case() {
let event = PluginEvent::Error {
message: "oops".into(),
};
let json = serde_json::to_string(&event).unwrap();
assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
}
#[test]
fn start_config_default_service_type() {
let config = StartConfig::default();
assert_eq!(config.foreground_service_type, "dataSync");
}
#[test]
fn start_config_custom_service_type() {
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "specialUse".into(),
};
assert_eq!(config.foreground_service_type, "specialUse");
}
#[test]
fn start_config_serde_roundtrip_service_type() {
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "specialUse".into(),
};
let json = serde_json::to_string(&config).unwrap();
let de: StartConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.foreground_service_type, "specialUse");
}
#[test]
fn start_config_deserialize_missing_service_type() {
let json = r#"{"serviceLabel":"test"}"#;
let de: StartConfig = serde_json::from_str(json).unwrap();
assert_eq!(de.foreground_service_type, "dataSync");
}
#[test]
fn start_config_deserialize_special_use() {
let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
let de: StartConfig = serde_json::from_str(json).unwrap();
assert_eq!(de.foreground_service_type, "specialUse");
}
#[test]
fn start_config_unrecognized_type_rejected_by_validation() {
let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
let de: StartConfig = serde_json::from_str(json).unwrap();
assert_eq!(de.foreground_service_type, "customType");
let result = validate_foreground_service_type(&de.foreground_service_type);
assert!(
result.is_err(),
"validation should reject unrecognized type"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("customType"),
"error should mention the invalid type: {err_msg}"
);
}
#[test]
fn start_config_json_key_is_camel_case_service_type() {
let config = StartConfig {
service_label: "test".into(),
foreground_service_type: "specialUse".into(),
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("foregroundServiceType"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn auto_start_config_pending_with_label_returns_start_config() {
let json = r#"{"pending": true, "label": "Syncing"}"#;
let config: AutoStartConfig = serde_json::from_str(json).unwrap();
let result = config.into_start_config();
assert!(result.is_some());
let start_config = result.unwrap();
assert_eq!(start_config.service_label, "Syncing");
assert_eq!(start_config.foreground_service_type, "dataSync");
}
#[test]
fn auto_start_config_not_pending_returns_none() {
let json = r#"{"pending": false, "label": null}"#;
let config: AutoStartConfig = serde_json::from_str(json).unwrap();
let result = config.into_start_config();
assert!(result.is_none());
}
#[test]
fn auto_start_config_pending_no_label_returns_none() {
let json = r#"{"pending": true, "label": null}"#;
let config: AutoStartConfig = serde_json::from_str(json).unwrap();
let result = config.into_start_config();
assert!(result.is_none());
}
#[test]
fn auto_start_config_with_service_type_preserves_it() {
let json = r#"{"pending":true,"label":"test","serviceType":"specialUse"}"#;
let config: AutoStartConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.service_type, Some("specialUse".to_string()));
let result = config.into_start_config();
assert!(result.is_some());
let start_config = result.unwrap();
assert_eq!(start_config.foreground_service_type, "specialUse");
}
#[test]
fn auto_start_config_without_service_type_uses_default() {
let json = r#"{"pending":true,"label":"test"}"#;
let config: AutoStartConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.service_type, None);
let result = config.into_start_config();
assert!(result.is_some());
assert_eq!(result.unwrap().foreground_service_type, "dataSync");
}
#[test]
fn auto_start_config_null_service_type_uses_default() {
let json = r#"{"pending":true,"label":"test","serviceType":null}"#;
let config: AutoStartConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.service_type, None);
let result = config.into_start_config();
assert!(result.is_some());
assert_eq!(result.unwrap().foreground_service_type, "dataSync");
}
#[test]
fn plugin_config_default_ios_safety_timeout() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_safety_timeout_secs, 28.0);
}
#[test]
fn plugin_config_custom_ios_safety_timeout() {
let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_safety_timeout_secs, 15.0);
}
#[test]
fn plugin_config_serde_roundtrip_preserves_value() {
let config = PluginConfig {
ios_safety_timeout_secs: 30.0,
ios_cancel_listener_timeout_secs: 14400,
ios_processing_safety_timeout_secs: 0.0,
ios_earliest_refresh_begin_minutes: 20.0,
ios_earliest_processing_begin_minutes: 30.0,
ios_requires_external_power: true,
ios_requires_network_connectivity: true,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.ios_safety_timeout_secs, 30.0);
assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
assert!(de.ios_requires_external_power);
assert!(de.ios_requires_network_connectivity);
}
#[test]
fn plugin_config_default_impl() {
let config = PluginConfig::default();
assert_eq!(config.ios_safety_timeout_secs, 28.0);
assert_eq!(config.channel_capacity, 16);
}
#[test]
fn plugin_config_default_cancel_timeout() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
}
#[test]
fn plugin_config_custom_cancel_timeout() {
let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
}
#[test]
fn plugin_config_cancel_timeout_serde_roundtrip() {
let config = PluginConfig {
ios_cancel_listener_timeout_secs: 3600,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
}
#[test]
fn plugin_config_processing_timeout_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
}
#[test]
fn plugin_config_processing_timeout_custom() {
let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
}
#[test]
fn plugin_config_processing_timeout_serde_roundtrip() {
let config = PluginConfig {
ios_processing_safety_timeout_secs: 120.0,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
}
#[test]
fn start_keepalive_args_with_timeout() {
let args = StartKeepaliveArgs {
label: "Test",
foreground_service_type: "dataSync",
ios_safety_timeout_secs: Some(15.0),
ios_processing_safety_timeout_secs: None,
ios_earliest_refresh_begin_minutes: None,
ios_earliest_processing_begin_minutes: None,
ios_requires_external_power: None,
ios_requires_network_connectivity: None,
};
let json = serde_json::to_string(&args).unwrap();
assert!(
json.contains("\"iosSafetyTimeoutSecs\":15.0"),
"JSON should contain iosSafetyTimeoutSecs: {json}"
);
}
#[test]
fn start_keepalive_args_without_timeout() {
let args = StartKeepaliveArgs {
label: "Test",
foreground_service_type: "dataSync",
ios_safety_timeout_secs: None,
ios_processing_safety_timeout_secs: None,
ios_earliest_refresh_begin_minutes: None,
ios_earliest_processing_begin_minutes: None,
ios_requires_external_power: None,
ios_requires_network_connectivity: None,
};
let json = serde_json::to_string(&args).unwrap();
assert!(
!json.contains("iosSafetyTimeoutSecs"),
"JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
);
}
#[test]
fn start_keepalive_args_processing_timeout() {
let args = StartKeepaliveArgs {
label: "Test",
foreground_service_type: "dataSync",
ios_safety_timeout_secs: None,
ios_processing_safety_timeout_secs: Some(60.0),
ios_earliest_refresh_begin_minutes: None,
ios_earliest_processing_begin_minutes: None,
ios_requires_external_power: None,
ios_requires_network_connectivity: None,
};
let json = serde_json::to_string(&args).unwrap();
assert!(
json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
"JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
);
}
#[test]
fn start_keepalive_args_no_processing_timeout() {
let args = StartKeepaliveArgs {
label: "Test",
foreground_service_type: "dataSync",
ios_safety_timeout_secs: None,
ios_processing_safety_timeout_secs: None,
ios_earliest_refresh_begin_minutes: None,
ios_earliest_processing_begin_minutes: None,
ios_requires_external_power: None,
ios_requires_network_connectivity: None,
};
let json = serde_json::to_string(&args).unwrap();
assert!(
!json.contains("iosProcessingSafetyTimeoutSecs"),
"JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
);
}
#[test]
fn start_keepalive_args_camel_case_keys() {
let args = StartKeepaliveArgs {
label: "Test",
foreground_service_type: "specialUse",
ios_safety_timeout_secs: None,
ios_processing_safety_timeout_secs: None,
ios_earliest_refresh_begin_minutes: None,
ios_earliest_processing_begin_minutes: None,
ios_requires_external_power: None,
ios_requires_network_connectivity: None,
};
let json = serde_json::to_string(&args).unwrap();
assert!(json.contains("\"label\""), "label: {json}");
assert!(
json.contains("\"foregroundServiceType\""),
"foregroundServiceType: {json}"
);
}
#[test]
fn start_keepalive_args_scheduling_intervals() {
let args = StartKeepaliveArgs {
label: "Test",
foreground_service_type: "dataSync",
ios_safety_timeout_secs: None,
ios_processing_safety_timeout_secs: None,
ios_earliest_refresh_begin_minutes: Some(30.0),
ios_earliest_processing_begin_minutes: Some(60.0),
ios_requires_external_power: None,
ios_requires_network_connectivity: None,
};
let json = serde_json::to_string(&args).unwrap();
assert!(
json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
"JSON should contain iosEarliestRefreshBeginMinutes: {json}"
);
assert!(
json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
"JSON should contain iosEarliestProcessingBeginMinutes: {json}"
);
}
#[test]
fn start_keepalive_args_processing_options() {
let args = StartKeepaliveArgs {
label: "Test",
foreground_service_type: "dataSync",
ios_safety_timeout_secs: None,
ios_processing_safety_timeout_secs: None,
ios_earliest_refresh_begin_minutes: None,
ios_earliest_processing_begin_minutes: None,
ios_requires_external_power: Some(true),
ios_requires_network_connectivity: Some(true),
};
let json = serde_json::to_string(&args).unwrap();
assert!(
json.contains("\"iosRequiresExternalPower\":true"),
"JSON should contain iosRequiresExternalPower: {json}"
);
assert!(
json.contains("\"iosRequiresNetworkConnectivity\":true"),
"JSON should contain iosRequiresNetworkConnectivity: {json}"
);
}
#[test]
fn plugin_config_earliest_refresh_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
}
#[test]
fn plugin_config_earliest_processing_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
}
#[test]
fn plugin_config_requires_external_power_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(!config.ios_requires_external_power);
}
#[test]
fn plugin_config_requires_network_connectivity_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(!config.ios_requires_network_connectivity);
}
#[test]
fn plugin_config_custom_scheduling_intervals() {
let json =
r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
}
#[test]
fn plugin_config_custom_processing_options() {
let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(config.ios_requires_external_power);
assert!(config.ios_requires_network_connectivity);
}
#[test]
fn plugin_config_channel_capacity_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.channel_capacity, 16);
}
#[test]
fn plugin_config_channel_capacity_custom() {
let json = r#"{"channelCapacity":32}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.channel_capacity, 32);
}
#[test]
fn plugin_config_channel_capacity_serde_roundtrip() {
let config = PluginConfig {
channel_capacity: 64,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.channel_capacity, 64);
}
#[test]
fn plugin_config_channel_capacity_json_key_camel_case() {
let config = PluginConfig {
channel_capacity: 32,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("channelCapacity"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_fgs_types_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_foreground_service_types, vec!["dataSync"]);
}
#[test]
fn plugin_config_android_fgs_types_custom() {
let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.android_foreground_service_types,
vec!["dataSync", "specialUse"]
);
}
#[test]
fn plugin_config_android_fgs_types_serde_roundtrip() {
let config = PluginConfig {
android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(
de.android_foreground_service_types,
vec!["location", "connectedDevice"]
);
}
#[test]
fn plugin_config_android_fgs_types_json_key_camel_case() {
let config = PluginConfig {
android_foreground_service_types: vec!["specialUse".into()],
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidForegroundServiceTypes"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_validate_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(config.android_validate_foreground_service_type);
}
#[test]
fn plugin_config_android_validate_false() {
let json = r#"{"androidValidateForegroundServiceType":false}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(!config.android_validate_foreground_service_type);
}
#[test]
fn plugin_config_android_validate_serde_roundtrip() {
let config = PluginConfig {
android_validate_foreground_service_type: false,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert!(!de.android_validate_foreground_service_type);
}
#[test]
fn plugin_config_android_validate_json_key_camel_case() {
let config = PluginConfig {
android_validate_foreground_service_type: false,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidValidateForegroundServiceType"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_on_timeout_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_on_timeout, "notifyUser");
}
#[test]
fn plugin_config_android_on_timeout_custom() {
let json = r#"{"androidOnTimeout":"stop"}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_on_timeout, "stop");
}
#[test]
fn plugin_config_android_on_timeout_schedule_recovery() {
let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_on_timeout, "scheduleRecovery");
}
#[test]
fn plugin_config_android_on_timeout_serde_roundtrip() {
let config = PluginConfig {
android_on_timeout: "stop".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.android_on_timeout, "stop");
}
#[test]
fn plugin_config_android_on_timeout_json_key_camel_case() {
let config = PluginConfig {
android_on_timeout: "notifyUser".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidOnTimeout"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_notification_channel_id_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_notification_channel_id, "bg_service");
}
#[test]
fn plugin_config_android_notification_channel_id_custom() {
let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_notification_channel_id, "my_channel");
}
#[test]
fn plugin_config_android_notification_channel_id_serde_roundtrip() {
let config = PluginConfig {
android_notification_channel_id: "custom_ch".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.android_notification_channel_id, "custom_ch");
}
#[test]
fn plugin_config_android_notification_channel_id_json_key_camel_case() {
let config = PluginConfig {
android_notification_channel_id: "test".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidNotificationChannelId"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_notification_channel_name_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.android_notification_channel_name,
"Background Service"
);
}
#[test]
fn plugin_config_android_notification_channel_name_custom() {
let json = r#"{"androidNotificationChannelName":"My Service"}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_notification_channel_name, "My Service");
}
#[test]
fn plugin_config_android_notification_channel_name_serde_roundtrip() {
let config = PluginConfig {
android_notification_channel_name: "Sync Service".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.android_notification_channel_name, "Sync Service");
}
#[test]
fn plugin_config_android_notification_channel_name_json_key_camel_case() {
let config = PluginConfig {
android_notification_channel_name: "Test".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidNotificationChannelName"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_notification_id_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_notification_id, 9001);
}
#[test]
fn plugin_config_android_notification_id_custom() {
let json = r#"{"androidNotificationId":1234}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_notification_id, 1234);
}
#[test]
fn plugin_config_android_notification_id_serde_roundtrip() {
let config = PluginConfig {
android_notification_id: 42,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.android_notification_id, 42);
}
#[test]
fn plugin_config_android_notification_id_json_key_camel_case() {
let config = PluginConfig {
android_notification_id: 5555,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidNotificationId"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_notification_small_icon_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.android_notification_small_icon, None);
}
#[test]
fn plugin_config_android_notification_small_icon_custom() {
let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(
config.android_notification_small_icon,
Some("ic_notification".to_string())
);
}
#[test]
fn plugin_config_android_notification_small_icon_serde_roundtrip() {
let config = PluginConfig {
android_notification_small_icon: Some("my_icon".into()),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
}
#[test]
fn plugin_config_android_notification_small_icon_absent_when_none() {
let config = PluginConfig {
android_notification_small_icon: None,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
!json.contains("androidNotificationSmallIcon"),
"should be absent when None: {json}"
);
}
#[test]
fn plugin_config_android_notification_small_icon_json_key_camel_case() {
let config = PluginConfig {
android_notification_small_icon: Some("icon".into()),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidNotificationSmallIcon"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_show_stop_action_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(config.android_show_stop_action);
}
#[test]
fn plugin_config_android_show_stop_action_false() {
let json = r#"{"androidShowStopAction":false}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(!config.android_show_stop_action);
}
#[test]
fn plugin_config_android_show_stop_action_serde_roundtrip() {
let config = PluginConfig {
android_show_stop_action: false,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert!(!de.android_show_stop_action);
}
#[test]
fn plugin_config_android_show_stop_action_json_key_camel_case() {
let config = PluginConfig {
android_show_stop_action: false,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("androidShowStopAction"),
"JSON should use camelCase: {json}"
);
}
#[test]
fn plugin_config_android_timeout_notification_full_roundtrip() {
let config = PluginConfig {
android_on_timeout: "scheduleRecovery".into(),
android_notification_channel_id: "my_ch".into(),
android_notification_channel_name: "My Channel".into(),
android_notification_id: 42,
android_notification_small_icon: Some("ic_bg".into()),
android_show_stop_action: false,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.android_on_timeout, "scheduleRecovery");
assert_eq!(de.android_notification_channel_id, "my_ch");
assert_eq!(de.android_notification_channel_name, "My Channel");
assert_eq!(de.android_notification_id, 42);
assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
assert!(!de.android_show_stop_action);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_mode_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.desktop_service_mode, "inProcess");
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_mode_custom() {
let json = r#"{"desktopServiceMode":"osService"}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.desktop_service_mode, "osService");
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_mode_serde_roundtrip() {
let config = PluginConfig {
desktop_service_mode: "osService".into(),
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.desktop_service_mode, "osService");
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_label_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.desktop_service_label, None);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_label_custom() {
let json = r#"{"desktopServiceLabel":"my.svc"}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_autostart_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(!config.desktop_service_autostart);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_autostart_true() {
let json = r#"{"desktopServiceAutostart":true}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(config.desktop_service_autostart);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_autostart_serde_roundtrip() {
let config = PluginConfig {
desktop_service_autostart: true,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert!(de.desktop_service_autostart);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_autostart_json_key_camel_case() {
let config = PluginConfig {
desktop_service_autostart: true,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("desktopServiceAutostart"),
"JSON should use camelCase: {json}"
);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_if_missing_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(!config.desktop_start_service_if_missing);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_if_missing_true() {
let json = r#"{"desktopStartServiceIfMissing":true}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert!(config.desktop_start_service_if_missing);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
let config = PluginConfig {
desktop_start_service_if_missing: true,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert!(de.desktop_start_service_if_missing);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
let config = PluginConfig {
desktop_start_service_if_missing: true,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("desktopStartServiceIfMissing"),
"JSON should use camelCase: {json}"
);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_timeout_default() {
let json = "{}";
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.desktop_service_start_timeout_ms, 5000);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_timeout_custom() {
let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
let config: PluginConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.desktop_service_start_timeout_ms, 10000);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_timeout_serde_roundtrip() {
let config = PluginConfig {
desktop_service_start_timeout_ms: 15000,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert_eq!(de.desktop_service_start_timeout_ms, 15000);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_start_timeout_json_key_camel_case() {
let config = PluginConfig {
desktop_service_start_timeout_ms: 3000,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
assert!(
json.contains("desktopServiceStartTimeoutMs"),
"JSON should use camelCase: {json}"
);
}
#[cfg(feature = "desktop-service")]
#[test]
fn plugin_config_desktop_all_new_fields_roundtrip() {
let config = PluginConfig {
desktop_service_autostart: true,
desktop_start_service_if_missing: true,
desktop_service_start_timeout_ms: 8000,
..Default::default()
};
let json = serde_json::to_string(&config).unwrap();
let de: PluginConfig = serde_json::from_str(&json).unwrap();
assert!(de.desktop_service_autostart);
assert!(de.desktop_start_service_if_missing);
assert_eq!(de.desktop_service_start_timeout_ms, 8000);
}
use tauri::AppHandle;
#[cfg(mobile)]
#[allow(dead_code)]
fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
let ctx = ServiceContext {
notifier: Notifier { app: app.clone() },
app,
shutdown: CancellationToken::new(),
service_label: "Syncing".into(),
foreground_service_type: "dataSync".into(),
};
assert_eq!(ctx.service_label, "Syncing");
assert_eq!(ctx.foreground_service_type, "dataSync");
}
#[cfg(not(mobile))]
#[allow(dead_code)]
fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
let ctx = ServiceContext {
notifier: Notifier { app: app.clone() },
app,
shutdown: CancellationToken::new(),
};
let _ = ctx;
}
#[test]
fn validate_data_sync_passes() {
assert!(
validate_foreground_service_type("dataSync").is_ok(),
"dataSync should be valid"
);
}
#[test]
fn validate_special_use_passes() {
assert!(
validate_foreground_service_type("specialUse").is_ok(),
"specialUse should be valid"
);
}
#[test]
fn validate_invalid_type_returns_platform_error() {
let result = validate_foreground_service_type("invalidType");
assert!(result.is_err(), "invalidType should be rejected");
match result {
Err(crate::error::ServiceError::Platform(msg)) => {
assert!(
msg.contains("invalidType"),
"error should mention the type: {msg}"
);
}
other => panic!("Expected Platform error, got: {other:?}"),
}
}
#[test]
fn validate_all_14_types_pass() {
for &t in VALID_FOREGROUND_SERVICE_TYPES {
assert!(
validate_foreground_service_type(t).is_ok(),
"{t} should be valid"
);
}
}
#[test]
fn valid_types_count_is_14() {
assert_eq!(
VALID_FOREGROUND_SERVICE_TYPES.len(),
14,
"should have exactly 14 valid types"
);
}
#[test]
fn validate_empty_string_returns_error() {
let result = validate_foreground_service_type("");
assert!(result.is_err(), "empty string should be rejected");
}
#[test]
fn validate_case_sensitive() {
let result = validate_foreground_service_type("DataSync");
assert!(
result.is_err(),
"validation should be case-sensitive: DataSync should fail"
);
}
#[test]
fn service_state_idle_serde_roundtrip() {
let state = ServiceState::Idle;
let json = serde_json::to_string(&state).unwrap();
let de: ServiceState = serde_json::from_str(&json).unwrap();
assert_eq!(de, ServiceState::Idle);
}
#[test]
fn service_state_initializing_serde_roundtrip() {
let state = ServiceState::Initializing;
let json = serde_json::to_string(&state).unwrap();
let de: ServiceState = serde_json::from_str(&json).unwrap();
assert_eq!(de, ServiceState::Initializing);
}
#[test]
fn service_state_running_serde_roundtrip() {
let state = ServiceState::Running;
let json = serde_json::to_string(&state).unwrap();
let de: ServiceState = serde_json::from_str(&json).unwrap();
assert_eq!(de, ServiceState::Running);
}
#[test]
fn service_state_stopped_serde_roundtrip() {
let state = ServiceState::Stopped;
let json = serde_json::to_string(&state).unwrap();
let de: ServiceState = serde_json::from_str(&json).unwrap();
assert_eq!(de, ServiceState::Stopped);
}
#[test]
fn service_state_json_values_are_camel_case() {
assert_eq!(
serde_json::to_string(&ServiceState::Idle).unwrap(),
"\"idle\""
);
assert_eq!(
serde_json::to_string(&ServiceState::Initializing).unwrap(),
"\"initializing\""
);
assert_eq!(
serde_json::to_string(&ServiceState::Running).unwrap(),
"\"running\""
);
assert_eq!(
serde_json::to_string(&ServiceState::Stopped).unwrap(),
"\"stopped\""
);
}
#[test]
fn service_status_serde_roundtrip_idle() {
let status = ServiceStatus {
state: ServiceState::Idle,
..Default::default()
};
let json = serde_json::to_string(&status).unwrap();
let de: ServiceStatus = serde_json::from_str(&json).unwrap();
assert_eq!(de.state, ServiceState::Idle);
assert_eq!(de.last_error, None);
}
#[test]
fn service_status_serde_roundtrip_with_error() {
let status = ServiceStatus {
state: ServiceState::Stopped,
last_error: Some("init failed".into()),
..Default::default()
};
let json = serde_json::to_string(&status).unwrap();
let de: ServiceStatus = serde_json::from_str(&json).unwrap();
assert_eq!(de.state, ServiceState::Stopped);
assert_eq!(de.last_error, Some("init failed".into()));
}
#[test]
fn service_status_json_keys_camel_case() {
let status = ServiceStatus {
state: ServiceState::Running,
..Default::default()
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"state\":"), "state key: {json}");
assert!(json.contains("\"lastError\":"), "lastError key: {json}");
}
#[test]
fn service_status_json_null_last_error() {
let status = ServiceStatus {
state: ServiceState::Idle,
..Default::default()
};
let json = serde_json::to_string(&status).unwrap();
assert!(
json.contains("\"lastError\":null"),
"lastError should be null: {json}"
);
}
#[test]
fn platform_serde_roundtrip() {
for variant in [
Platform::Android,
Platform::Ios,
Platform::Windows,
Platform::Macos,
Platform::Linux,
Platform::Unknown,
] {
let json = serde_json::to_string(&variant).unwrap();
let de: Platform = serde_json::from_str(&json).unwrap();
assert_eq!(de, variant);
}
}
#[test]
fn platform_json_values_are_camel_case() {
assert_eq!(
serde_json::to_string(&Platform::Android).unwrap(),
"\"android\""
);
assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
assert_eq!(
serde_json::to_string(&Platform::Windows).unwrap(),
"\"windows\""
);
assert_eq!(
serde_json::to_string(&Platform::Macos).unwrap(),
"\"macos\""
);
assert_eq!(
serde_json::to_string(&Platform::Linux).unwrap(),
"\"linux\""
);
assert_eq!(
serde_json::to_string(&Platform::Unknown).unwrap(),
"\"unknown\""
);
}
#[test]
fn lifecycle_mode_serde_roundtrip() {
for variant in [
LifecycleMode::AndroidForegroundService,
LifecycleMode::IosBgTaskScheduler,
LifecycleMode::DesktopInProcess,
LifecycleMode::DesktopOsService,
] {
let json = serde_json::to_string(&variant).unwrap();
let de: LifecycleMode = serde_json::from_str(&json).unwrap();
assert_eq!(de, variant);
}
}
#[test]
fn lifecycle_mode_json_values_are_camel_case() {
assert_eq!(
serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
"\"androidForegroundService\""
);
assert_eq!(
serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
"\"iosBgTaskScheduler\""
);
assert_eq!(
serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
"\"desktopInProcess\""
);
assert_eq!(
serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
"\"desktopOsService\""
);
}
#[test]
fn lifecycle_guarantee_serde_roundtrip() {
for variant in [
LifecycleGuarantee::Guaranteed,
LifecycleGuarantee::BestEffort,
LifecycleGuarantee::Unsupported,
] {
let json = serde_json::to_string(&variant).unwrap();
let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
assert_eq!(de, variant);
}
}
#[test]
fn lifecycle_guarantee_json_values_are_camel_case() {
assert_eq!(
serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
"\"guaranteed\""
);
assert_eq!(
serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
"\"bestEffort\""
);
assert_eq!(
serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
"\"unsupported\""
);
}
#[test]
fn platform_capabilities_serde_roundtrip() {
let caps = PlatformCapabilities {
platform: Platform::Android,
lifecycle_mode: LifecycleMode::AndroidForegroundService,
survives_app_close: LifecycleGuarantee::BestEffort,
survives_reboot: LifecycleGuarantee::BestEffort,
survives_force_quit: LifecycleGuarantee::Unsupported,
background_execution: LifecycleGuarantee::Guaranteed,
limitations: vec!["OEM battery optimization".into()],
required_setup: vec!["FOREGROUND_SERVICE permission".into()],
};
let json = serde_json::to_string(&caps).unwrap();
let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
assert_eq!(de, caps);
}
#[test]
fn platform_capabilities_json_keys_camel_case() {
let caps = PlatformCapabilities {
platform: Platform::Linux,
lifecycle_mode: LifecycleMode::DesktopInProcess,
survives_app_close: LifecycleGuarantee::Unsupported,
survives_reboot: LifecycleGuarantee::Unsupported,
survives_force_quit: LifecycleGuarantee::Unsupported,
background_execution: LifecycleGuarantee::Guaranteed,
limitations: vec![],
required_setup: vec![],
};
let json = serde_json::to_string(&caps).unwrap();
assert!(json.contains("\"platform\":"), "platform: {json}");
assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
assert!(
json.contains("\"survivesAppClose\":"),
"survivesAppClose: {json}"
);
assert!(
json.contains("\"survivesReboot\":"),
"survivesReboot: {json}"
);
assert!(
json.contains("\"survivesForceQuit\":"),
"survivesForceQuit: {json}"
);
assert!(
json.contains("\"backgroundExecution\":"),
"backgroundExecution: {json}"
);
assert!(json.contains("\"limitations\":"), "limitations: {json}");
assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
}
#[test]
fn platform_capabilities_empty_collections_serialize() {
let caps = PlatformCapabilities {
platform: Platform::Unknown,
lifecycle_mode: LifecycleMode::DesktopInProcess,
survives_app_close: LifecycleGuarantee::Unsupported,
survives_reboot: LifecycleGuarantee::Unsupported,
survives_force_quit: LifecycleGuarantee::Unsupported,
background_execution: LifecycleGuarantee::Unsupported,
limitations: vec![],
required_setup: vec![],
};
let json = serde_json::to_string(&caps).unwrap();
assert!(json.contains("\"limitations\":[]"), "{json}");
assert!(json.contains("\"requiredSetup\":[]"), "{json}");
}
#[test]
fn native_state_serde_roundtrip() {
for variant in [
NativeState::Idle,
NativeState::Starting,
NativeState::Running,
NativeState::Stopping,
NativeState::Timeout,
NativeState::Expired,
NativeState::Recovering,
NativeState::Error,
] {
let json = serde_json::to_string(&variant).unwrap();
let de: NativeState = serde_json::from_str(&json).unwrap();
assert_eq!(de, variant, "roundtrip failed for {variant:?}");
}
}
#[test]
fn native_state_json_values_are_camel_case() {
assert_eq!(
serde_json::to_string(&NativeState::Idle).unwrap(),
"\"idle\""
);
assert_eq!(
serde_json::to_string(&NativeState::Starting).unwrap(),
"\"starting\""
);
assert_eq!(
serde_json::to_string(&NativeState::Running).unwrap(),
"\"running\""
);
assert_eq!(
serde_json::to_string(&NativeState::Stopping).unwrap(),
"\"stopping\""
);
assert_eq!(
serde_json::to_string(&NativeState::Timeout).unwrap(),
"\"timeout\""
);
assert_eq!(
serde_json::to_string(&NativeState::Expired).unwrap(),
"\"expired\""
);
assert_eq!(
serde_json::to_string(&NativeState::Recovering).unwrap(),
"\"recovering\""
);
assert_eq!(
serde_json::to_string(&NativeState::Error).unwrap(),
"\"error\""
);
}
#[test]
fn service_status_backward_compat_deserialize_old_json() {
let old_json = r#"{"state":"running","lastError":null}"#;
let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
assert_eq!(status.state, ServiceState::Running);
assert_eq!(status.last_error, None);
assert_eq!(status.desired_running, None);
assert_eq!(status.native_state, None);
assert_eq!(status.platform_mode, None);
assert_eq!(status.last_start_config, None);
assert_eq!(status.last_heartbeat_at, None);
assert_eq!(status.restart_attempt, None);
assert_eq!(status.recovery_reason, None);
assert_eq!(status.platform_error, None);
}
#[test]
fn service_status_new_fields_serialize_when_present() {
let status = ServiceStatus {
state: ServiceState::Running,
last_error: None,
desired_running: Some(true),
native_state: Some(NativeState::Running),
platform_mode: Some(LifecycleMode::AndroidForegroundService),
last_start_config: Some(StartConfig::default()),
last_heartbeat_at: Some(1234567890),
restart_attempt: Some(2),
recovery_reason: Some("boot recovery".into()),
platform_error: Some("timeout exceeded".into()),
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"desiredRunning\":true"), "{json}");
assert!(json.contains("\"nativeState\":\"running\""), "{json}");
assert!(
json.contains("\"platformMode\":\"androidForegroundService\""),
"{json}"
);
assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
assert!(json.contains("\"restartAttempt\":2"), "{json}");
assert!(
json.contains("\"recoveryReason\":\"boot recovery\""),
"{json}"
);
assert!(
json.contains("\"platformError\":\"timeout exceeded\""),
"{json}"
);
}
#[test]
fn service_status_new_fields_absent_when_none() {
let status = ServiceStatus {
state: ServiceState::Idle,
last_error: None,
desired_running: None,
native_state: None,
platform_mode: None,
last_start_config: None,
last_heartbeat_at: None,
restart_attempt: None,
recovery_reason: None,
platform_error: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(!json.contains("desiredRunning"), "should be absent: {json}");
assert!(!json.contains("nativeState"), "should be absent: {json}");
assert!(!json.contains("platformMode"), "should be absent: {json}");
assert!(
!json.contains("lastStartConfig"),
"should be absent: {json}"
);
assert!(
!json.contains("lastHeartbeatAt"),
"should be absent: {json}"
);
assert!(!json.contains("restartAttempt"), "should be absent: {json}");
assert!(!json.contains("recoveryReason"), "should be absent: {json}");
assert!(!json.contains("platformError"), "should be absent: {json}");
}
#[test]
fn service_status_default_impl() {
let status = ServiceStatus::default();
assert_eq!(status.state, ServiceState::Idle);
assert_eq!(status.last_error, None);
assert_eq!(status.desired_running, None);
assert_eq!(status.native_state, None);
assert_eq!(status.platform_mode, None);
assert_eq!(status.last_start_config, None);
assert_eq!(status.last_heartbeat_at, None);
assert_eq!(status.restart_attempt, None);
assert_eq!(status.recovery_reason, None);
assert_eq!(status.platform_error, None);
}
#[test]
fn service_status_full_roundtrip_with_all_fields() {
let status = ServiceStatus {
state: ServiceState::Running,
last_error: Some("previous crash".into()),
desired_running: Some(true),
native_state: Some(NativeState::Recovering),
platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
last_start_config: Some(StartConfig {
service_label: "Sync".into(),
foreground_service_type: "dataSync".into(),
}),
last_heartbeat_at: Some(999),
restart_attempt: Some(3),
recovery_reason: Some("force stop".into()),
platform_error: Some("scheduler busy".into()),
};
let json = serde_json::to_string(&status).unwrap();
let de: ServiceStatus = serde_json::from_str(&json).unwrap();
assert_eq!(de.state, ServiceState::Running);
assert_eq!(de.last_error, Some("previous crash".into()));
assert_eq!(de.desired_running, Some(true));
assert_eq!(de.native_state, Some(NativeState::Recovering));
assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
assert!(de.last_start_config.is_some());
assert_eq!(de.last_heartbeat_at, Some(999));
assert_eq!(de.restart_attempt, Some(3));
assert_eq!(de.recovery_reason, Some("force stop".into()));
assert_eq!(de.platform_error, Some("scheduler busy".into()));
}
#[test]
fn platform_capabilities_deserialize_from_json() {
let json = r#"{
"platform":"ios",
"lifecycleMode":"iosBgTaskScheduler",
"survivesAppClose":"bestEffort",
"survivesReboot":"bestEffort",
"survivesForceQuit":"unsupported",
"backgroundExecution":"bestEffort",
"limitations":["Cannot guarantee continuous execution"],
"requiredSetup":["UIBackgroundModes in Info.plist"]
}"#;
let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
assert_eq!(caps.platform, Platform::Ios);
assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
assert_eq!(caps.limitations.len(), 1);
assert_eq!(caps.required_setup.len(), 1);
}
#[test]
fn ios_scheduling_status_both_scheduled() {
let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
assert!(status.refresh_scheduled);
assert!(status.processing_scheduled);
assert_eq!(status.refresh_error, None);
assert_eq!(status.processing_error, None);
}
#[test]
fn ios_scheduling_status_partial_success() {
let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
assert!(status.refresh_scheduled);
assert!(!status.processing_scheduled);
assert_eq!(status.refresh_error, None);
assert_eq!(status.processing_error, Some("not permitted".to_string()));
}
#[test]
fn ios_scheduling_status_with_errors() {
let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
assert!(!status.refresh_scheduled);
assert!(!status.processing_scheduled);
assert_eq!(status.refresh_error, Some("err1".to_string()));
assert_eq!(status.processing_error, Some("err2".to_string()));
}
#[test]
fn ios_scheduling_status_serde_roundtrip() {
let status = IOSSchedulingStatus {
refresh_scheduled: true,
processing_scheduled: false,
refresh_error: None,
processing_error: Some("busy".into()),
};
let json = serde_json::to_string(&status).unwrap();
let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
assert_eq!(de, status);
}
#[test]
fn ios_scheduling_status_json_keys_camel_case() {
let status = IOSSchedulingStatus {
refresh_scheduled: true,
processing_scheduled: true,
refresh_error: Some("err".into()),
processing_error: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"refreshScheduled\":"), "{json}");
assert!(json.contains("\"processingScheduled\":"), "{json}");
assert!(json.contains("\"refreshError\":"), "{json}");
assert!(
!json.contains("processingError"),
"None fields should be absent: {json}"
);
}
#[test]
fn ios_scheduling_status_from_value_null_errors() {
let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
assert!(status.refresh_scheduled);
assert!(status.processing_scheduled);
assert_eq!(status.refresh_error, None);
assert_eq!(status.processing_error, None);
}
#[test]
fn ios_scheduling_status_from_value_missing_errors() {
let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
assert!(status.refresh_scheduled);
assert!(status.processing_scheduled);
assert_eq!(status.refresh_error, None);
assert_eq!(status.processing_error, None);
}
#[test]
fn os_service_install_state_serde_roundtrip() {
for variant in [
OsServiceInstallState::NotInstalled,
OsServiceInstallState::Installed,
OsServiceInstallState::Running,
] {
let json = serde_json::to_string(&variant).unwrap();
let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
assert_eq!(de, variant, "roundtrip failed for {variant:?}");
}
}
#[test]
fn os_service_install_state_json_values_camel_case() {
assert_eq!(
serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
"\"notInstalled\""
);
assert_eq!(
serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
"\"installed\""
);
assert_eq!(
serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
"\"running\""
);
}
#[test]
fn os_service_status_serde_roundtrip() {
let status = OsServiceStatus {
label: "com.example.bg-service".into(),
mode: "systemd".into(),
installed: OsServiceInstallState::Running,
ipc_connected: true,
socket_path: Some("/tmp/test.sock".into()),
last_error: None,
};
let json = serde_json::to_string(&status).unwrap();
let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
assert_eq!(de.label, "com.example.bg-service");
assert_eq!(de.mode, "systemd");
assert_eq!(de.installed, OsServiceInstallState::Running);
assert!(de.ipc_connected);
assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
assert_eq!(de.last_error, None);
}
#[test]
fn os_service_status_json_keys_camel_case() {
let status = OsServiceStatus {
label: "test".into(),
mode: "launchd".into(),
installed: OsServiceInstallState::Installed,
ipc_connected: false,
socket_path: Some("/run/test.sock".into()),
last_error: Some("timeout".into()),
};
let json = serde_json::to_string(&status).unwrap();
assert!(json.contains("\"label\":"), "{json}");
assert!(json.contains("\"mode\":"), "{json}");
assert!(json.contains("\"installed\":"), "{json}");
assert!(json.contains("\"ipcConnected\":"), "{json}");
assert!(json.contains("\"socketPath\":"), "{json}");
assert!(json.contains("\"lastError\":"), "{json}");
}
#[test]
fn os_service_status_optional_fields_absent_when_none() {
let status = OsServiceStatus {
label: "test".into(),
mode: "systemd".into(),
installed: OsServiceInstallState::NotInstalled,
ipc_connected: false,
socket_path: None,
last_error: None,
};
let json = serde_json::to_string(&status).unwrap();
assert!(!json.contains("socketPath"), "should be absent: {json}");
assert!(!json.contains("lastError"), "should be absent: {json}");
}
#[test]
fn os_service_status_with_all_optional_fields() {
let status = OsServiceStatus {
label: "com.test".into(),
mode: "launchd".into(),
installed: OsServiceInstallState::Running,
ipc_connected: true,
socket_path: Some("/var/run/com.test.sock".into()),
last_error: Some("connection refused".into()),
};
let json = serde_json::to_string(&status).unwrap();
assert!(
json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
"{json}"
);
assert!(
json.contains("\"lastError\":\"connection refused\""),
"{json}"
);
}
#[test]
fn os_service_status_deserialize_from_json() {
let json = r#"{
"label":"com.example.svc",
"mode":"systemd",
"installed":"running",
"ipcConnected":true,
"socketPath":"/tmp/test.sock"
}"#;
let status: OsServiceStatus = serde_json::from_str(json).unwrap();
assert_eq!(status.label, "com.example.svc");
assert_eq!(status.mode, "systemd");
assert_eq!(status.installed, OsServiceInstallState::Running);
assert!(status.ipc_connected);
assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
assert_eq!(status.last_error, None);
}
#[test]
fn pending_task_info_serde_roundtrip() {
let info = PendingTaskInfo {
task_kind: "refresh".into(),
identifier: "com.example.app.bg-refresh".into(),
received_at: 1700000000.123,
};
let json = serde_json::to_string(&info).unwrap();
let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
assert_eq!(de, info);
}
#[test]
fn pending_task_info_json_keys_camel_case() {
let info = PendingTaskInfo {
task_kind: "processing".into(),
identifier: "test-id".into(),
received_at: 123456.0,
};
let json = serde_json::to_string(&info).unwrap();
assert!(json.contains("\"taskKind\":"), "{json}");
assert!(json.contains("\"identifier\":"), "{json}");
assert!(json.contains("\"receivedAt\":"), "{json}");
}
#[test]
fn pending_task_info_from_native_response() {
let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.task_kind, "refresh");
assert_eq!(info.identifier, "com.example.bg-refresh");
assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
}
#[test]
fn pending_task_info_processing_kind() {
let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.task_kind, "processing");
assert_eq!(info.identifier, "com.example.bg-processing");
}
}