Skip to main content

tauri_plugin_background_service/
models.rs

1//! Data types shared between the plugin's Rust core and the JS/Tauri layer.
2//!
3//! - [`ServiceContext`] is passed to every [`BackgroundService`](crate::BackgroundService) method.
4//! - [`StartConfig`] and [`PluginConfig`] control service and plugin behaviour.
5//! - [`PluginEvent`] represents events emitted to the JavaScript front-end.
6
7use serde::{Deserialize, Serialize};
8use tauri::Runtime;
9use tokio_util::sync::CancellationToken;
10
11use crate::error::ServiceError;
12use crate::notifier::Notifier;
13
14/// The 14 valid Android foreground service types.
15///
16/// Sourced from <https://developer.android.com/about/versions/14/changes/fg-types>.
17/// Unknown types are rejected with [`ServiceError::Platform`] at both the Rust
18/// and Kotlin layers.
19pub const VALID_FOREGROUND_SERVICE_TYPES: &[&str] = &[
20    "dataSync",
21    "mediaPlayback",
22    "phoneCall",
23    "location",
24    "connectedDevice",
25    "mediaProjection",
26    "camera",
27    "microphone",
28    "health",
29    "remoteMessaging",
30    "systemExempted",
31    "shortService",
32    "specialUse",
33    "mediaProcessing",
34];
35
36/// Validate a foreground service type against the allowlist.
37///
38/// Returns `Ok(())` if the type is one of the 14 valid Android foreground
39/// service types, or `Err(ServiceError::Platform)` for unknown strings.
40pub fn validate_foreground_service_type(t: &str) -> Result<(), ServiceError> {
41    if VALID_FOREGROUND_SERVICE_TYPES.contains(&t) {
42        Ok(())
43    } else {
44        Err(ServiceError::Platform(format!(
45            "invalid foreground_service_type '{}'. Valid types: {:?}",
46            t, VALID_FOREGROUND_SERVICE_TYPES
47        )))
48    }
49}
50
51/// Passed into both `init` and `run`.
52/// Gives your service everything it needs to interact with the outside world.
53pub struct ServiceContext<R: Runtime> {
54    /// Fire a local notification. Works on all platforms.
55    pub notifier: Notifier<R>,
56
57    /// Emit an event to the JS UI layer.
58    pub app: tauri::AppHandle<R>,
59
60    /// Cancelled when `stopService()` is called.
61    pub shutdown: CancellationToken,
62
63    /// Text shown in the Android persistent notification.
64    /// Only available on mobile platforms.
65    #[cfg(mobile)]
66    pub service_label: String,
67
68    /// Android foreground service type (e.g. "dataSync", "specialUse").
69    /// Only available on mobile platforms.
70    #[cfg(mobile)]
71    pub foreground_service_type: String,
72}
73
74/// Optional startup configuration forwarded from JS through the plugin.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76#[serde(rename_all = "camelCase")]
77pub struct StartConfig {
78    /// Text shown in the Android persistent foreground notification.
79    #[serde(default = "default_label")]
80    pub service_label: String,
81
82    /// Android foreground service type (e.g. "dataSync", "specialUse").
83    #[serde(default = "default_foreground_service_type")]
84    pub foreground_service_type: String,
85}
86
87fn default_label() -> String {
88    "Service running".into()
89}
90
91fn default_foreground_service_type() -> String {
92    "dataSync".into()
93}
94
95/// Plugin-level configuration, deserialized from the Tauri plugin config.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct PluginConfig {
99    /// iOS safety timeout in seconds for the expiration handler.
100    /// Default: 28.0 (Apple recommends keeping BG tasks under ~30s).
101    #[serde(default = "default_ios_safety_timeout")]
102    pub ios_safety_timeout_secs: f64,
103
104    /// iOS cancel listener timeout in seconds.
105    /// Default: 14400 (4 hours). Balances leak risk vs. service lifetime.
106    #[serde(default = "default_ios_cancel_listener_timeout_secs")]
107    pub ios_cancel_listener_timeout_secs: u64,
108
109    /// iOS BGProcessingTask safety timeout in seconds.
110    /// Default: 0.0 (no cap). Processing tasks can run for minutes/hours,
111    /// so unlike BGAppRefreshTask (28s default), this defaults to uncapped.
112    /// Set to a positive value to impose a hard cap on processing task duration.
113    #[serde(default = "default_ios_processing_safety_timeout_secs")]
114    pub ios_processing_safety_timeout_secs: f64,
115
116    /// iOS `BGAppRefreshTask` earliest begin date in minutes from now.
117    /// Default: 15.0. Controls how soon iOS can launch the refresh task.
118    #[serde(default = "default_ios_earliest_refresh_begin_minutes")]
119    pub ios_earliest_refresh_begin_minutes: f64,
120
121    /// iOS `BGProcessingTask` earliest begin date in minutes from now.
122    /// Default: 15.0. Controls how soon iOS can launch the processing task.
123    #[serde(default = "default_ios_earliest_processing_begin_minutes")]
124    pub ios_earliest_processing_begin_minutes: f64,
125
126    /// iOS `BGProcessingTask` requires external power.
127    /// Default: false. When true, iOS only launches the processing task
128    /// while the device is connected to power.
129    #[serde(default)]
130    pub ios_requires_external_power: bool,
131
132    /// iOS `BGProcessingTask` requires network connectivity.
133    /// Default: false. When true, iOS only launches the processing task
134    /// when the device has network access.
135    #[serde(default)]
136    pub ios_requires_network_connectivity: bool,
137
138    /// Capacity for the manager command channel (mpsc).
139    /// Default: 16. Increase for high-throughput scenarios with many
140    /// concurrent start/stop/is-running calls.
141    #[serde(default = "default_channel_capacity")]
142    pub channel_capacity: usize,
143
144    /// Android foreground service types allowed for `startService()`.
145    /// Default: `["dataSync"]`. The preflight validation rejects any type
146    /// not in this list when `android_validate_foreground_service_type` is true.
147    #[serde(default = "default_android_foreground_service_types")]
148    pub android_foreground_service_types: Vec<String>,
149
150    /// Whether to validate the requested foreground service type against
151    /// `android_foreground_service_types` before starting the native service.
152    /// Default: true. Set to false to skip the allowlist check.
153    #[serde(default = "default_true")]
154    pub android_validate_foreground_service_type: bool,
155
156    /// Timeout policy for Android foreground service.
157    /// Values: "stop", "notifyUser" (default), "scheduleRecovery".
158    /// - "stop": set desiredRunning=false, stop service.
159    /// - "notifyUser": stop service, post timeout notification, keep desiredRunning=true.
160    /// - "scheduleRecovery": stop service, set recoveryPending=true, attempt reschedule.
161    #[serde(default = "default_android_on_timeout")]
162    pub android_on_timeout: String,
163
164    /// Android notification channel ID for the foreground service notification.
165    /// Default: "bg_service".
166    #[serde(default = "default_android_notification_channel_id")]
167    pub android_notification_channel_id: String,
168
169    /// Android notification channel name (visible to the user in system settings).
170    /// Default: "Background Service".
171    #[serde(default = "default_android_notification_channel_name")]
172    pub android_notification_channel_name: String,
173
174    /// Android notification ID for the foreground service notification.
175    /// Default: 9001.
176    #[serde(default = "default_android_notification_id")]
177    pub android_notification_id: u32,
178
179    /// Custom small icon resource name for the foreground notification.
180    /// When `None`, the system default (`android.R.drawable.ic_dialog_info`) is used.
181    #[serde(default)]
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub android_notification_small_icon: Option<String>,
184
185    /// Whether to show a stop action button on the foreground notification.
186    /// Default: true.
187    #[serde(default = "default_true")]
188    pub android_show_stop_action: bool,
189
190    /// Desktop service mode: "inProcess" (default) or "osService".
191    /// Controls whether the background service runs in-process or as a
192    /// registered OS service/daemon.
193    #[cfg(feature = "desktop-service")]
194    #[serde(default = "default_desktop_service_mode")]
195    pub desktop_service_mode: String,
196
197    /// Optional custom label for the desktop OS service registration.
198    /// When `None`, the label is auto-derived from the app identifier.
199    #[cfg(feature = "desktop-service")]
200    #[serde(default)]
201    pub desktop_service_label: Option<String>,
202
203    /// Whether the OS service should start automatically on boot (Linux) or
204    /// login (macOS). Only applies when `desktop_service_mode` is `"osService"`.
205    /// Default: false.
206    #[cfg(feature = "desktop-service")]
207    #[serde(default)]
208    pub desktop_service_autostart: bool,
209
210    /// When `true`, calling `startService()` will automatically start the OS
211    /// service if it is not already running (i.e. IPC is disconnected).
212    /// Only applies when `desktop_service_mode` is `"osService"`.
213    /// Default: false.
214    #[cfg(feature = "desktop-service")]
215    #[serde(default)]
216    pub desktop_start_service_if_missing: bool,
217
218    /// Timeout in milliseconds to wait for the IPC connection to become ready
219    /// after starting the OS service sidecar. Only applies when
220    /// `desktop_start_service_if_missing` is `true`.
221    /// Default: 5000 (5 seconds).
222    #[cfg(feature = "desktop-service")]
223    #[serde(default = "default_desktop_service_start_timeout_ms")]
224    pub desktop_service_start_timeout_ms: u64,
225}
226
227fn default_ios_safety_timeout() -> f64 {
228    28.0
229}
230
231fn default_ios_cancel_listener_timeout_secs() -> u64 {
232    14400
233}
234
235fn default_ios_processing_safety_timeout_secs() -> f64 {
236    0.0
237}
238
239fn default_ios_earliest_refresh_begin_minutes() -> f64 {
240    15.0
241}
242
243fn default_ios_earliest_processing_begin_minutes() -> f64 {
244    15.0
245}
246
247fn default_android_foreground_service_types() -> Vec<String> {
248    vec!["dataSync".into()]
249}
250
251fn default_android_on_timeout() -> String {
252    "notifyUser".into()
253}
254
255fn default_android_notification_channel_id() -> String {
256    "bg_service".into()
257}
258
259fn default_android_notification_channel_name() -> String {
260    "Background Service".into()
261}
262
263fn default_android_notification_id() -> u32 {
264    9001
265}
266
267fn default_true() -> bool {
268    true
269}
270
271fn default_channel_capacity() -> usize {
272    16
273}
274
275#[cfg(feature = "desktop-service")]
276fn default_desktop_service_mode() -> String {
277    "inProcess".into()
278}
279
280#[cfg(feature = "desktop-service")]
281fn default_desktop_service_start_timeout_ms() -> u64 {
282    5000
283}
284
285impl Default for StartConfig {
286    fn default() -> Self {
287        Self {
288            service_label: default_label(),
289            foreground_service_type: default_foreground_service_type(),
290        }
291    }
292}
293
294/// Lifecycle state of the background service.
295///
296/// Exposed via the `get-service-state` command to provide
297/// fine-grained status beyond a simple boolean.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300#[non_exhaustive]
301pub enum ServiceState {
302    /// No service has been started, or the last run has fully cleaned up.
303    Idle,
304    /// `init()` is in progress (between `Start` and successful `init()`).
305    Initializing,
306    /// `init()` succeeded; `run()` is executing.
307    Running,
308    /// Service stopped (by `stop()`, natural completion, or error).
309    Stopped,
310}
311
312/// Native platform-side state reported by the OS service layer.
313///
314/// Reflects the state as observed by the Android foreground service, iOS
315/// BGTask handler, or desktop OS-service process — distinct from the
316/// plugin-internal [`ServiceState`].
317#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319#[non_exhaustive]
320pub enum NativeState {
321    Idle,
322    Starting,
323    Running,
324    Stopping,
325    Timeout,
326    Expired,
327    Recovering,
328    Error,
329}
330
331/// Snapshot of the service lifecycle status.
332///
333/// Returned by the `get-service-state` command.
334#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct ServiceStatus {
337    /// Current lifecycle state.
338    pub state: ServiceState,
339    /// Last error message, if the service stopped due to an error.
340    pub last_error: Option<String>,
341
342    // --- Extended fields (Step 4) ---
343    /// Whether the service is desired to be running (persisted across restarts).
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub desired_running: Option<bool>,
346    /// Platform-native state as reported by the OS service layer.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub native_state: Option<NativeState>,
349    /// The lifecycle mechanism in use on the current platform.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub platform_mode: Option<LifecycleMode>,
352    /// Configuration used for the last successful start.
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub last_start_config: Option<StartConfig>,
355    /// Epoch milliseconds of the last heartbeat received from the service.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub last_heartbeat_at: Option<u64>,
358    /// How many restart attempts have been made since the last clean start.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub restart_attempt: Option<u32>,
361    /// Human-readable reason for the current recovery attempt.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub recovery_reason: Option<String>,
364    /// Last platform-specific error message.
365    #[serde(skip_serializing_if = "Option::is_none")]
366    pub platform_error: Option<String>,
367}
368
369impl Default for ServiceStatus {
370    fn default() -> Self {
371        Self {
372            state: ServiceState::Idle,
373            last_error: None,
374            desired_running: None,
375            native_state: None,
376            platform_mode: None,
377            last_start_config: None,
378            last_heartbeat_at: None,
379            restart_attempt: None,
380            recovery_reason: None,
381            platform_error: None,
382        }
383    }
384}
385
386/// The operating system platform.
387///
388/// Returned by `get_platform_capabilities` to identify the current runtime environment.
389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "camelCase")]
391#[non_exhaustive]
392pub enum Platform {
393    Android,
394    Ios,
395    Windows,
396    Macos,
397    Linux,
398    Unknown,
399}
400
401/// The lifecycle mechanism used by the plugin on the current platform.
402#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404#[non_exhaustive]
405pub enum LifecycleMode {
406    AndroidForegroundService,
407    IosBgTaskScheduler,
408    DesktopInProcess,
409    DesktopOsService,
410}
411
412/// Guarantee level for a specific background execution scenario.
413///
414/// - `Guaranteed`: The platform reliably supports this scenario.
415/// - `BestEffort`: The platform may support this scenario but cannot guarantee it.
416/// - `Unsupported`: The platform does not support this scenario.
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "camelCase")]
419#[non_exhaustive]
420pub enum LifecycleGuarantee {
421    Guaranteed,
422    BestEffort,
423    Unsupported,
424}
425
426/// Platform-specific background execution capabilities.
427///
428/// Returned by the `get_platform_capabilities` Tauri command. Provides honest
429/// reporting of what each platform can guarantee for background service survival.
430#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
431#[serde(rename_all = "camelCase")]
432#[non_exhaustive]
433pub struct PlatformCapabilities {
434    pub platform: Platform,
435    pub lifecycle_mode: LifecycleMode,
436    pub survives_app_close: LifecycleGuarantee,
437    pub survives_reboot: LifecycleGuarantee,
438    pub survives_force_quit: LifecycleGuarantee,
439    pub background_execution: LifecycleGuarantee,
440    pub limitations: Vec<String>,
441    pub required_setup: Vec<String>,
442}
443
444/// OS service install/running state.
445///
446/// Reported by [`OsServiceStatus`] to indicate whether the OS-level service
447/// (systemd, launchd, etc.) is installed and/or currently running.
448#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
449#[serde(rename_all = "camelCase")]
450#[non_exhaustive]
451pub enum OsServiceInstallState {
452    /// The OS service is not installed.
453    NotInstalled,
454    /// The OS service is installed but not currently running.
455    Installed,
456    /// The OS service is installed and currently running.
457    Running,
458}
459
460/// Snapshot of an OS-level service's status.
461///
462/// Returned by `get_os_service_status` to report the state of the desktop
463/// OS service (systemd user service, launchd agent).
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465#[serde(rename_all = "camelCase")]
466#[non_exhaustive]
467pub struct OsServiceStatus {
468    /// The service label (e.g. `com.example.background-service`).
469    pub label: String,
470    /// The service manager kind (e.g. `systemd`, `launchd`).
471    pub mode: String,
472    /// Whether the service is installed and/or running.
473    pub installed: OsServiceInstallState,
474    /// Whether the IPC connection to the service sidecar is active.
475    pub ipc_connected: bool,
476    /// Path to the Unix domain socket used for IPC.
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub socket_path: Option<String>,
479    /// Last error message from the OS service, if any.
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub last_error: Option<String>,
482}
483
484/// Built-in event types emitted by the runner itself.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486#[serde(rename_all = "camelCase", tag = "type")]
487#[non_exhaustive]
488pub enum PluginEvent {
489    /// init() completed successfully
490    Started,
491    /// run() returned or was cancelled
492    Stopped { reason: String },
493    /// init() or run() returned an error
494    Error { message: String },
495}
496
497impl Default for PluginConfig {
498    fn default() -> Self {
499        Self {
500            ios_safety_timeout_secs: default_ios_safety_timeout(),
501            ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
502            ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
503            ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
504            ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
505            ios_requires_external_power: false,
506            ios_requires_network_connectivity: false,
507            channel_capacity: default_channel_capacity(),
508            android_foreground_service_types: default_android_foreground_service_types(),
509            android_validate_foreground_service_type: default_true(),
510            android_on_timeout: default_android_on_timeout(),
511            android_notification_channel_id: default_android_notification_channel_id(),
512            android_notification_channel_name: default_android_notification_channel_name(),
513            android_notification_id: default_android_notification_id(),
514            android_notification_small_icon: None,
515            android_show_stop_action: default_true(),
516            #[cfg(feature = "desktop-service")]
517            desktop_service_mode: default_desktop_service_mode(),
518            #[cfg(feature = "desktop-service")]
519            desktop_service_label: None,
520            #[cfg(feature = "desktop-service")]
521            desktop_service_autostart: false,
522            #[cfg(feature = "desktop-service")]
523            desktop_start_service_if_missing: false,
524            #[cfg(feature = "desktop-service")]
525            desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
526        }
527    }
528}
529
530/// Arguments sent to the native `startKeepalive` handler.
531///
532/// Lives in `models.rs` (not `mobile.rs`) so serde tests run on all platforms.
533#[derive(Debug, Serialize)]
534#[serde(rename_all = "camelCase")]
535#[allow(dead_code)]
536pub(crate) struct StartKeepaliveArgs<'a> {
537    pub label: &'a str,
538    pub foreground_service_type: &'a str,
539    /// iOS safety timeout in seconds. Only sent to iOS; `None` omits the key.
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub ios_safety_timeout_secs: Option<f64>,
542    /// iOS BGProcessingTask safety timeout in seconds. Only sent to iOS; `None` omits the key.
543    /// When `Some(positive)`, caps the processing task duration.
544    #[serde(skip_serializing_if = "Option::is_none")]
545    pub ios_processing_safety_timeout_secs: Option<f64>,
546    /// iOS BGAppRefreshTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
547    #[serde(skip_serializing_if = "Option::is_none")]
548    pub ios_earliest_refresh_begin_minutes: Option<f64>,
549    /// iOS BGProcessingTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
550    #[serde(skip_serializing_if = "Option::is_none")]
551    pub ios_earliest_processing_begin_minutes: Option<f64>,
552    /// iOS BGProcessingTask requires external power. Only sent to iOS; `None` omits the key.
553    #[serde(skip_serializing_if = "Option::is_none")]
554    pub ios_requires_external_power: Option<bool>,
555    /// iOS BGProcessingTask requires network connectivity. Only sent to iOS; `None` omits the key.
556    #[serde(skip_serializing_if = "Option::is_none")]
557    pub ios_requires_network_connectivity: Option<bool>,
558}
559
560/// Auto-start config returned by the Kotlin bridge.
561///
562/// Deserialized from SharedPreferences values read by `getAutoStartConfig`.
563/// Only used on Android (the iOS path doesn't have auto-start).
564#[doc(hidden)]
565#[derive(Debug, Clone, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct AutoStartConfig {
568    pub pending: bool,
569    pub label: Option<String>,
570    pub service_type: Option<String>,
571}
572
573impl AutoStartConfig {
574    /// Convert to `StartConfig` if auto-start is pending and label is available.
575    pub fn into_start_config(self) -> Option<StartConfig> {
576        if self.pending {
577            self.label.map(|label| StartConfig {
578                service_label: label,
579                foreground_service_type: self
580                    .service_type
581                    .unwrap_or_else(default_foreground_service_type),
582            })
583        } else {
584            None
585        }
586    }
587}
588
589/// Information about a pending iOS background task that launched the app.
590///
591/// Returned by `getPendingBgTask()` on iOS when the app was launched by iOS
592/// for a background task (BGAppRefreshTask or BGProcessingTask). Used by the
593/// Rust auto-start logic to detect OS-initiated launches and automatically
594/// start the service if `desired_running` is true.
595#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
596#[serde(rename_all = "camelCase")]
597#[non_exhaustive]
598pub struct PendingTaskInfo {
599    /// The kind of background task: "refresh" or "processing".
600    pub task_kind: String,
601    /// The task identifier (e.g. "com.example.app.bg-refresh").
602    pub identifier: String,
603    /// Epoch timestamp (seconds) when the task was received by the native layer.
604    pub received_at: f64,
605}
606
607/// iOS scheduling status returned by the native layer.
608///
609/// Reports which background task types were successfully scheduled
610/// and any errors that occurred during scheduling. Returned by
611/// `get_scheduling_status` and parsed from `startKeepalive` on iOS.
612#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
613#[serde(rename_all = "camelCase")]
614#[non_exhaustive]
615pub struct IOSSchedulingStatus {
616    /// Whether a `BGAppRefreshTask` was successfully scheduled.
617    pub refresh_scheduled: bool,
618    /// Whether a `BGProcessingTask` was successfully scheduled.
619    pub processing_scheduled: bool,
620    /// Error from `BGAppRefreshTask` scheduling, if any.
621    #[serde(default)]
622    #[serde(skip_serializing_if = "Option::is_none")]
623    pub refresh_error: Option<String>,
624    /// Error from `BGProcessingTask` scheduling, if any.
625    #[serde(default)]
626    #[serde(skip_serializing_if = "Option::is_none")]
627    pub processing_error: Option<String>,
628}
629
630/// A single setup issue found during validation.
631///
632/// Part of [`SetupValidationReport`]. Each issue has a machine-readable code,
633/// a human-readable message, the platform it applies to, and an optional fix.
634#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
635#[serde(rename_all = "camelCase")]
636#[non_exhaustive]
637pub struct SetupIssue {
638    /// Machine-readable error code (e.g. "android_fgs_type").
639    pub code: String,
640    /// Human-readable description of the issue.
641    pub message: String,
642    /// The platform this issue applies to.
643    pub platform: Platform,
644    /// Suggested fix for the issue.
645    #[serde(skip_serializing_if = "Option::is_none")]
646    pub fix: Option<String>,
647}
648
649/// Result of validating background service setup prerequisites.
650///
651/// Returned by `validateBackgroundServiceSetup()`. Contains `errors` (blocking
652/// issues that prevent the service from working) and `warnings` (non-blocking
653/// issues that may cause degraded behavior).
654#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
655#[serde(rename_all = "camelCase")]
656#[non_exhaustive]
657pub struct SetupValidationReport {
658    /// `true` when `errors` is empty (warnings do not affect this).
659    pub ok: bool,
660    /// Blocking issues that prevent the service from working correctly.
661    pub errors: Vec<SetupIssue>,
662    /// Non-blocking issues that may cause degraded behavior.
663    pub warnings: Vec<SetupIssue>,
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    // --- StartConfig tests ---
671
672    #[test]
673    fn start_config_default_label() {
674        let config = StartConfig::default();
675        assert_eq!(config.service_label, "Service running");
676    }
677
678    #[test]
679    fn start_config_custom_label() {
680        let config = StartConfig {
681            service_label: "Syncing data".into(),
682            ..Default::default()
683        };
684        assert_eq!(config.service_label, "Syncing data");
685    }
686
687    #[test]
688    fn start_config_serde_roundtrip_default() {
689        let config = StartConfig::default();
690        let json = serde_json::to_string(&config).unwrap();
691        let de: StartConfig = serde_json::from_str(&json).unwrap();
692        assert_eq!(de.service_label, config.service_label);
693    }
694
695    #[test]
696    fn start_config_serde_roundtrip_custom() {
697        let config = StartConfig {
698            service_label: "My service".into(),
699            ..Default::default()
700        };
701        let json = serde_json::to_string(&config).unwrap();
702        let de: StartConfig = serde_json::from_str(&json).unwrap();
703        assert_eq!(de.service_label, "My service");
704    }
705
706    #[test]
707    fn start_config_deserialize_missing_field_uses_default() {
708        // An empty JSON object should produce the default label
709        let json = "{}";
710        let de: StartConfig = serde_json::from_str(json).unwrap();
711        assert_eq!(de.service_label, "Service running");
712    }
713
714    #[test]
715    fn start_config_json_key_is_camel_case() {
716        let config = StartConfig {
717            service_label: "test".into(),
718            ..Default::default()
719        };
720        let json = serde_json::to_string(&config).unwrap();
721        assert!(
722            json.contains("serviceLabel"),
723            "JSON should use camelCase: {json}"
724        );
725    }
726
727    // --- PluginEvent tests ---
728
729    #[test]
730    fn plugin_event_started_serde_roundtrip() {
731        let event = PluginEvent::Started;
732        let json = serde_json::to_string(&event).unwrap();
733        let de: PluginEvent = serde_json::from_str(&json).unwrap();
734        assert!(matches!(de, PluginEvent::Started));
735    }
736
737    #[test]
738    fn plugin_event_stopped_serde_roundtrip() {
739        let event = PluginEvent::Stopped {
740            reason: "cancelled".into(),
741        };
742        let json = serde_json::to_string(&event).unwrap();
743        let de: PluginEvent = serde_json::from_str(&json).unwrap();
744        match de {
745            PluginEvent::Stopped { reason } => assert_eq!(reason, "cancelled"),
746            other => panic!("Expected Stopped, got {other:?}"),
747        }
748    }
749
750    #[test]
751    fn plugin_event_error_serde_roundtrip() {
752        let event = PluginEvent::Error {
753            message: "init failed".into(),
754        };
755        let json = serde_json::to_string(&event).unwrap();
756        let de: PluginEvent = serde_json::from_str(&json).unwrap();
757        match de {
758            PluginEvent::Error { message } => assert_eq!(message, "init failed"),
759            other => panic!("Expected Error, got {other:?}"),
760        }
761    }
762
763    #[test]
764    fn plugin_event_tagged_json_format() {
765        let event = PluginEvent::Started;
766        let json = serde_json::to_string(&event).unwrap();
767        assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
768    }
769
770    #[test]
771    fn plugin_event_stopped_json_keys_camel_case() {
772        let event = PluginEvent::Stopped {
773            reason: "done".into(),
774        };
775        let json = serde_json::to_string(&event).unwrap();
776        assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
777        assert!(json.contains("\"reason\":\"done\""), "Reason: {json}");
778    }
779
780    #[test]
781    fn plugin_event_error_json_keys_camel_case() {
782        let event = PluginEvent::Error {
783            message: "oops".into(),
784        };
785        let json = serde_json::to_string(&event).unwrap();
786        assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
787        assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
788    }
789
790    // --- StartConfig foreground_service_type tests ---
791
792    #[test]
793    fn start_config_default_service_type() {
794        let config = StartConfig::default();
795        assert_eq!(config.foreground_service_type, "dataSync");
796    }
797
798    #[test]
799    fn start_config_custom_service_type() {
800        let config = StartConfig {
801            service_label: "test".into(),
802            foreground_service_type: "specialUse".into(),
803        };
804        assert_eq!(config.foreground_service_type, "specialUse");
805    }
806
807    #[test]
808    fn start_config_serde_roundtrip_service_type() {
809        let config = StartConfig {
810            service_label: "test".into(),
811            foreground_service_type: "specialUse".into(),
812        };
813        let json = serde_json::to_string(&config).unwrap();
814        let de: StartConfig = serde_json::from_str(&json).unwrap();
815        assert_eq!(de.foreground_service_type, "specialUse");
816    }
817
818    #[test]
819    fn start_config_deserialize_missing_service_type() {
820        let json = r#"{"serviceLabel":"test"}"#;
821        let de: StartConfig = serde_json::from_str(json).unwrap();
822        assert_eq!(de.foreground_service_type, "dataSync");
823    }
824
825    #[test]
826    fn start_config_deserialize_special_use() {
827        let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
828        let de: StartConfig = serde_json::from_str(json).unwrap();
829        assert_eq!(de.foreground_service_type, "specialUse");
830    }
831
832    #[test]
833    fn start_config_unrecognized_type_rejected_by_validation() {
834        // Deserialization still passes through any string.
835        let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
836        let de: StartConfig = serde_json::from_str(json).unwrap();
837        assert_eq!(de.foreground_service_type, "customType");
838        // But validation rejects it.
839        let result = validate_foreground_service_type(&de.foreground_service_type);
840        assert!(
841            result.is_err(),
842            "validation should reject unrecognized type"
843        );
844        let err_msg = result.unwrap_err().to_string();
845        assert!(
846            err_msg.contains("customType"),
847            "error should mention the invalid type: {err_msg}"
848        );
849    }
850
851    #[test]
852    fn start_config_json_key_is_camel_case_service_type() {
853        let config = StartConfig {
854            service_label: "test".into(),
855            foreground_service_type: "specialUse".into(),
856        };
857        let json = serde_json::to_string(&config).unwrap();
858        assert!(
859            json.contains("foregroundServiceType"),
860            "JSON should use camelCase: {json}"
861        );
862    }
863
864    // --- AutoStartConfig tests ---
865
866    #[test]
867    fn auto_start_config_pending_with_label_returns_start_config() {
868        let json = r#"{"pending": true, "label": "Syncing"}"#;
869        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
870        let result = config.into_start_config();
871        assert!(result.is_some());
872        let start_config = result.unwrap();
873        assert_eq!(start_config.service_label, "Syncing");
874        assert_eq!(start_config.foreground_service_type, "dataSync");
875    }
876
877    #[test]
878    fn auto_start_config_not_pending_returns_none() {
879        let json = r#"{"pending": false, "label": null}"#;
880        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
881        let result = config.into_start_config();
882        assert!(result.is_none());
883    }
884
885    #[test]
886    fn auto_start_config_pending_no_label_returns_none() {
887        let json = r#"{"pending": true, "label": null}"#;
888        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
889        let result = config.into_start_config();
890        assert!(result.is_none());
891    }
892
893    #[test]
894    fn auto_start_config_with_service_type_preserves_it() {
895        let json = r#"{"pending":true,"label":"test","serviceType":"specialUse"}"#;
896        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
897        assert_eq!(config.service_type, Some("specialUse".to_string()));
898        let result = config.into_start_config();
899        assert!(result.is_some());
900        let start_config = result.unwrap();
901        assert_eq!(start_config.foreground_service_type, "specialUse");
902    }
903
904    #[test]
905    fn auto_start_config_without_service_type_uses_default() {
906        let json = r#"{"pending":true,"label":"test"}"#;
907        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
908        assert_eq!(config.service_type, None);
909        let result = config.into_start_config();
910        assert!(result.is_some());
911        assert_eq!(result.unwrap().foreground_service_type, "dataSync");
912    }
913
914    #[test]
915    fn auto_start_config_null_service_type_uses_default() {
916        let json = r#"{"pending":true,"label":"test","serviceType":null}"#;
917        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
918        assert_eq!(config.service_type, None);
919        let result = config.into_start_config();
920        assert!(result.is_some());
921        assert_eq!(result.unwrap().foreground_service_type, "dataSync");
922    }
923
924    // --- PluginConfig tests ---
925
926    #[test]
927    fn plugin_config_default_ios_safety_timeout() {
928        let json = "{}";
929        let config: PluginConfig = serde_json::from_str(json).unwrap();
930        assert_eq!(config.ios_safety_timeout_secs, 28.0);
931    }
932
933    #[test]
934    fn plugin_config_custom_ios_safety_timeout() {
935        let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
936        let config: PluginConfig = serde_json::from_str(json).unwrap();
937        assert_eq!(config.ios_safety_timeout_secs, 15.0);
938    }
939
940    #[test]
941    fn plugin_config_serde_roundtrip_preserves_value() {
942        let config = PluginConfig {
943            ios_safety_timeout_secs: 30.0,
944            ios_cancel_listener_timeout_secs: 14400,
945            ios_processing_safety_timeout_secs: 0.0,
946            ios_earliest_refresh_begin_minutes: 20.0,
947            ios_earliest_processing_begin_minutes: 30.0,
948            ios_requires_external_power: true,
949            ios_requires_network_connectivity: true,
950            ..Default::default()
951        };
952        let json = serde_json::to_string(&config).unwrap();
953        let de: PluginConfig = serde_json::from_str(&json).unwrap();
954        assert_eq!(de.ios_safety_timeout_secs, 30.0);
955        assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
956        assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
957        assert!(de.ios_requires_external_power);
958        assert!(de.ios_requires_network_connectivity);
959    }
960
961    #[test]
962    fn plugin_config_default_impl() {
963        let config = PluginConfig::default();
964        assert_eq!(config.ios_safety_timeout_secs, 28.0);
965        assert_eq!(config.channel_capacity, 16);
966    }
967
968    #[test]
969    fn plugin_config_default_cancel_timeout() {
970        let json = "{}";
971        let config: PluginConfig = serde_json::from_str(json).unwrap();
972        assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
973    }
974
975    #[test]
976    fn plugin_config_custom_cancel_timeout() {
977        let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
978        let config: PluginConfig = serde_json::from_str(json).unwrap();
979        assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
980    }
981
982    #[test]
983    fn plugin_config_cancel_timeout_serde_roundtrip() {
984        let config = PluginConfig {
985            ios_cancel_listener_timeout_secs: 3600,
986            ..Default::default()
987        };
988        let json = serde_json::to_string(&config).unwrap();
989        let de: PluginConfig = serde_json::from_str(&json).unwrap();
990        assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
991    }
992
993    // --- PluginConfig ios_processing_safety_timeout_secs tests ---
994
995    #[test]
996    fn plugin_config_processing_timeout_default() {
997        let json = "{}";
998        let config: PluginConfig = serde_json::from_str(json).unwrap();
999        assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
1000    }
1001
1002    #[test]
1003    fn plugin_config_processing_timeout_custom() {
1004        let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
1005        let config: PluginConfig = serde_json::from_str(json).unwrap();
1006        assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
1007    }
1008
1009    #[test]
1010    fn plugin_config_processing_timeout_serde_roundtrip() {
1011        let config = PluginConfig {
1012            ios_processing_safety_timeout_secs: 120.0,
1013            ..Default::default()
1014        };
1015        let json = serde_json::to_string(&config).unwrap();
1016        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1017        assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
1018    }
1019
1020    // --- StartKeepaliveArgs tests ---
1021
1022    #[test]
1023    fn start_keepalive_args_with_timeout() {
1024        let args = StartKeepaliveArgs {
1025            label: "Test",
1026            foreground_service_type: "dataSync",
1027            ios_safety_timeout_secs: Some(15.0),
1028            ios_processing_safety_timeout_secs: None,
1029            ios_earliest_refresh_begin_minutes: None,
1030            ios_earliest_processing_begin_minutes: None,
1031            ios_requires_external_power: None,
1032            ios_requires_network_connectivity: None,
1033        };
1034        let json = serde_json::to_string(&args).unwrap();
1035        assert!(
1036            json.contains("\"iosSafetyTimeoutSecs\":15.0"),
1037            "JSON should contain iosSafetyTimeoutSecs: {json}"
1038        );
1039    }
1040
1041    #[test]
1042    fn start_keepalive_args_without_timeout() {
1043        let args = StartKeepaliveArgs {
1044            label: "Test",
1045            foreground_service_type: "dataSync",
1046            ios_safety_timeout_secs: None,
1047            ios_processing_safety_timeout_secs: None,
1048            ios_earliest_refresh_begin_minutes: None,
1049            ios_earliest_processing_begin_minutes: None,
1050            ios_requires_external_power: None,
1051            ios_requires_network_connectivity: None,
1052        };
1053        let json = serde_json::to_string(&args).unwrap();
1054        assert!(
1055            !json.contains("iosSafetyTimeoutSecs"),
1056            "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
1057        );
1058    }
1059
1060    #[test]
1061    fn start_keepalive_args_processing_timeout() {
1062        let args = StartKeepaliveArgs {
1063            label: "Test",
1064            foreground_service_type: "dataSync",
1065            ios_safety_timeout_secs: None,
1066            ios_processing_safety_timeout_secs: Some(60.0),
1067            ios_earliest_refresh_begin_minutes: None,
1068            ios_earliest_processing_begin_minutes: None,
1069            ios_requires_external_power: None,
1070            ios_requires_network_connectivity: None,
1071        };
1072        let json = serde_json::to_string(&args).unwrap();
1073        assert!(
1074            json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
1075            "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
1076        );
1077    }
1078
1079    #[test]
1080    fn start_keepalive_args_no_processing_timeout() {
1081        let args = StartKeepaliveArgs {
1082            label: "Test",
1083            foreground_service_type: "dataSync",
1084            ios_safety_timeout_secs: None,
1085            ios_processing_safety_timeout_secs: None,
1086            ios_earliest_refresh_begin_minutes: None,
1087            ios_earliest_processing_begin_minutes: None,
1088            ios_requires_external_power: None,
1089            ios_requires_network_connectivity: None,
1090        };
1091        let json = serde_json::to_string(&args).unwrap();
1092        assert!(
1093            !json.contains("iosProcessingSafetyTimeoutSecs"),
1094            "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
1095        );
1096    }
1097
1098    #[test]
1099    fn start_keepalive_args_camel_case_keys() {
1100        let args = StartKeepaliveArgs {
1101            label: "Test",
1102            foreground_service_type: "specialUse",
1103            ios_safety_timeout_secs: None,
1104            ios_processing_safety_timeout_secs: None,
1105            ios_earliest_refresh_begin_minutes: None,
1106            ios_earliest_processing_begin_minutes: None,
1107            ios_requires_external_power: None,
1108            ios_requires_network_connectivity: None,
1109        };
1110        let json = serde_json::to_string(&args).unwrap();
1111        assert!(json.contains("\"label\""), "label: {json}");
1112        assert!(
1113            json.contains("\"foregroundServiceType\""),
1114            "foregroundServiceType: {json}"
1115        );
1116    }
1117
1118    #[test]
1119    fn start_keepalive_args_scheduling_intervals() {
1120        let args = StartKeepaliveArgs {
1121            label: "Test",
1122            foreground_service_type: "dataSync",
1123            ios_safety_timeout_secs: None,
1124            ios_processing_safety_timeout_secs: None,
1125            ios_earliest_refresh_begin_minutes: Some(30.0),
1126            ios_earliest_processing_begin_minutes: Some(60.0),
1127            ios_requires_external_power: None,
1128            ios_requires_network_connectivity: None,
1129        };
1130        let json = serde_json::to_string(&args).unwrap();
1131        assert!(
1132            json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
1133            "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
1134        );
1135        assert!(
1136            json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
1137            "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
1138        );
1139    }
1140
1141    #[test]
1142    fn start_keepalive_args_processing_options() {
1143        let args = StartKeepaliveArgs {
1144            label: "Test",
1145            foreground_service_type: "dataSync",
1146            ios_safety_timeout_secs: None,
1147            ios_processing_safety_timeout_secs: None,
1148            ios_earliest_refresh_begin_minutes: None,
1149            ios_earliest_processing_begin_minutes: None,
1150            ios_requires_external_power: Some(true),
1151            ios_requires_network_connectivity: Some(true),
1152        };
1153        let json = serde_json::to_string(&args).unwrap();
1154        assert!(
1155            json.contains("\"iosRequiresExternalPower\":true"),
1156            "JSON should contain iosRequiresExternalPower: {json}"
1157        );
1158        assert!(
1159            json.contains("\"iosRequiresNetworkConnectivity\":true"),
1160            "JSON should contain iosRequiresNetworkConnectivity: {json}"
1161        );
1162    }
1163
1164    // --- PluginConfig new scheduling fields tests ---
1165
1166    #[test]
1167    fn plugin_config_earliest_refresh_default() {
1168        let json = "{}";
1169        let config: PluginConfig = serde_json::from_str(json).unwrap();
1170        assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
1171    }
1172
1173    #[test]
1174    fn plugin_config_earliest_processing_default() {
1175        let json = "{}";
1176        let config: PluginConfig = serde_json::from_str(json).unwrap();
1177        assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
1178    }
1179
1180    #[test]
1181    fn plugin_config_requires_external_power_default() {
1182        let json = "{}";
1183        let config: PluginConfig = serde_json::from_str(json).unwrap();
1184        assert!(!config.ios_requires_external_power);
1185    }
1186
1187    #[test]
1188    fn plugin_config_requires_network_connectivity_default() {
1189        let json = "{}";
1190        let config: PluginConfig = serde_json::from_str(json).unwrap();
1191        assert!(!config.ios_requires_network_connectivity);
1192    }
1193
1194    #[test]
1195    fn plugin_config_custom_scheduling_intervals() {
1196        let json =
1197            r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
1198        let config: PluginConfig = serde_json::from_str(json).unwrap();
1199        assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
1200        assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
1201    }
1202
1203    #[test]
1204    fn plugin_config_custom_processing_options() {
1205        let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
1206        let config: PluginConfig = serde_json::from_str(json).unwrap();
1207        assert!(config.ios_requires_external_power);
1208        assert!(config.ios_requires_network_connectivity);
1209    }
1210
1211    // --- PluginConfig channel_capacity tests ---
1212
1213    #[test]
1214    fn plugin_config_channel_capacity_default() {
1215        let json = "{}";
1216        let config: PluginConfig = serde_json::from_str(json).unwrap();
1217        assert_eq!(config.channel_capacity, 16);
1218    }
1219
1220    #[test]
1221    fn plugin_config_channel_capacity_custom() {
1222        let json = r#"{"channelCapacity":32}"#;
1223        let config: PluginConfig = serde_json::from_str(json).unwrap();
1224        assert_eq!(config.channel_capacity, 32);
1225    }
1226
1227    #[test]
1228    fn plugin_config_channel_capacity_serde_roundtrip() {
1229        let config = PluginConfig {
1230            channel_capacity: 64,
1231            ..Default::default()
1232        };
1233        let json = serde_json::to_string(&config).unwrap();
1234        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1235        assert_eq!(de.channel_capacity, 64);
1236    }
1237
1238    #[test]
1239    fn plugin_config_channel_capacity_json_key_camel_case() {
1240        let config = PluginConfig {
1241            channel_capacity: 32,
1242            ..Default::default()
1243        };
1244        let json = serde_json::to_string(&config).unwrap();
1245        assert!(
1246            json.contains("channelCapacity"),
1247            "JSON should use camelCase: {json}"
1248        );
1249    }
1250
1251    // --- PluginConfig android FGS type fields tests ---
1252
1253    #[test]
1254    fn plugin_config_android_fgs_types_default() {
1255        let json = "{}";
1256        let config: PluginConfig = serde_json::from_str(json).unwrap();
1257        assert_eq!(config.android_foreground_service_types, vec!["dataSync"]);
1258    }
1259
1260    #[test]
1261    fn plugin_config_android_fgs_types_custom() {
1262        let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
1263        let config: PluginConfig = serde_json::from_str(json).unwrap();
1264        assert_eq!(
1265            config.android_foreground_service_types,
1266            vec!["dataSync", "specialUse"]
1267        );
1268    }
1269
1270    #[test]
1271    fn plugin_config_android_fgs_types_serde_roundtrip() {
1272        let config = PluginConfig {
1273            android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
1274            ..Default::default()
1275        };
1276        let json = serde_json::to_string(&config).unwrap();
1277        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1278        assert_eq!(
1279            de.android_foreground_service_types,
1280            vec!["location", "connectedDevice"]
1281        );
1282    }
1283
1284    #[test]
1285    fn plugin_config_android_fgs_types_json_key_camel_case() {
1286        let config = PluginConfig {
1287            android_foreground_service_types: vec!["specialUse".into()],
1288            ..Default::default()
1289        };
1290        let json = serde_json::to_string(&config).unwrap();
1291        assert!(
1292            json.contains("androidForegroundServiceTypes"),
1293            "JSON should use camelCase: {json}"
1294        );
1295    }
1296
1297    #[test]
1298    fn plugin_config_android_validate_default() {
1299        let json = "{}";
1300        let config: PluginConfig = serde_json::from_str(json).unwrap();
1301        assert!(config.android_validate_foreground_service_type);
1302    }
1303
1304    #[test]
1305    fn plugin_config_android_validate_false() {
1306        let json = r#"{"androidValidateForegroundServiceType":false}"#;
1307        let config: PluginConfig = serde_json::from_str(json).unwrap();
1308        assert!(!config.android_validate_foreground_service_type);
1309    }
1310
1311    #[test]
1312    fn plugin_config_android_validate_serde_roundtrip() {
1313        let config = PluginConfig {
1314            android_validate_foreground_service_type: false,
1315            ..Default::default()
1316        };
1317        let json = serde_json::to_string(&config).unwrap();
1318        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1319        assert!(!de.android_validate_foreground_service_type);
1320    }
1321
1322    #[test]
1323    fn plugin_config_android_validate_json_key_camel_case() {
1324        let config = PluginConfig {
1325            android_validate_foreground_service_type: false,
1326            ..Default::default()
1327        };
1328        let json = serde_json::to_string(&config).unwrap();
1329        assert!(
1330            json.contains("androidValidateForegroundServiceType"),
1331            "JSON should use camelCase: {json}"
1332        );
1333    }
1334
1335    // --- PluginConfig Android timeout/notification config tests ---
1336
1337    #[test]
1338    fn plugin_config_android_on_timeout_default() {
1339        let json = "{}";
1340        let config: PluginConfig = serde_json::from_str(json).unwrap();
1341        assert_eq!(config.android_on_timeout, "notifyUser");
1342    }
1343
1344    #[test]
1345    fn plugin_config_android_on_timeout_custom() {
1346        let json = r#"{"androidOnTimeout":"stop"}"#;
1347        let config: PluginConfig = serde_json::from_str(json).unwrap();
1348        assert_eq!(config.android_on_timeout, "stop");
1349    }
1350
1351    #[test]
1352    fn plugin_config_android_on_timeout_schedule_recovery() {
1353        let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
1354        let config: PluginConfig = serde_json::from_str(json).unwrap();
1355        assert_eq!(config.android_on_timeout, "scheduleRecovery");
1356    }
1357
1358    #[test]
1359    fn plugin_config_android_on_timeout_serde_roundtrip() {
1360        let config = PluginConfig {
1361            android_on_timeout: "stop".into(),
1362            ..Default::default()
1363        };
1364        let json = serde_json::to_string(&config).unwrap();
1365        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1366        assert_eq!(de.android_on_timeout, "stop");
1367    }
1368
1369    #[test]
1370    fn plugin_config_android_on_timeout_json_key_camel_case() {
1371        let config = PluginConfig {
1372            android_on_timeout: "notifyUser".into(),
1373            ..Default::default()
1374        };
1375        let json = serde_json::to_string(&config).unwrap();
1376        assert!(
1377            json.contains("androidOnTimeout"),
1378            "JSON should use camelCase: {json}"
1379        );
1380    }
1381
1382    #[test]
1383    fn plugin_config_android_notification_channel_id_default() {
1384        let json = "{}";
1385        let config: PluginConfig = serde_json::from_str(json).unwrap();
1386        assert_eq!(config.android_notification_channel_id, "bg_service");
1387    }
1388
1389    #[test]
1390    fn plugin_config_android_notification_channel_id_custom() {
1391        let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
1392        let config: PluginConfig = serde_json::from_str(json).unwrap();
1393        assert_eq!(config.android_notification_channel_id, "my_channel");
1394    }
1395
1396    #[test]
1397    fn plugin_config_android_notification_channel_id_serde_roundtrip() {
1398        let config = PluginConfig {
1399            android_notification_channel_id: "custom_ch".into(),
1400            ..Default::default()
1401        };
1402        let json = serde_json::to_string(&config).unwrap();
1403        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1404        assert_eq!(de.android_notification_channel_id, "custom_ch");
1405    }
1406
1407    #[test]
1408    fn plugin_config_android_notification_channel_id_json_key_camel_case() {
1409        let config = PluginConfig {
1410            android_notification_channel_id: "test".into(),
1411            ..Default::default()
1412        };
1413        let json = serde_json::to_string(&config).unwrap();
1414        assert!(
1415            json.contains("androidNotificationChannelId"),
1416            "JSON should use camelCase: {json}"
1417        );
1418    }
1419
1420    #[test]
1421    fn plugin_config_android_notification_channel_name_default() {
1422        let json = "{}";
1423        let config: PluginConfig = serde_json::from_str(json).unwrap();
1424        assert_eq!(
1425            config.android_notification_channel_name,
1426            "Background Service"
1427        );
1428    }
1429
1430    #[test]
1431    fn plugin_config_android_notification_channel_name_custom() {
1432        let json = r#"{"androidNotificationChannelName":"My Service"}"#;
1433        let config: PluginConfig = serde_json::from_str(json).unwrap();
1434        assert_eq!(config.android_notification_channel_name, "My Service");
1435    }
1436
1437    #[test]
1438    fn plugin_config_android_notification_channel_name_serde_roundtrip() {
1439        let config = PluginConfig {
1440            android_notification_channel_name: "Sync Service".into(),
1441            ..Default::default()
1442        };
1443        let json = serde_json::to_string(&config).unwrap();
1444        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1445        assert_eq!(de.android_notification_channel_name, "Sync Service");
1446    }
1447
1448    #[test]
1449    fn plugin_config_android_notification_channel_name_json_key_camel_case() {
1450        let config = PluginConfig {
1451            android_notification_channel_name: "Test".into(),
1452            ..Default::default()
1453        };
1454        let json = serde_json::to_string(&config).unwrap();
1455        assert!(
1456            json.contains("androidNotificationChannelName"),
1457            "JSON should use camelCase: {json}"
1458        );
1459    }
1460
1461    #[test]
1462    fn plugin_config_android_notification_id_default() {
1463        let json = "{}";
1464        let config: PluginConfig = serde_json::from_str(json).unwrap();
1465        assert_eq!(config.android_notification_id, 9001);
1466    }
1467
1468    #[test]
1469    fn plugin_config_android_notification_id_custom() {
1470        let json = r#"{"androidNotificationId":1234}"#;
1471        let config: PluginConfig = serde_json::from_str(json).unwrap();
1472        assert_eq!(config.android_notification_id, 1234);
1473    }
1474
1475    #[test]
1476    fn plugin_config_android_notification_id_serde_roundtrip() {
1477        let config = PluginConfig {
1478            android_notification_id: 42,
1479            ..Default::default()
1480        };
1481        let json = serde_json::to_string(&config).unwrap();
1482        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1483        assert_eq!(de.android_notification_id, 42);
1484    }
1485
1486    #[test]
1487    fn plugin_config_android_notification_id_json_key_camel_case() {
1488        let config = PluginConfig {
1489            android_notification_id: 5555,
1490            ..Default::default()
1491        };
1492        let json = serde_json::to_string(&config).unwrap();
1493        assert!(
1494            json.contains("androidNotificationId"),
1495            "JSON should use camelCase: {json}"
1496        );
1497    }
1498
1499    #[test]
1500    fn plugin_config_android_notification_small_icon_default() {
1501        let json = "{}";
1502        let config: PluginConfig = serde_json::from_str(json).unwrap();
1503        assert_eq!(config.android_notification_small_icon, None);
1504    }
1505
1506    #[test]
1507    fn plugin_config_android_notification_small_icon_custom() {
1508        let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
1509        let config: PluginConfig = serde_json::from_str(json).unwrap();
1510        assert_eq!(
1511            config.android_notification_small_icon,
1512            Some("ic_notification".to_string())
1513        );
1514    }
1515
1516    #[test]
1517    fn plugin_config_android_notification_small_icon_serde_roundtrip() {
1518        let config = PluginConfig {
1519            android_notification_small_icon: Some("my_icon".into()),
1520            ..Default::default()
1521        };
1522        let json = serde_json::to_string(&config).unwrap();
1523        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1524        assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
1525    }
1526
1527    #[test]
1528    fn plugin_config_android_notification_small_icon_absent_when_none() {
1529        let config = PluginConfig {
1530            android_notification_small_icon: None,
1531            ..Default::default()
1532        };
1533        let json = serde_json::to_string(&config).unwrap();
1534        assert!(
1535            !json.contains("androidNotificationSmallIcon"),
1536            "should be absent when None: {json}"
1537        );
1538    }
1539
1540    #[test]
1541    fn plugin_config_android_notification_small_icon_json_key_camel_case() {
1542        let config = PluginConfig {
1543            android_notification_small_icon: Some("icon".into()),
1544            ..Default::default()
1545        };
1546        let json = serde_json::to_string(&config).unwrap();
1547        assert!(
1548            json.contains("androidNotificationSmallIcon"),
1549            "JSON should use camelCase: {json}"
1550        );
1551    }
1552
1553    #[test]
1554    fn plugin_config_android_show_stop_action_default() {
1555        let json = "{}";
1556        let config: PluginConfig = serde_json::from_str(json).unwrap();
1557        assert!(config.android_show_stop_action);
1558    }
1559
1560    #[test]
1561    fn plugin_config_android_show_stop_action_false() {
1562        let json = r#"{"androidShowStopAction":false}"#;
1563        let config: PluginConfig = serde_json::from_str(json).unwrap();
1564        assert!(!config.android_show_stop_action);
1565    }
1566
1567    #[test]
1568    fn plugin_config_android_show_stop_action_serde_roundtrip() {
1569        let config = PluginConfig {
1570            android_show_stop_action: false,
1571            ..Default::default()
1572        };
1573        let json = serde_json::to_string(&config).unwrap();
1574        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1575        assert!(!de.android_show_stop_action);
1576    }
1577
1578    #[test]
1579    fn plugin_config_android_show_stop_action_json_key_camel_case() {
1580        let config = PluginConfig {
1581            android_show_stop_action: false,
1582            ..Default::default()
1583        };
1584        let json = serde_json::to_string(&config).unwrap();
1585        assert!(
1586            json.contains("androidShowStopAction"),
1587            "JSON should use camelCase: {json}"
1588        );
1589    }
1590
1591    #[test]
1592    fn plugin_config_android_timeout_notification_full_roundtrip() {
1593        let config = PluginConfig {
1594            android_on_timeout: "scheduleRecovery".into(),
1595            android_notification_channel_id: "my_ch".into(),
1596            android_notification_channel_name: "My Channel".into(),
1597            android_notification_id: 42,
1598            android_notification_small_icon: Some("ic_bg".into()),
1599            android_show_stop_action: false,
1600            ..Default::default()
1601        };
1602        let json = serde_json::to_string(&config).unwrap();
1603        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1604        assert_eq!(de.android_on_timeout, "scheduleRecovery");
1605        assert_eq!(de.android_notification_channel_id, "my_ch");
1606        assert_eq!(de.android_notification_channel_name, "My Channel");
1607        assert_eq!(de.android_notification_id, 42);
1608        assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
1609        assert!(!de.android_show_stop_action);
1610    }
1611
1612    // --- PluginConfig desktop fields tests (feature-gated) ---
1613
1614    #[cfg(feature = "desktop-service")]
1615    #[test]
1616    fn plugin_config_desktop_mode_default() {
1617        let json = "{}";
1618        let config: PluginConfig = serde_json::from_str(json).unwrap();
1619        assert_eq!(config.desktop_service_mode, "inProcess");
1620    }
1621
1622    #[cfg(feature = "desktop-service")]
1623    #[test]
1624    fn plugin_config_desktop_mode_custom() {
1625        let json = r#"{"desktopServiceMode":"osService"}"#;
1626        let config: PluginConfig = serde_json::from_str(json).unwrap();
1627        assert_eq!(config.desktop_service_mode, "osService");
1628    }
1629
1630    #[cfg(feature = "desktop-service")]
1631    #[test]
1632    fn plugin_config_desktop_mode_serde_roundtrip() {
1633        let config = PluginConfig {
1634            desktop_service_mode: "osService".into(),
1635            ..Default::default()
1636        };
1637        let json = serde_json::to_string(&config).unwrap();
1638        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1639        assert_eq!(de.desktop_service_mode, "osService");
1640    }
1641
1642    #[cfg(feature = "desktop-service")]
1643    #[test]
1644    fn plugin_config_desktop_label_default() {
1645        let json = "{}";
1646        let config: PluginConfig = serde_json::from_str(json).unwrap();
1647        assert_eq!(config.desktop_service_label, None);
1648    }
1649
1650    #[cfg(feature = "desktop-service")]
1651    #[test]
1652    fn plugin_config_desktop_label_custom() {
1653        let json = r#"{"desktopServiceLabel":"my.svc"}"#;
1654        let config: PluginConfig = serde_json::from_str(json).unwrap();
1655        assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
1656    }
1657
1658    // --- PluginConfig desktop autostart/start-if-missing/timeout tests ---
1659
1660    #[cfg(feature = "desktop-service")]
1661    #[test]
1662    fn plugin_config_desktop_autostart_default() {
1663        let json = "{}";
1664        let config: PluginConfig = serde_json::from_str(json).unwrap();
1665        assert!(!config.desktop_service_autostart);
1666    }
1667
1668    #[cfg(feature = "desktop-service")]
1669    #[test]
1670    fn plugin_config_desktop_autostart_true() {
1671        let json = r#"{"desktopServiceAutostart":true}"#;
1672        let config: PluginConfig = serde_json::from_str(json).unwrap();
1673        assert!(config.desktop_service_autostart);
1674    }
1675
1676    #[cfg(feature = "desktop-service")]
1677    #[test]
1678    fn plugin_config_desktop_autostart_serde_roundtrip() {
1679        let config = PluginConfig {
1680            desktop_service_autostart: true,
1681            ..Default::default()
1682        };
1683        let json = serde_json::to_string(&config).unwrap();
1684        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1685        assert!(de.desktop_service_autostart);
1686    }
1687
1688    #[cfg(feature = "desktop-service")]
1689    #[test]
1690    fn plugin_config_desktop_autostart_json_key_camel_case() {
1691        let config = PluginConfig {
1692            desktop_service_autostart: true,
1693            ..Default::default()
1694        };
1695        let json = serde_json::to_string(&config).unwrap();
1696        assert!(
1697            json.contains("desktopServiceAutostart"),
1698            "JSON should use camelCase: {json}"
1699        );
1700    }
1701
1702    #[cfg(feature = "desktop-service")]
1703    #[test]
1704    fn plugin_config_desktop_start_if_missing_default() {
1705        let json = "{}";
1706        let config: PluginConfig = serde_json::from_str(json).unwrap();
1707        assert!(!config.desktop_start_service_if_missing);
1708    }
1709
1710    #[cfg(feature = "desktop-service")]
1711    #[test]
1712    fn plugin_config_desktop_start_if_missing_true() {
1713        let json = r#"{"desktopStartServiceIfMissing":true}"#;
1714        let config: PluginConfig = serde_json::from_str(json).unwrap();
1715        assert!(config.desktop_start_service_if_missing);
1716    }
1717
1718    #[cfg(feature = "desktop-service")]
1719    #[test]
1720    fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
1721        let config = PluginConfig {
1722            desktop_start_service_if_missing: true,
1723            ..Default::default()
1724        };
1725        let json = serde_json::to_string(&config).unwrap();
1726        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1727        assert!(de.desktop_start_service_if_missing);
1728    }
1729
1730    #[cfg(feature = "desktop-service")]
1731    #[test]
1732    fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
1733        let config = PluginConfig {
1734            desktop_start_service_if_missing: true,
1735            ..Default::default()
1736        };
1737        let json = serde_json::to_string(&config).unwrap();
1738        assert!(
1739            json.contains("desktopStartServiceIfMissing"),
1740            "JSON should use camelCase: {json}"
1741        );
1742    }
1743
1744    #[cfg(feature = "desktop-service")]
1745    #[test]
1746    fn plugin_config_desktop_start_timeout_default() {
1747        let json = "{}";
1748        let config: PluginConfig = serde_json::from_str(json).unwrap();
1749        assert_eq!(config.desktop_service_start_timeout_ms, 5000);
1750    }
1751
1752    #[cfg(feature = "desktop-service")]
1753    #[test]
1754    fn plugin_config_desktop_start_timeout_custom() {
1755        let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
1756        let config: PluginConfig = serde_json::from_str(json).unwrap();
1757        assert_eq!(config.desktop_service_start_timeout_ms, 10000);
1758    }
1759
1760    #[cfg(feature = "desktop-service")]
1761    #[test]
1762    fn plugin_config_desktop_start_timeout_serde_roundtrip() {
1763        let config = PluginConfig {
1764            desktop_service_start_timeout_ms: 15000,
1765            ..Default::default()
1766        };
1767        let json = serde_json::to_string(&config).unwrap();
1768        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1769        assert_eq!(de.desktop_service_start_timeout_ms, 15000);
1770    }
1771
1772    #[cfg(feature = "desktop-service")]
1773    #[test]
1774    fn plugin_config_desktop_start_timeout_json_key_camel_case() {
1775        let config = PluginConfig {
1776            desktop_service_start_timeout_ms: 3000,
1777            ..Default::default()
1778        };
1779        let json = serde_json::to_string(&config).unwrap();
1780        assert!(
1781            json.contains("desktopServiceStartTimeoutMs"),
1782            "JSON should use camelCase: {json}"
1783        );
1784    }
1785
1786    #[cfg(feature = "desktop-service")]
1787    #[test]
1788    fn plugin_config_desktop_all_new_fields_roundtrip() {
1789        let config = PluginConfig {
1790            desktop_service_autostart: true,
1791            desktop_start_service_if_missing: true,
1792            desktop_service_start_timeout_ms: 8000,
1793            ..Default::default()
1794        };
1795        let json = serde_json::to_string(&config).unwrap();
1796        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1797        assert!(de.desktop_service_autostart);
1798        assert!(de.desktop_start_service_if_missing);
1799        assert_eq!(de.desktop_service_start_timeout_ms, 8000);
1800    }
1801
1802    use tauri::AppHandle;
1803
1804    // --- ServiceContext mobile fields tests ---
1805
1806    /// Compile-time + runtime test: ServiceContext mobile fields are String.
1807    #[cfg(mobile)]
1808    #[allow(dead_code)]
1809    fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
1810        let ctx = ServiceContext {
1811            notifier: Notifier { app: app.clone() },
1812            app,
1813            shutdown: CancellationToken::new(),
1814            service_label: "Syncing".into(),
1815            foreground_service_type: "dataSync".into(),
1816        };
1817        assert_eq!(ctx.service_label, "Syncing");
1818        assert_eq!(ctx.foreground_service_type, "dataSync");
1819    }
1820
1821    /// Compile-time + runtime test: ServiceContext on desktop has no mobile fields.
1822    #[cfg(not(mobile))]
1823    #[allow(dead_code)]
1824    fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
1825        let ctx = ServiceContext {
1826            notifier: Notifier { app: app.clone() },
1827            app,
1828            shutdown: CancellationToken::new(),
1829        };
1830        // Compiles — service_label and foreground_service_type are absent.
1831        let _ = ctx;
1832    }
1833
1834    // --- Foreground service type validation tests ---
1835
1836    #[test]
1837    fn validate_data_sync_passes() {
1838        assert!(
1839            validate_foreground_service_type("dataSync").is_ok(),
1840            "dataSync should be valid"
1841        );
1842    }
1843
1844    #[test]
1845    fn validate_special_use_passes() {
1846        assert!(
1847            validate_foreground_service_type("specialUse").is_ok(),
1848            "specialUse should be valid"
1849        );
1850    }
1851
1852    #[test]
1853    fn validate_invalid_type_returns_platform_error() {
1854        let result = validate_foreground_service_type("invalidType");
1855        assert!(result.is_err(), "invalidType should be rejected");
1856        match result {
1857            Err(crate::error::ServiceError::Platform(msg)) => {
1858                assert!(
1859                    msg.contains("invalidType"),
1860                    "error should mention the type: {msg}"
1861                );
1862            }
1863            other => panic!("Expected Platform error, got: {other:?}"),
1864        }
1865    }
1866
1867    #[test]
1868    fn validate_all_14_types_pass() {
1869        for &t in VALID_FOREGROUND_SERVICE_TYPES {
1870            assert!(
1871                validate_foreground_service_type(t).is_ok(),
1872                "{t} should be valid"
1873            );
1874        }
1875    }
1876
1877    #[test]
1878    fn valid_types_count_is_14() {
1879        assert_eq!(
1880            VALID_FOREGROUND_SERVICE_TYPES.len(),
1881            14,
1882            "should have exactly 14 valid types"
1883        );
1884    }
1885
1886    #[test]
1887    fn validate_empty_string_returns_error() {
1888        let result = validate_foreground_service_type("");
1889        assert!(result.is_err(), "empty string should be rejected");
1890    }
1891
1892    #[test]
1893    fn validate_case_sensitive() {
1894        // "DataSync" (capitalized) should NOT pass — case-sensitive.
1895        let result = validate_foreground_service_type("DataSync");
1896        assert!(
1897            result.is_err(),
1898            "validation should be case-sensitive: DataSync should fail"
1899        );
1900    }
1901
1902    // --- ServiceState serde tests ---
1903
1904    #[test]
1905    fn service_state_idle_serde_roundtrip() {
1906        let state = ServiceState::Idle;
1907        let json = serde_json::to_string(&state).unwrap();
1908        let de: ServiceState = serde_json::from_str(&json).unwrap();
1909        assert_eq!(de, ServiceState::Idle);
1910    }
1911
1912    #[test]
1913    fn service_state_initializing_serde_roundtrip() {
1914        let state = ServiceState::Initializing;
1915        let json = serde_json::to_string(&state).unwrap();
1916        let de: ServiceState = serde_json::from_str(&json).unwrap();
1917        assert_eq!(de, ServiceState::Initializing);
1918    }
1919
1920    #[test]
1921    fn service_state_running_serde_roundtrip() {
1922        let state = ServiceState::Running;
1923        let json = serde_json::to_string(&state).unwrap();
1924        let de: ServiceState = serde_json::from_str(&json).unwrap();
1925        assert_eq!(de, ServiceState::Running);
1926    }
1927
1928    #[test]
1929    fn service_state_stopped_serde_roundtrip() {
1930        let state = ServiceState::Stopped;
1931        let json = serde_json::to_string(&state).unwrap();
1932        let de: ServiceState = serde_json::from_str(&json).unwrap();
1933        assert_eq!(de, ServiceState::Stopped);
1934    }
1935
1936    #[test]
1937    fn service_state_json_values_are_camel_case() {
1938        assert_eq!(
1939            serde_json::to_string(&ServiceState::Idle).unwrap(),
1940            "\"idle\""
1941        );
1942        assert_eq!(
1943            serde_json::to_string(&ServiceState::Initializing).unwrap(),
1944            "\"initializing\""
1945        );
1946        assert_eq!(
1947            serde_json::to_string(&ServiceState::Running).unwrap(),
1948            "\"running\""
1949        );
1950        assert_eq!(
1951            serde_json::to_string(&ServiceState::Stopped).unwrap(),
1952            "\"stopped\""
1953        );
1954    }
1955
1956    // --- ServiceStatus serde tests ---
1957
1958    #[test]
1959    fn service_status_serde_roundtrip_idle() {
1960        let status = ServiceStatus {
1961            state: ServiceState::Idle,
1962            ..Default::default()
1963        };
1964        let json = serde_json::to_string(&status).unwrap();
1965        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
1966        assert_eq!(de.state, ServiceState::Idle);
1967        assert_eq!(de.last_error, None);
1968    }
1969
1970    #[test]
1971    fn service_status_serde_roundtrip_with_error() {
1972        let status = ServiceStatus {
1973            state: ServiceState::Stopped,
1974            last_error: Some("init failed".into()),
1975            ..Default::default()
1976        };
1977        let json = serde_json::to_string(&status).unwrap();
1978        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
1979        assert_eq!(de.state, ServiceState::Stopped);
1980        assert_eq!(de.last_error, Some("init failed".into()));
1981    }
1982
1983    #[test]
1984    fn service_status_json_keys_camel_case() {
1985        let status = ServiceStatus {
1986            state: ServiceState::Running,
1987            ..Default::default()
1988        };
1989        let json = serde_json::to_string(&status).unwrap();
1990        assert!(json.contains("\"state\":"), "state key: {json}");
1991        assert!(json.contains("\"lastError\":"), "lastError key: {json}");
1992    }
1993
1994    #[test]
1995    fn service_status_json_null_last_error() {
1996        let status = ServiceStatus {
1997            state: ServiceState::Idle,
1998            ..Default::default()
1999        };
2000        let json = serde_json::to_string(&status).unwrap();
2001        assert!(
2002            json.contains("\"lastError\":null"),
2003            "lastError should be null: {json}"
2004        );
2005    }
2006
2007    // --- Platform tests ---
2008
2009    #[test]
2010    fn platform_serde_roundtrip() {
2011        for variant in [
2012            Platform::Android,
2013            Platform::Ios,
2014            Platform::Windows,
2015            Platform::Macos,
2016            Platform::Linux,
2017            Platform::Unknown,
2018        ] {
2019            let json = serde_json::to_string(&variant).unwrap();
2020            let de: Platform = serde_json::from_str(&json).unwrap();
2021            assert_eq!(de, variant);
2022        }
2023    }
2024
2025    #[test]
2026    fn platform_json_values_are_camel_case() {
2027        assert_eq!(
2028            serde_json::to_string(&Platform::Android).unwrap(),
2029            "\"android\""
2030        );
2031        assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
2032        assert_eq!(
2033            serde_json::to_string(&Platform::Windows).unwrap(),
2034            "\"windows\""
2035        );
2036        assert_eq!(
2037            serde_json::to_string(&Platform::Macos).unwrap(),
2038            "\"macos\""
2039        );
2040        assert_eq!(
2041            serde_json::to_string(&Platform::Linux).unwrap(),
2042            "\"linux\""
2043        );
2044        assert_eq!(
2045            serde_json::to_string(&Platform::Unknown).unwrap(),
2046            "\"unknown\""
2047        );
2048    }
2049
2050    // --- LifecycleMode tests ---
2051
2052    #[test]
2053    fn lifecycle_mode_serde_roundtrip() {
2054        for variant in [
2055            LifecycleMode::AndroidForegroundService,
2056            LifecycleMode::IosBgTaskScheduler,
2057            LifecycleMode::DesktopInProcess,
2058            LifecycleMode::DesktopOsService,
2059        ] {
2060            let json = serde_json::to_string(&variant).unwrap();
2061            let de: LifecycleMode = serde_json::from_str(&json).unwrap();
2062            assert_eq!(de, variant);
2063        }
2064    }
2065
2066    #[test]
2067    fn lifecycle_mode_json_values_are_camel_case() {
2068        assert_eq!(
2069            serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
2070            "\"androidForegroundService\""
2071        );
2072        assert_eq!(
2073            serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
2074            "\"iosBgTaskScheduler\""
2075        );
2076        assert_eq!(
2077            serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
2078            "\"desktopInProcess\""
2079        );
2080        assert_eq!(
2081            serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
2082            "\"desktopOsService\""
2083        );
2084    }
2085
2086    // --- LifecycleGuarantee tests ---
2087
2088    #[test]
2089    fn lifecycle_guarantee_serde_roundtrip() {
2090        for variant in [
2091            LifecycleGuarantee::Guaranteed,
2092            LifecycleGuarantee::BestEffort,
2093            LifecycleGuarantee::Unsupported,
2094        ] {
2095            let json = serde_json::to_string(&variant).unwrap();
2096            let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
2097            assert_eq!(de, variant);
2098        }
2099    }
2100
2101    #[test]
2102    fn lifecycle_guarantee_json_values_are_camel_case() {
2103        assert_eq!(
2104            serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
2105            "\"guaranteed\""
2106        );
2107        assert_eq!(
2108            serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
2109            "\"bestEffort\""
2110        );
2111        assert_eq!(
2112            serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
2113            "\"unsupported\""
2114        );
2115    }
2116
2117    // --- PlatformCapabilities tests ---
2118
2119    #[test]
2120    fn platform_capabilities_serde_roundtrip() {
2121        let caps = PlatformCapabilities {
2122            platform: Platform::Android,
2123            lifecycle_mode: LifecycleMode::AndroidForegroundService,
2124            survives_app_close: LifecycleGuarantee::BestEffort,
2125            survives_reboot: LifecycleGuarantee::BestEffort,
2126            survives_force_quit: LifecycleGuarantee::Unsupported,
2127            background_execution: LifecycleGuarantee::Guaranteed,
2128            limitations: vec!["OEM battery optimization".into()],
2129            required_setup: vec!["FOREGROUND_SERVICE permission".into()],
2130        };
2131        let json = serde_json::to_string(&caps).unwrap();
2132        let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
2133        assert_eq!(de, caps);
2134    }
2135
2136    #[test]
2137    fn platform_capabilities_json_keys_camel_case() {
2138        let caps = PlatformCapabilities {
2139            platform: Platform::Linux,
2140            lifecycle_mode: LifecycleMode::DesktopInProcess,
2141            survives_app_close: LifecycleGuarantee::Unsupported,
2142            survives_reboot: LifecycleGuarantee::Unsupported,
2143            survives_force_quit: LifecycleGuarantee::Unsupported,
2144            background_execution: LifecycleGuarantee::Guaranteed,
2145            limitations: vec![],
2146            required_setup: vec![],
2147        };
2148        let json = serde_json::to_string(&caps).unwrap();
2149        assert!(json.contains("\"platform\":"), "platform: {json}");
2150        assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
2151        assert!(
2152            json.contains("\"survivesAppClose\":"),
2153            "survivesAppClose: {json}"
2154        );
2155        assert!(
2156            json.contains("\"survivesReboot\":"),
2157            "survivesReboot: {json}"
2158        );
2159        assert!(
2160            json.contains("\"survivesForceQuit\":"),
2161            "survivesForceQuit: {json}"
2162        );
2163        assert!(
2164            json.contains("\"backgroundExecution\":"),
2165            "backgroundExecution: {json}"
2166        );
2167        assert!(json.contains("\"limitations\":"), "limitations: {json}");
2168        assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
2169    }
2170
2171    #[test]
2172    fn platform_capabilities_empty_collections_serialize() {
2173        let caps = PlatformCapabilities {
2174            platform: Platform::Unknown,
2175            lifecycle_mode: LifecycleMode::DesktopInProcess,
2176            survives_app_close: LifecycleGuarantee::Unsupported,
2177            survives_reboot: LifecycleGuarantee::Unsupported,
2178            survives_force_quit: LifecycleGuarantee::Unsupported,
2179            background_execution: LifecycleGuarantee::Unsupported,
2180            limitations: vec![],
2181            required_setup: vec![],
2182        };
2183        let json = serde_json::to_string(&caps).unwrap();
2184        assert!(json.contains("\"limitations\":[]"), "{json}");
2185        assert!(json.contains("\"requiredSetup\":[]"), "{json}");
2186    }
2187
2188    // --- NativeState tests ---
2189
2190    #[test]
2191    fn native_state_serde_roundtrip() {
2192        for variant in [
2193            NativeState::Idle,
2194            NativeState::Starting,
2195            NativeState::Running,
2196            NativeState::Stopping,
2197            NativeState::Timeout,
2198            NativeState::Expired,
2199            NativeState::Recovering,
2200            NativeState::Error,
2201        ] {
2202            let json = serde_json::to_string(&variant).unwrap();
2203            let de: NativeState = serde_json::from_str(&json).unwrap();
2204            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
2205        }
2206    }
2207
2208    #[test]
2209    fn native_state_json_values_are_camel_case() {
2210        assert_eq!(
2211            serde_json::to_string(&NativeState::Idle).unwrap(),
2212            "\"idle\""
2213        );
2214        assert_eq!(
2215            serde_json::to_string(&NativeState::Starting).unwrap(),
2216            "\"starting\""
2217        );
2218        assert_eq!(
2219            serde_json::to_string(&NativeState::Running).unwrap(),
2220            "\"running\""
2221        );
2222        assert_eq!(
2223            serde_json::to_string(&NativeState::Stopping).unwrap(),
2224            "\"stopping\""
2225        );
2226        assert_eq!(
2227            serde_json::to_string(&NativeState::Timeout).unwrap(),
2228            "\"timeout\""
2229        );
2230        assert_eq!(
2231            serde_json::to_string(&NativeState::Expired).unwrap(),
2232            "\"expired\""
2233        );
2234        assert_eq!(
2235            serde_json::to_string(&NativeState::Recovering).unwrap(),
2236            "\"recovering\""
2237        );
2238        assert_eq!(
2239            serde_json::to_string(&NativeState::Error).unwrap(),
2240            "\"error\""
2241        );
2242    }
2243
2244    // --- Extended ServiceStatus tests ---
2245
2246    #[test]
2247    fn service_status_backward_compat_deserialize_old_json() {
2248        let old_json = r#"{"state":"running","lastError":null}"#;
2249        let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
2250        assert_eq!(status.state, ServiceState::Running);
2251        assert_eq!(status.last_error, None);
2252        assert_eq!(status.desired_running, None);
2253        assert_eq!(status.native_state, None);
2254        assert_eq!(status.platform_mode, None);
2255        assert_eq!(status.last_start_config, None);
2256        assert_eq!(status.last_heartbeat_at, None);
2257        assert_eq!(status.restart_attempt, None);
2258        assert_eq!(status.recovery_reason, None);
2259        assert_eq!(status.platform_error, None);
2260    }
2261
2262    #[test]
2263    fn service_status_new_fields_serialize_when_present() {
2264        let status = ServiceStatus {
2265            state: ServiceState::Running,
2266            last_error: None,
2267            desired_running: Some(true),
2268            native_state: Some(NativeState::Running),
2269            platform_mode: Some(LifecycleMode::AndroidForegroundService),
2270            last_start_config: Some(StartConfig::default()),
2271            last_heartbeat_at: Some(1234567890),
2272            restart_attempt: Some(2),
2273            recovery_reason: Some("boot recovery".into()),
2274            platform_error: Some("timeout exceeded".into()),
2275        };
2276        let json = serde_json::to_string(&status).unwrap();
2277        assert!(json.contains("\"desiredRunning\":true"), "{json}");
2278        assert!(json.contains("\"nativeState\":\"running\""), "{json}");
2279        assert!(
2280            json.contains("\"platformMode\":\"androidForegroundService\""),
2281            "{json}"
2282        );
2283        assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
2284        assert!(json.contains("\"restartAttempt\":2"), "{json}");
2285        assert!(
2286            json.contains("\"recoveryReason\":\"boot recovery\""),
2287            "{json}"
2288        );
2289        assert!(
2290            json.contains("\"platformError\":\"timeout exceeded\""),
2291            "{json}"
2292        );
2293    }
2294
2295    #[test]
2296    fn service_status_new_fields_absent_when_none() {
2297        let status = ServiceStatus {
2298            state: ServiceState::Idle,
2299            last_error: None,
2300            desired_running: None,
2301            native_state: None,
2302            platform_mode: None,
2303            last_start_config: None,
2304            last_heartbeat_at: None,
2305            restart_attempt: None,
2306            recovery_reason: None,
2307            platform_error: None,
2308        };
2309        let json = serde_json::to_string(&status).unwrap();
2310        assert!(!json.contains("desiredRunning"), "should be absent: {json}");
2311        assert!(!json.contains("nativeState"), "should be absent: {json}");
2312        assert!(!json.contains("platformMode"), "should be absent: {json}");
2313        assert!(
2314            !json.contains("lastStartConfig"),
2315            "should be absent: {json}"
2316        );
2317        assert!(
2318            !json.contains("lastHeartbeatAt"),
2319            "should be absent: {json}"
2320        );
2321        assert!(!json.contains("restartAttempt"), "should be absent: {json}");
2322        assert!(!json.contains("recoveryReason"), "should be absent: {json}");
2323        assert!(!json.contains("platformError"), "should be absent: {json}");
2324    }
2325
2326    #[test]
2327    fn service_status_default_impl() {
2328        let status = ServiceStatus::default();
2329        assert_eq!(status.state, ServiceState::Idle);
2330        assert_eq!(status.last_error, None);
2331        assert_eq!(status.desired_running, None);
2332        assert_eq!(status.native_state, None);
2333        assert_eq!(status.platform_mode, None);
2334        assert_eq!(status.last_start_config, None);
2335        assert_eq!(status.last_heartbeat_at, None);
2336        assert_eq!(status.restart_attempt, None);
2337        assert_eq!(status.recovery_reason, None);
2338        assert_eq!(status.platform_error, None);
2339    }
2340
2341    #[test]
2342    fn service_status_full_roundtrip_with_all_fields() {
2343        let status = ServiceStatus {
2344            state: ServiceState::Running,
2345            last_error: Some("previous crash".into()),
2346            desired_running: Some(true),
2347            native_state: Some(NativeState::Recovering),
2348            platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
2349            last_start_config: Some(StartConfig {
2350                service_label: "Sync".into(),
2351                foreground_service_type: "dataSync".into(),
2352            }),
2353            last_heartbeat_at: Some(999),
2354            restart_attempt: Some(3),
2355            recovery_reason: Some("force stop".into()),
2356            platform_error: Some("scheduler busy".into()),
2357        };
2358        let json = serde_json::to_string(&status).unwrap();
2359        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
2360        assert_eq!(de.state, ServiceState::Running);
2361        assert_eq!(de.last_error, Some("previous crash".into()));
2362        assert_eq!(de.desired_running, Some(true));
2363        assert_eq!(de.native_state, Some(NativeState::Recovering));
2364        assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
2365        assert!(de.last_start_config.is_some());
2366        assert_eq!(de.last_heartbeat_at, Some(999));
2367        assert_eq!(de.restart_attempt, Some(3));
2368        assert_eq!(de.recovery_reason, Some("force stop".into()));
2369        assert_eq!(de.platform_error, Some("scheduler busy".into()));
2370    }
2371
2372    #[test]
2373    fn platform_capabilities_deserialize_from_json() {
2374        let json = r#"{
2375            "platform":"ios",
2376            "lifecycleMode":"iosBgTaskScheduler",
2377            "survivesAppClose":"bestEffort",
2378            "survivesReboot":"bestEffort",
2379            "survivesForceQuit":"unsupported",
2380            "backgroundExecution":"bestEffort",
2381            "limitations":["Cannot guarantee continuous execution"],
2382            "requiredSetup":["UIBackgroundModes in Info.plist"]
2383        }"#;
2384        let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
2385        assert_eq!(caps.platform, Platform::Ios);
2386        assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
2387        assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
2388        assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
2389        assert_eq!(caps.limitations.len(), 1);
2390        assert_eq!(caps.required_setup.len(), 1);
2391    }
2392
2393    // --- IOSSchedulingStatus tests ---
2394
2395    #[test]
2396    fn ios_scheduling_status_both_scheduled() {
2397        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
2398        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2399        assert!(status.refresh_scheduled);
2400        assert!(status.processing_scheduled);
2401        assert_eq!(status.refresh_error, None);
2402        assert_eq!(status.processing_error, None);
2403    }
2404
2405    #[test]
2406    fn ios_scheduling_status_partial_success() {
2407        let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
2408        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2409        assert!(status.refresh_scheduled);
2410        assert!(!status.processing_scheduled);
2411        assert_eq!(status.refresh_error, None);
2412        assert_eq!(status.processing_error, Some("not permitted".to_string()));
2413    }
2414
2415    #[test]
2416    fn ios_scheduling_status_with_errors() {
2417        let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
2418        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2419        assert!(!status.refresh_scheduled);
2420        assert!(!status.processing_scheduled);
2421        assert_eq!(status.refresh_error, Some("err1".to_string()));
2422        assert_eq!(status.processing_error, Some("err2".to_string()));
2423    }
2424
2425    #[test]
2426    fn ios_scheduling_status_serde_roundtrip() {
2427        let status = IOSSchedulingStatus {
2428            refresh_scheduled: true,
2429            processing_scheduled: false,
2430            refresh_error: None,
2431            processing_error: Some("busy".into()),
2432        };
2433        let json = serde_json::to_string(&status).unwrap();
2434        let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
2435        assert_eq!(de, status);
2436    }
2437
2438    #[test]
2439    fn ios_scheduling_status_json_keys_camel_case() {
2440        let status = IOSSchedulingStatus {
2441            refresh_scheduled: true,
2442            processing_scheduled: true,
2443            refresh_error: Some("err".into()),
2444            processing_error: None,
2445        };
2446        let json = serde_json::to_string(&status).unwrap();
2447        assert!(json.contains("\"refreshScheduled\":"), "{json}");
2448        assert!(json.contains("\"processingScheduled\":"), "{json}");
2449        assert!(json.contains("\"refreshError\":"), "{json}");
2450        assert!(
2451            !json.contains("processingError"),
2452            "None fields should be absent: {json}"
2453        );
2454    }
2455
2456    #[test]
2457    fn ios_scheduling_status_from_value_null_errors() {
2458        // Simulates the Swift response where errors are NSNull()
2459        let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
2460        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2461        assert!(status.refresh_scheduled);
2462        assert!(status.processing_scheduled);
2463        assert_eq!(status.refresh_error, None);
2464        assert_eq!(status.processing_error, None);
2465    }
2466
2467    #[test]
2468    fn ios_scheduling_status_from_value_missing_errors() {
2469        // Swift may not include error fields when scheduling succeeds
2470        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
2471        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2472        assert!(status.refresh_scheduled);
2473        assert!(status.processing_scheduled);
2474        assert_eq!(status.refresh_error, None);
2475        assert_eq!(status.processing_error, None);
2476    }
2477
2478    // --- OsServiceInstallState tests ---
2479
2480    #[test]
2481    fn os_service_install_state_serde_roundtrip() {
2482        for variant in [
2483            OsServiceInstallState::NotInstalled,
2484            OsServiceInstallState::Installed,
2485            OsServiceInstallState::Running,
2486        ] {
2487            let json = serde_json::to_string(&variant).unwrap();
2488            let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
2489            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
2490        }
2491    }
2492
2493    #[test]
2494    fn os_service_install_state_json_values_camel_case() {
2495        assert_eq!(
2496            serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
2497            "\"notInstalled\""
2498        );
2499        assert_eq!(
2500            serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
2501            "\"installed\""
2502        );
2503        assert_eq!(
2504            serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
2505            "\"running\""
2506        );
2507    }
2508
2509    // --- OsServiceStatus tests ---
2510
2511    #[test]
2512    fn os_service_status_serde_roundtrip() {
2513        let status = OsServiceStatus {
2514            label: "com.example.bg-service".into(),
2515            mode: "systemd".into(),
2516            installed: OsServiceInstallState::Running,
2517            ipc_connected: true,
2518            socket_path: Some("/tmp/test.sock".into()),
2519            last_error: None,
2520        };
2521        let json = serde_json::to_string(&status).unwrap();
2522        let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
2523        assert_eq!(de.label, "com.example.bg-service");
2524        assert_eq!(de.mode, "systemd");
2525        assert_eq!(de.installed, OsServiceInstallState::Running);
2526        assert!(de.ipc_connected);
2527        assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
2528        assert_eq!(de.last_error, None);
2529    }
2530
2531    #[test]
2532    fn os_service_status_json_keys_camel_case() {
2533        let status = OsServiceStatus {
2534            label: "test".into(),
2535            mode: "launchd".into(),
2536            installed: OsServiceInstallState::Installed,
2537            ipc_connected: false,
2538            socket_path: Some("/run/test.sock".into()),
2539            last_error: Some("timeout".into()),
2540        };
2541        let json = serde_json::to_string(&status).unwrap();
2542        assert!(json.contains("\"label\":"), "{json}");
2543        assert!(json.contains("\"mode\":"), "{json}");
2544        assert!(json.contains("\"installed\":"), "{json}");
2545        assert!(json.contains("\"ipcConnected\":"), "{json}");
2546        assert!(json.contains("\"socketPath\":"), "{json}");
2547        assert!(json.contains("\"lastError\":"), "{json}");
2548    }
2549
2550    #[test]
2551    fn os_service_status_optional_fields_absent_when_none() {
2552        let status = OsServiceStatus {
2553            label: "test".into(),
2554            mode: "systemd".into(),
2555            installed: OsServiceInstallState::NotInstalled,
2556            ipc_connected: false,
2557            socket_path: None,
2558            last_error: None,
2559        };
2560        let json = serde_json::to_string(&status).unwrap();
2561        assert!(!json.contains("socketPath"), "should be absent: {json}");
2562        assert!(!json.contains("lastError"), "should be absent: {json}");
2563    }
2564
2565    #[test]
2566    fn os_service_status_with_all_optional_fields() {
2567        let status = OsServiceStatus {
2568            label: "com.test".into(),
2569            mode: "launchd".into(),
2570            installed: OsServiceInstallState::Running,
2571            ipc_connected: true,
2572            socket_path: Some("/var/run/com.test.sock".into()),
2573            last_error: Some("connection refused".into()),
2574        };
2575        let json = serde_json::to_string(&status).unwrap();
2576        assert!(
2577            json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
2578            "{json}"
2579        );
2580        assert!(
2581            json.contains("\"lastError\":\"connection refused\""),
2582            "{json}"
2583        );
2584    }
2585
2586    #[test]
2587    fn os_service_status_deserialize_from_json() {
2588        let json = r#"{
2589            "label":"com.example.svc",
2590            "mode":"systemd",
2591            "installed":"running",
2592            "ipcConnected":true,
2593            "socketPath":"/tmp/test.sock"
2594        }"#;
2595        let status: OsServiceStatus = serde_json::from_str(json).unwrap();
2596        assert_eq!(status.label, "com.example.svc");
2597        assert_eq!(status.mode, "systemd");
2598        assert_eq!(status.installed, OsServiceInstallState::Running);
2599        assert!(status.ipc_connected);
2600        assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
2601        assert_eq!(status.last_error, None);
2602    }
2603
2604    // --- PendingTaskInfo tests ---
2605
2606    #[test]
2607    fn pending_task_info_serde_roundtrip() {
2608        let info = PendingTaskInfo {
2609            task_kind: "refresh".into(),
2610            identifier: "com.example.app.bg-refresh".into(),
2611            received_at: 1700000000.123,
2612        };
2613        let json = serde_json::to_string(&info).unwrap();
2614        let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
2615        assert_eq!(de, info);
2616    }
2617
2618    #[test]
2619    fn pending_task_info_json_keys_camel_case() {
2620        let info = PendingTaskInfo {
2621            task_kind: "processing".into(),
2622            identifier: "test-id".into(),
2623            received_at: 123456.0,
2624        };
2625        let json = serde_json::to_string(&info).unwrap();
2626        assert!(json.contains("\"taskKind\":"), "{json}");
2627        assert!(json.contains("\"identifier\":"), "{json}");
2628        assert!(json.contains("\"receivedAt\":"), "{json}");
2629    }
2630
2631    #[test]
2632    fn pending_task_info_from_native_response() {
2633        // Simulates the Swift response when a pending task exists
2634        let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
2635        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
2636        assert_eq!(info.task_kind, "refresh");
2637        assert_eq!(info.identifier, "com.example.bg-refresh");
2638        assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
2639    }
2640
2641    #[test]
2642    fn pending_task_info_processing_kind() {
2643        let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
2644        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
2645        assert_eq!(info.task_kind, "processing");
2646        assert_eq!(info.identifier, "com.example.bg-processing");
2647    }
2648}