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", alias = "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    /// Whether to automatically request POST_NOTIFICATIONS permission on
191    /// Android API 33+ when the plugin loads. Default: true (backward compat).
192    /// Set to false to manage permission requests explicitly from JS.
193    #[serde(default = "default_true")]
194    pub android_request_notification_permission_on_load: bool,
195
196    /// Desktop service mode: "inProcess" (default) or "osService".
197    /// Controls whether the background service runs in-process or as a
198    /// registered OS service/daemon.
199    #[cfg(feature = "desktop-service")]
200    #[serde(default = "default_desktop_service_mode")]
201    pub desktop_service_mode: String,
202
203    /// Optional custom label for the desktop OS service registration.
204    /// When `None`, the label is auto-derived from the app identifier.
205    #[cfg(feature = "desktop-service")]
206    #[serde(default)]
207    pub desktop_service_label: Option<String>,
208
209    /// Whether the OS service should start automatically on boot (Linux) or
210    /// login (macOS). Only applies when `desktop_service_mode` is `"osService"`.
211    /// Default: false.
212    #[cfg(feature = "desktop-service")]
213    #[serde(default)]
214    pub desktop_service_autostart: bool,
215
216    /// When `true`, calling `startService()` will automatically start the OS
217    /// service if it is not already running (i.e. IPC is disconnected).
218    /// Only applies when `desktop_service_mode` is `"osService"`.
219    /// Default: false.
220    #[cfg(feature = "desktop-service")]
221    #[serde(default)]
222    pub desktop_start_service_if_missing: bool,
223
224    /// Timeout in milliseconds to wait for the IPC connection to become ready
225    /// after starting the OS service sidecar. Only applies when
226    /// `desktop_start_service_if_missing` is `true`.
227    /// Default: 5000 (5 seconds).
228    #[cfg(feature = "desktop-service")]
229    #[serde(default = "default_desktop_service_start_timeout_ms")]
230    pub desktop_service_start_timeout_ms: u64,
231}
232
233fn default_ios_safety_timeout() -> f64 {
234    28.0
235}
236
237fn default_ios_cancel_listener_timeout_secs() -> u64 {
238    14400
239}
240
241fn default_ios_processing_safety_timeout_secs() -> f64 {
242    0.0
243}
244
245fn default_ios_earliest_refresh_begin_minutes() -> f64 {
246    15.0
247}
248
249fn default_ios_earliest_processing_begin_minutes() -> f64 {
250    15.0
251}
252
253fn default_android_foreground_service_types() -> Vec<String> {
254    vec!["dataSync".into()]
255}
256
257fn default_android_on_timeout() -> String {
258    "notifyUser".into()
259}
260
261fn default_android_notification_channel_id() -> String {
262    "bg_service".into()
263}
264
265fn default_android_notification_channel_name() -> String {
266    "Background Service".into()
267}
268
269fn default_android_notification_id() -> u32 {
270    9001
271}
272
273fn default_true() -> bool {
274    true
275}
276
277fn default_channel_capacity() -> usize {
278    16
279}
280
281#[cfg(feature = "desktop-service")]
282fn default_desktop_service_mode() -> String {
283    "inProcess".into()
284}
285
286#[cfg(feature = "desktop-service")]
287fn default_desktop_service_start_timeout_ms() -> u64 {
288    5000
289}
290
291impl Default for StartConfig {
292    fn default() -> Self {
293        Self {
294            service_label: default_label(),
295            foreground_service_type: default_foreground_service_type(),
296        }
297    }
298}
299
300/// Lifecycle state of the background service.
301///
302/// Exposed via the `get-service-state` command to provide
303/// fine-grained status beyond a simple boolean.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
305#[serde(rename_all = "camelCase")]
306#[non_exhaustive]
307pub enum ServiceState {
308    /// No service has been started, or the last run has fully cleaned up.
309    Idle,
310    /// `init()` is in progress (between `Start` and successful `init()`).
311    Initializing,
312    /// `init()` succeeded; `run()` is executing.
313    Running,
314    /// Service stopped (by `stop()`, natural completion, or error).
315    Stopped,
316}
317
318/// Unified lifecycle state for the background service.
319///
320/// Provides fine-grained visibility into the service's current state,
321/// combining internal state, recovery status, and platform conditions.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323#[serde(rename_all = "camelCase")]
324#[non_exhaustive]
325pub enum LifecycleState {
326    /// No service has been started.
327    Idle,
328    /// Service init() is in progress.
329    Starting,
330    /// Service run() is executing.
331    Running,
332    /// Service is being stopped (cancellation requested).
333    Stopping,
334    /// Service has stopped.
335    Stopped,
336    /// Service is recovering after a platform timeout or expiration.
337    Recovering,
338    /// Recovery is pending (waiting for platform conditions).
339    RecoveryPending,
340    /// Background execution window has expired (e.g. iOS BGTask).
341    Expired,
342    /// Service is blocked by a platform issue (e.g. missing permission).
343    Blocked,
344    /// Service encountered an error.
345    Error,
346}
347
348impl From<ServiceState> for LifecycleState {
349    fn from(state: ServiceState) -> Self {
350        match state {
351            ServiceState::Idle => LifecycleState::Idle,
352            ServiceState::Initializing => LifecycleState::Starting,
353            ServiceState::Running => LifecycleState::Running,
354            ServiceState::Stopped => LifecycleState::Stopped,
355        }
356    }
357}
358
359/// Native platform-side state reported by the OS service layer.
360///
361/// Reflects the state as observed by the Android foreground service, iOS
362/// BGTask handler, or desktop OS-service process — distinct from the
363/// plugin-internal [`ServiceState`].
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
365#[serde(rename_all = "camelCase")]
366#[non_exhaustive]
367pub enum NativeState {
368    Idle,
369    Starting,
370    Running,
371    Stopping,
372    Timeout,
373    Expired,
374    Recovering,
375    Error,
376}
377
378/// Snapshot of the service lifecycle status.
379///
380/// Returned by the `get-service-state` command.
381#[derive(Debug, Clone, Serialize, Deserialize)]
382#[serde(rename_all = "camelCase")]
383pub struct ServiceStatus {
384    /// Current lifecycle state.
385    pub state: ServiceState,
386    /// Last error message, if the service stopped due to an error.
387    pub last_error: Option<String>,
388
389    // --- Extended fields (Step 4) ---
390    /// Whether the service is desired to be running (persisted across restarts).
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub desired_running: Option<bool>,
393    /// Platform-native state as reported by the OS service layer.
394    #[serde(skip_serializing_if = "Option::is_none")]
395    pub native_state: Option<NativeState>,
396    /// The lifecycle mechanism in use on the current platform.
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub platform_mode: Option<LifecycleMode>,
399    /// Configuration used for the last successful start.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub last_start_config: Option<StartConfig>,
402    /// Epoch milliseconds of the last heartbeat received from the service.
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub last_heartbeat_at: Option<u64>,
405    /// How many restart attempts have been made since the last clean start.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub restart_attempt: Option<u32>,
408    /// Human-readable reason for the current recovery attempt.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub recovery_reason: Option<String>,
411    /// Last platform-specific error message.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub platform_error: Option<String>,
414}
415
416impl Default for ServiceStatus {
417    fn default() -> Self {
418        Self {
419            state: ServiceState::Idle,
420            last_error: None,
421            desired_running: None,
422            native_state: None,
423            platform_mode: None,
424            last_start_config: None,
425            last_heartbeat_at: None,
426            restart_attempt: None,
427            recovery_reason: None,
428            platform_error: None,
429        }
430    }
431}
432
433/// The operating system platform.
434///
435/// Returned by `get_platform_capabilities` to identify the current runtime environment.
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(rename_all = "camelCase")]
438#[non_exhaustive]
439pub enum Platform {
440    Android,
441    Ios,
442    Windows,
443    Macos,
444    Linux,
445    Unknown,
446}
447
448/// Severity level for a validation issue.
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
450#[serde(rename_all = "camelCase")]
451#[non_exhaustive]
452pub enum Severity {
453    Error,
454    Warning,
455    Info,
456}
457
458/// The lifecycle mechanism used by the plugin on the current platform.
459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
460#[serde(rename_all = "camelCase")]
461#[non_exhaustive]
462pub enum LifecycleMode {
463    AndroidForegroundService,
464    IosBgTaskScheduler,
465    DesktopInProcess,
466    DesktopOsService,
467}
468
469/// Guarantee level for a specific background execution scenario.
470///
471/// - `Guaranteed`: The platform reliably supports this scenario.
472/// - `BestEffort`: The platform may support this scenario but cannot guarantee it.
473/// - `Unsupported`: The platform does not support this scenario.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
475#[serde(rename_all = "camelCase")]
476#[non_exhaustive]
477pub enum LifecycleGuarantee {
478    Guaranteed,
479    BestEffort,
480    Unsupported,
481}
482
483/// Platform-specific background execution capabilities.
484///
485/// Returned by the `get_platform_capabilities` Tauri command. Provides honest
486/// reporting of what each platform can guarantee for background service survival.
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
488#[serde(rename_all = "camelCase")]
489#[non_exhaustive]
490pub struct PlatformCapabilities {
491    pub platform: Platform,
492    pub lifecycle_mode: LifecycleMode,
493    pub survives_app_close: LifecycleGuarantee,
494    pub survives_reboot: LifecycleGuarantee,
495    pub survives_force_quit: LifecycleGuarantee,
496    pub background_execution: LifecycleGuarantee,
497    pub limitations: Vec<String>,
498    pub required_setup: Vec<String>,
499}
500
501/// OS service install/running state.
502///
503/// Reported by [`OsServiceStatus`] to indicate whether the OS-level service
504/// (systemd, launchd, etc.) is installed and/or currently running.
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507#[non_exhaustive]
508pub enum OsServiceInstallState {
509    /// The OS service is not installed.
510    NotInstalled,
511    /// The OS service is installed but not currently running.
512    Installed,
513    /// The OS service is installed and currently running.
514    Running,
515}
516
517/// Snapshot of an OS-level service's status.
518///
519/// Returned by `get_os_service_status` to report the state of the desktop
520/// OS service (systemd user service, launchd agent).
521#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
522#[serde(rename_all = "camelCase")]
523#[non_exhaustive]
524pub struct OsServiceStatus {
525    /// The service label (e.g. `com.example.background-service`).
526    pub label: String,
527    /// The service manager kind (e.g. `systemd`, `launchd`).
528    pub mode: String,
529    /// Whether the service is installed and/or running.
530    pub installed: OsServiceInstallState,
531    /// Whether the IPC connection to the service sidecar is active.
532    pub ipc_connected: bool,
533    /// Path to the Unix domain socket used for IPC.
534    #[serde(skip_serializing_if = "Option::is_none")]
535    pub socket_path: Option<String>,
536    /// Last error message from the OS service, if any.
537    #[serde(skip_serializing_if = "Option::is_none")]
538    pub last_error: Option<String>,
539}
540
541/// Reason why the background service stopped.
542///
543/// Structured stop reasons that distinguish between user-initiated stops,
544/// platform-imposed terminations, and natural task completion.
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
546#[serde(rename_all = "camelCase")]
547#[non_exhaustive]
548pub enum StopReason {
549    /// User called stopService().
550    UserStop,
551    /// Application is shutting down gracefully.
552    AppStop,
553    /// Platform killed the service due to a timeout (e.g. Android FGS timeout).
554    PlatformTimeout,
555    /// Platform expired the background execution window (e.g. iOS BGTask).
556    PlatformExpiration,
557    /// User pressed stop on the native notification.
558    NativeNotificationStop,
559    /// OS restarted the service after a reboot.
560    OsRestart,
561    /// Service recovered after device boot.
562    BootRecovery,
563    /// Service's run() returned Ok(()) naturally.
564    TaskCompleted,
565    /// Service's run() returned an error.
566    Error,
567}
568
569impl<'de> serde::Deserialize<'de> for StopReason {
570    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
571        let s = String::deserialize(deserializer)?;
572        match s.as_str() {
573            "userStop" => Ok(Self::UserStop),
574            "appStop" => Ok(Self::AppStop),
575            "platformTimeout" => Ok(Self::PlatformTimeout),
576            "platformExpiration" => Ok(Self::PlatformExpiration),
577            "nativeNotificationStop" => Ok(Self::NativeNotificationStop),
578            "osRestart" => Ok(Self::OsRestart),
579            "bootRecovery" => Ok(Self::BootRecovery),
580            "taskCompleted" => Ok(Self::TaskCompleted),
581            "error" => Ok(Self::Error),
582            // Legacy string mappings for backward compatibility
583            "completed" => Ok(Self::TaskCompleted),
584            "cancelled" | "user" => Ok(Self::UserStop),
585            _ => Err(serde::de::Error::unknown_variant(
586                &s,
587                &[
588                    "userStop",
589                    "appStop",
590                    "platformTimeout",
591                    "platformExpiration",
592                    "nativeNotificationStop",
593                    "osRestart",
594                    "bootRecovery",
595                    "taskCompleted",
596                    "error",
597                ],
598            )),
599        }
600    }
601}
602
603/// Native platform lifecycle events sent from Kotlin/Swift to the Rust actor.
604///
605/// These events originate in the native layer and are forwarded to the actor
606/// via [`ManagerCommand::NativeLifecycleEvent`](crate::manager::ManagerCommand).
607/// The actor maps each variant to the appropriate [`StopReason`].
608#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
609#[serde(rename_all = "camelCase", tag = "type")]
610#[non_exhaustive]
611pub enum NativeLifecycleEvent {
612    /// User pressed stop on the Android foreground service notification.
613    AndroidNotificationStop,
614    /// Android system killed the foreground service due to a timeout.
615    AndroidTimeout {
616        /// The foreground service type that timed out (e.g. "dataSync").
617        #[serde(skip_serializing_if = "Option::is_none")]
618        fgs_type: Option<String>,
619    },
620}
621
622impl NativeLifecycleEvent {
623    /// Map this native event to the corresponding [`StopReason`].
624    pub fn to_stop_reason(&self) -> StopReason {
625        match self {
626            Self::AndroidNotificationStop => StopReason::NativeNotificationStop,
627            Self::AndroidTimeout { .. } => StopReason::PlatformTimeout,
628        }
629    }
630}
631
632/// Built-in event types emitted by the runner itself.
633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
634#[serde(rename_all = "camelCase", tag = "type")]
635#[non_exhaustive]
636pub enum PluginEvent {
637    /// init() completed successfully
638    Started,
639    /// run() returned or was cancelled
640    Stopped { reason: StopReason },
641    /// init() or run() returned an error
642    Error { message: String },
643}
644
645impl Default for PluginConfig {
646    fn default() -> Self {
647        Self {
648            ios_safety_timeout_secs: default_ios_safety_timeout(),
649            ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
650            ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
651            ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
652            ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
653            ios_requires_external_power: false,
654            ios_requires_network_connectivity: false,
655            channel_capacity: default_channel_capacity(),
656            android_foreground_service_types: default_android_foreground_service_types(),
657            android_validate_foreground_service_type: default_true(),
658            android_on_timeout: default_android_on_timeout(),
659            android_notification_channel_id: default_android_notification_channel_id(),
660            android_notification_channel_name: default_android_notification_channel_name(),
661            android_notification_id: default_android_notification_id(),
662            android_notification_small_icon: None,
663            android_show_stop_action: default_true(),
664            android_request_notification_permission_on_load: default_true(),
665            #[cfg(feature = "desktop-service")]
666            desktop_service_mode: default_desktop_service_mode(),
667            #[cfg(feature = "desktop-service")]
668            desktop_service_label: None,
669            #[cfg(feature = "desktop-service")]
670            desktop_service_autostart: false,
671            #[cfg(feature = "desktop-service")]
672            desktop_start_service_if_missing: false,
673            #[cfg(feature = "desktop-service")]
674            desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
675        }
676    }
677}
678
679/// Arguments sent to the native `startKeepalive` handler.
680///
681/// Lives in `models.rs` (not `mobile.rs`) so serde tests run on all platforms.
682#[derive(Debug, Serialize)]
683#[serde(rename_all = "camelCase")]
684#[allow(dead_code)]
685pub(crate) struct StartKeepaliveArgs<'a> {
686    pub label: &'a str,
687    pub foreground_service_type: &'a str,
688    /// iOS safety timeout in seconds. Only sent to iOS; `None` omits the key.
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub ios_safety_timeout_secs: Option<f64>,
691    /// iOS BGProcessingTask safety timeout in seconds. Only sent to iOS; `None` omits the key.
692    /// When `Some(positive)`, caps the processing task duration.
693    #[serde(skip_serializing_if = "Option::is_none")]
694    pub ios_processing_safety_timeout_secs: Option<f64>,
695    /// iOS BGAppRefreshTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub ios_earliest_refresh_begin_minutes: Option<f64>,
698    /// iOS BGProcessingTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub ios_earliest_processing_begin_minutes: Option<f64>,
701    /// iOS BGProcessingTask requires external power. Only sent to iOS; `None` omits the key.
702    #[serde(skip_serializing_if = "Option::is_none")]
703    pub ios_requires_external_power: Option<bool>,
704    /// iOS BGProcessingTask requires network connectivity. Only sent to iOS; `None` omits the key.
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub ios_requires_network_connectivity: Option<bool>,
707}
708
709/// Auto-start config returned by the Kotlin bridge.
710///
711/// Deserialized from SharedPreferences values read by `getAutoStartConfig`.
712/// Only used on Android (the iOS path doesn't have auto-start).
713#[doc(hidden)]
714#[derive(Debug, Clone, Deserialize)]
715#[serde(rename_all = "camelCase")]
716pub struct AutoStartConfig {
717    pub pending: bool,
718    pub label: Option<String>,
719    pub service_type: Option<String>,
720}
721
722impl AutoStartConfig {
723    /// Convert to `StartConfig` if auto-start is pending and label is available.
724    pub fn into_start_config(self) -> Option<StartConfig> {
725        if self.pending {
726            self.label.map(|label| StartConfig {
727                service_label: label,
728                foreground_service_type: self
729                    .service_type
730                    .unwrap_or_else(default_foreground_service_type),
731            })
732        } else {
733            None
734        }
735    }
736}
737
738/// Information about a pending iOS background task that launched the app.
739///
740/// Returned by `getPendingBgTask()` on iOS when the app was launched by iOS
741/// for a background task (BGAppRefreshTask or BGProcessingTask). Used by the
742/// Rust auto-start logic to detect OS-initiated launches and automatically
743/// start the service if `desired_running` is true.
744#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
745#[serde(rename_all = "camelCase")]
746#[non_exhaustive]
747pub struct PendingTaskInfo {
748    /// The kind of background task: "refresh" or "processing".
749    pub task_kind: String,
750    /// The task identifier (e.g. "com.example.app.bg-refresh").
751    pub identifier: String,
752    /// Epoch timestamp (seconds) when the task was received by the native layer.
753    pub received_at: f64,
754    /// Epoch timestamp (seconds) when the pending task was consumed by the Rust
755    /// auto-start logic. `None` until `clear_pending_bg_task` is called.
756    #[serde(default)]
757    pub consumed_at: Option<f64>,
758}
759
760/// iOS scheduling status returned by the native layer.
761///
762/// Reports which background task types were successfully scheduled
763/// and any errors that occurred during scheduling. Returned by
764/// `get_scheduling_status` and parsed from `startKeepalive` on iOS.
765#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
766#[serde(rename_all = "camelCase")]
767#[non_exhaustive]
768pub struct IOSSchedulingStatus {
769    /// Whether a `BGAppRefreshTask` was successfully scheduled.
770    pub refresh_scheduled: bool,
771    /// Whether a `BGProcessingTask` was successfully scheduled.
772    pub processing_scheduled: bool,
773    /// Error from `BGAppRefreshTask` scheduling, if any.
774    #[serde(default)]
775    #[serde(skip_serializing_if = "Option::is_none")]
776    pub refresh_error: Option<String>,
777    /// Error from `BGProcessingTask` scheduling, if any.
778    #[serde(default)]
779    #[serde(skip_serializing_if = "Option::is_none")]
780    pub processing_error: Option<String>,
781}
782
783/// A single setup issue found during validation.
784///
785/// Part of [`SetupValidationReport`]. Each issue has a machine-readable code,
786/// a human-readable message, the platform it applies to, and an optional fix.
787#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
788#[serde(rename_all = "camelCase")]
789#[non_exhaustive]
790pub struct SetupIssue {
791    /// Machine-readable error code (e.g. "android_fgs_type").
792    pub code: String,
793    /// Human-readable description of the issue.
794    pub message: String,
795    /// The platform this issue applies to.
796    pub platform: Platform,
797    /// Suggested fix for the issue.
798    #[serde(skip_serializing_if = "Option::is_none")]
799    pub fix: Option<String>,
800}
801
802impl SetupIssue {
803    /// Convert this `SetupIssue` into a [`ValidationIssue`] with the given severity.
804    pub fn to_validation_issue(&self, severity: Severity) -> ValidationIssue {
805        ValidationIssue {
806            severity,
807            code: self.code.clone(),
808            message: self.message.clone(),
809            fix: self.fix.clone(),
810            platform: self.platform,
811        }
812    }
813}
814
815/// Result of validating background service setup prerequisites.
816///
817/// Returned by `validateBackgroundServiceSetup()`. Contains `errors` (blocking
818/// issues that prevent the service from working) and `warnings` (non-blocking
819/// issues that may cause degraded behavior).
820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
821#[serde(rename_all = "camelCase")]
822#[non_exhaustive]
823pub struct SetupValidationReport {
824    /// `true` when `errors` is empty (warnings do not affect this).
825    pub ok: bool,
826    /// Blocking issues that prevent the service from working correctly.
827    pub errors: Vec<SetupIssue>,
828    /// Non-blocking issues that may cause degraded behavior.
829    pub warnings: Vec<SetupIssue>,
830    /// Unified issues with typed severity.
831    ///
832    /// Combines `errors` (as [`Severity::Error`]) and `warnings` (as
833    /// [`Severity::Warning`]) into a single list. Populated automatically
834    /// by [`crate::validator::SetupValidator::validate`].
835    #[serde(default)]
836    pub issues: Vec<ValidationIssue>,
837}
838
839/// A single validation issue found during lifecycle validation.
840///
841/// Each issue has a severity level, machine-readable code, human-readable
842/// message, optional fix suggestion, and the platform it applies to.
843#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
844#[serde(rename_all = "camelCase")]
845#[non_exhaustive]
846pub struct ValidationIssue {
847    pub severity: Severity,
848    pub code: String,
849    pub message: String,
850    #[serde(skip_serializing_if = "Option::is_none")]
851    pub fix: Option<String>,
852    pub platform: Platform,
853}
854
855/// Complete snapshot of the background service lifecycle status.
856///
857/// Provides a unified view of service state, desired state, recovery status,
858/// platform capabilities, and validation issues.
859#[derive(Debug, Clone, Serialize, Deserialize)]
860#[serde(rename_all = "camelCase")]
861#[non_exhaustive]
862pub struct LifecycleStatus {
863    pub state: LifecycleState,
864    pub desired_running: bool,
865    pub recovery_enabled: bool,
866    pub recovery_pending: bool,
867    #[serde(skip_serializing_if = "Option::is_none")]
868    pub recovery_reason: Option<String>,
869    #[serde(skip_serializing_if = "Option::is_none")]
870    pub last_start_config: Option<StartConfig>,
871    #[serde(skip_serializing_if = "Option::is_none")]
872    pub last_platform_state: Option<String>,
873    #[serde(skip_serializing_if = "Option::is_none")]
874    pub last_platform_error: Option<String>,
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub last_error: Option<String>,
877    pub platform: Platform,
878    pub capabilities: PlatformCapabilities,
879    pub issues: Vec<ValidationIssue>,
880}
881
882#[cfg(test)]
883mod tests {
884    use super::*;
885
886    // --- StartConfig tests ---
887
888    #[test]
889    fn start_config_default_label() {
890        let config = StartConfig::default();
891        assert_eq!(config.service_label, "Service running");
892    }
893
894    #[test]
895    fn start_config_custom_label() {
896        let config = StartConfig {
897            service_label: "Syncing data".into(),
898            ..Default::default()
899        };
900        assert_eq!(config.service_label, "Syncing data");
901    }
902
903    #[test]
904    fn start_config_serde_roundtrip_default() {
905        let config = StartConfig::default();
906        let json = serde_json::to_string(&config).unwrap();
907        let de: StartConfig = serde_json::from_str(&json).unwrap();
908        assert_eq!(de.service_label, config.service_label);
909    }
910
911    #[test]
912    fn start_config_serde_roundtrip_custom() {
913        let config = StartConfig {
914            service_label: "My service".into(),
915            ..Default::default()
916        };
917        let json = serde_json::to_string(&config).unwrap();
918        let de: StartConfig = serde_json::from_str(&json).unwrap();
919        assert_eq!(de.service_label, "My service");
920    }
921
922    #[test]
923    fn start_config_deserialize_missing_field_uses_default() {
924        // An empty JSON object should produce the default label
925        let json = "{}";
926        let de: StartConfig = serde_json::from_str(json).unwrap();
927        assert_eq!(de.service_label, "Service running");
928    }
929
930    #[test]
931    fn start_config_json_key_is_camel_case() {
932        let config = StartConfig {
933            service_label: "test".into(),
934            ..Default::default()
935        };
936        let json = serde_json::to_string(&config).unwrap();
937        assert!(
938            json.contains("serviceLabel"),
939            "JSON should use camelCase: {json}"
940        );
941    }
942
943    // --- StartConfig alias tests ---
944
945    #[test]
946    fn start_config_legacy_label_alias_decodes() {
947        let json = r#"{"label":"Legacy name"}"#;
948        let de: StartConfig = serde_json::from_str(json).unwrap();
949        assert_eq!(de.service_label, "Legacy name");
950    }
951
952    #[test]
953    fn start_config_both_label_and_service_label_rejected() {
954        let json = r#"{"serviceLabel":"New name","label":"Old name"}"#;
955        let result = serde_json::from_str::<StartConfig>(json);
956        assert!(result.is_err(), "should reject duplicate field via alias");
957    }
958
959    #[test]
960    fn start_config_unknown_fields_ignored() {
961        let json = r#"{"serviceLabel":"test","unknownField":42,"extra":"data"}"#;
962        let de: StartConfig = serde_json::from_str(json).unwrap();
963        assert_eq!(de.service_label, "test");
964        assert_eq!(de.foreground_service_type, "dataSync");
965    }
966
967    #[test]
968    fn start_config_camel_case_key_still_works() {
969        let json = r#"{"serviceLabel":"Modern name"}"#;
970        let de: StartConfig = serde_json::from_str(json).unwrap();
971        assert_eq!(de.service_label, "Modern name");
972    }
973
974    // --- PluginEvent tests ---
975
976    #[test]
977    fn plugin_event_started_serde_roundtrip() {
978        let event = PluginEvent::Started;
979        let json = serde_json::to_string(&event).unwrap();
980        let de: PluginEvent = serde_json::from_str(&json).unwrap();
981        assert!(matches!(de, PluginEvent::Started));
982    }
983
984    #[test]
985    fn plugin_event_stopped_serde_roundtrip() {
986        let event = PluginEvent::Stopped {
987            reason: StopReason::UserStop,
988        };
989        let json = serde_json::to_string(&event).unwrap();
990        let de: PluginEvent = serde_json::from_str(&json).unwrap();
991        match de {
992            PluginEvent::Stopped { reason } => assert_eq!(reason, StopReason::UserStop),
993            other => panic!("Expected Stopped, got {other:?}"),
994        }
995    }
996
997    #[test]
998    fn plugin_event_error_serde_roundtrip() {
999        let event = PluginEvent::Error {
1000            message: "init failed".into(),
1001        };
1002        let json = serde_json::to_string(&event).unwrap();
1003        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1004        match de {
1005            PluginEvent::Error { message } => assert_eq!(message, "init failed"),
1006            other => panic!("Expected Error, got {other:?}"),
1007        }
1008    }
1009
1010    #[test]
1011    fn plugin_event_tagged_json_format() {
1012        let event = PluginEvent::Started;
1013        let json = serde_json::to_string(&event).unwrap();
1014        assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
1015    }
1016
1017    #[test]
1018    fn plugin_event_stopped_json_keys_camel_case() {
1019        let event = PluginEvent::Stopped {
1020            reason: StopReason::TaskCompleted,
1021        };
1022        let json = serde_json::to_string(&event).unwrap();
1023        assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
1024        assert!(
1025            json.contains("\"reason\":\"taskCompleted\""),
1026            "Reason: {json}"
1027        );
1028    }
1029
1030    #[test]
1031    fn plugin_event_error_json_keys_camel_case() {
1032        let event = PluginEvent::Error {
1033            message: "oops".into(),
1034        };
1035        let json = serde_json::to_string(&event).unwrap();
1036        assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
1037        assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
1038    }
1039
1040    // --- StopReason tests ---
1041
1042    #[test]
1043    fn stop_reason_all_variants_serialize_to_camel_case() {
1044        assert_eq!(
1045            serde_json::to_string(&StopReason::UserStop).unwrap(),
1046            "\"userStop\""
1047        );
1048        assert_eq!(
1049            serde_json::to_string(&StopReason::AppStop).unwrap(),
1050            "\"appStop\""
1051        );
1052        assert_eq!(
1053            serde_json::to_string(&StopReason::PlatformTimeout).unwrap(),
1054            "\"platformTimeout\""
1055        );
1056        assert_eq!(
1057            serde_json::to_string(&StopReason::PlatformExpiration).unwrap(),
1058            "\"platformExpiration\""
1059        );
1060        assert_eq!(
1061            serde_json::to_string(&StopReason::NativeNotificationStop).unwrap(),
1062            "\"nativeNotificationStop\""
1063        );
1064        assert_eq!(
1065            serde_json::to_string(&StopReason::OsRestart).unwrap(),
1066            "\"osRestart\""
1067        );
1068        assert_eq!(
1069            serde_json::to_string(&StopReason::BootRecovery).unwrap(),
1070            "\"bootRecovery\""
1071        );
1072        assert_eq!(
1073            serde_json::to_string(&StopReason::TaskCompleted).unwrap(),
1074            "\"taskCompleted\""
1075        );
1076        assert_eq!(
1077            serde_json::to_string(&StopReason::Error).unwrap(),
1078            "\"error\""
1079        );
1080    }
1081
1082    #[test]
1083    fn stop_reason_roundtrip_all_variants() {
1084        for variant in [
1085            StopReason::UserStop,
1086            StopReason::AppStop,
1087            StopReason::PlatformTimeout,
1088            StopReason::PlatformExpiration,
1089            StopReason::NativeNotificationStop,
1090            StopReason::OsRestart,
1091            StopReason::BootRecovery,
1092            StopReason::TaskCompleted,
1093            StopReason::Error,
1094        ] {
1095            let json = serde_json::to_string(&variant).unwrap();
1096            let de: StopReason = serde_json::from_str(&json).unwrap();
1097            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
1098        }
1099    }
1100
1101    #[test]
1102    fn stop_reason_legacy_completed_maps_to_task_completed() {
1103        let json = "\"completed\"";
1104        let de: StopReason = serde_json::from_str(json).unwrap();
1105        assert_eq!(de, StopReason::TaskCompleted);
1106    }
1107
1108    #[test]
1109    fn stop_reason_legacy_cancelled_maps_to_user_stop() {
1110        let json = "\"cancelled\"";
1111        let de: StopReason = serde_json::from_str(json).unwrap();
1112        assert_eq!(de, StopReason::UserStop);
1113    }
1114
1115    #[test]
1116    fn stop_reason_legacy_user_maps_to_user_stop() {
1117        let json = "\"user\"";
1118        let de: StopReason = serde_json::from_str(json).unwrap();
1119        assert_eq!(de, StopReason::UserStop);
1120    }
1121
1122    #[test]
1123    fn stop_reason_unknown_variant_returns_error() {
1124        let json = "\"unknownReason\"";
1125        let result = serde_json::from_str::<StopReason>(json);
1126        assert!(
1127            result.is_err(),
1128            "unknown variant should fail to deserialize"
1129        );
1130    }
1131
1132    // --- NativeLifecycleEvent tests ---
1133
1134    #[test]
1135    fn native_lifecycle_event_android_notification_stop_roundtrip() {
1136        let event = NativeLifecycleEvent::AndroidNotificationStop;
1137        let json = serde_json::to_string(&event).unwrap();
1138        assert_eq!(json, r#"{"type":"androidNotificationStop"}"#);
1139        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1140        assert_eq!(de, event);
1141    }
1142
1143    #[test]
1144    fn native_lifecycle_event_android_timeout_roundtrip() {
1145        let event = NativeLifecycleEvent::AndroidTimeout {
1146            fgs_type: Some("dataSync".into()),
1147        };
1148        let json = serde_json::to_string(&event).unwrap();
1149        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1150        assert_eq!(de, event);
1151    }
1152
1153    #[test]
1154    fn native_lifecycle_event_android_timeout_without_fgs_type() {
1155        let event = NativeLifecycleEvent::AndroidTimeout { fgs_type: None };
1156        let json = serde_json::to_string(&event).unwrap();
1157        // skip_serializing_if means fgs_type is omitted when None
1158        assert!(!json.contains("fgsType"), "{json}");
1159        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1160        assert_eq!(de, event);
1161    }
1162
1163    #[test]
1164    fn native_lifecycle_event_to_stop_reason_mapping() {
1165        assert_eq!(
1166            NativeLifecycleEvent::AndroidNotificationStop.to_stop_reason(),
1167            StopReason::NativeNotificationStop
1168        );
1169        assert_eq!(
1170            NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.to_stop_reason(),
1171            StopReason::PlatformTimeout
1172        );
1173        assert_eq!(
1174            NativeLifecycleEvent::AndroidTimeout {
1175                fgs_type: Some("dataSync".into())
1176            }
1177            .to_stop_reason(),
1178            StopReason::PlatformTimeout
1179        );
1180    }
1181
1182    #[test]
1183    fn plugin_event_stopped_with_stop_reason_roundtrip() {
1184        let event = PluginEvent::Stopped {
1185            reason: StopReason::TaskCompleted,
1186        };
1187        let json = serde_json::to_string(&event).unwrap();
1188        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1189        assert_eq!(
1190            de,
1191            PluginEvent::Stopped {
1192                reason: StopReason::TaskCompleted
1193            }
1194        );
1195    }
1196
1197    #[test]
1198    fn plugin_event_stopped_legacy_reason_deserializes() {
1199        // Simulates receiving a legacy event with reason "completed"
1200        let json = r#"{"type":"stopped","reason":"completed"}"#;
1201        let de: PluginEvent = serde_json::from_str(json).unwrap();
1202        match de {
1203            PluginEvent::Stopped { reason } => {
1204                assert_eq!(reason, StopReason::TaskCompleted);
1205            }
1206            other => panic!("Expected Stopped, got {other:?}"),
1207        }
1208    }
1209
1210    #[test]
1211    fn plugin_event_stopped_legacy_cancelled_deserializes() {
1212        let json = r#"{"type":"stopped","reason":"cancelled"}"#;
1213        let de: PluginEvent = serde_json::from_str(json).unwrap();
1214        match de {
1215            PluginEvent::Stopped { reason } => {
1216                assert_eq!(reason, StopReason::UserStop);
1217            }
1218            other => panic!("Expected Stopped, got {other:?}"),
1219        }
1220    }
1221
1222    // --- StartConfig foreground_service_type tests ---
1223
1224    #[test]
1225    fn start_config_default_service_type() {
1226        let config = StartConfig::default();
1227        assert_eq!(config.foreground_service_type, "dataSync");
1228    }
1229
1230    #[test]
1231    fn start_config_custom_service_type() {
1232        let config = StartConfig {
1233            service_label: "test".into(),
1234            foreground_service_type: "specialUse".into(),
1235        };
1236        assert_eq!(config.foreground_service_type, "specialUse");
1237    }
1238
1239    #[test]
1240    fn start_config_serde_roundtrip_service_type() {
1241        let config = StartConfig {
1242            service_label: "test".into(),
1243            foreground_service_type: "specialUse".into(),
1244        };
1245        let json = serde_json::to_string(&config).unwrap();
1246        let de: StartConfig = serde_json::from_str(&json).unwrap();
1247        assert_eq!(de.foreground_service_type, "specialUse");
1248    }
1249
1250    #[test]
1251    fn start_config_deserialize_missing_service_type() {
1252        let json = r#"{"serviceLabel":"test"}"#;
1253        let de: StartConfig = serde_json::from_str(json).unwrap();
1254        assert_eq!(de.foreground_service_type, "dataSync");
1255    }
1256
1257    #[test]
1258    fn start_config_deserialize_special_use() {
1259        let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
1260        let de: StartConfig = serde_json::from_str(json).unwrap();
1261        assert_eq!(de.foreground_service_type, "specialUse");
1262    }
1263
1264    #[test]
1265    fn start_config_unrecognized_type_rejected_by_validation() {
1266        // Deserialization still passes through any string.
1267        let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
1268        let de: StartConfig = serde_json::from_str(json).unwrap();
1269        assert_eq!(de.foreground_service_type, "customType");
1270        // But validation rejects it.
1271        let result = validate_foreground_service_type(&de.foreground_service_type);
1272        assert!(
1273            result.is_err(),
1274            "validation should reject unrecognized type"
1275        );
1276        let err_msg = result.unwrap_err().to_string();
1277        assert!(
1278            err_msg.contains("customType"),
1279            "error should mention the invalid type: {err_msg}"
1280        );
1281    }
1282
1283    #[test]
1284    fn start_config_json_key_is_camel_case_service_type() {
1285        let config = StartConfig {
1286            service_label: "test".into(),
1287            foreground_service_type: "specialUse".into(),
1288        };
1289        let json = serde_json::to_string(&config).unwrap();
1290        assert!(
1291            json.contains("foregroundServiceType"),
1292            "JSON should use camelCase: {json}"
1293        );
1294    }
1295
1296    // --- AutoStartConfig tests ---
1297
1298    #[test]
1299    fn auto_start_config_pending_with_label_returns_start_config() {
1300        let json = r#"{"pending": true, "label": "Syncing"}"#;
1301        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
1302        let result = config.into_start_config();
1303        assert!(result.is_some());
1304        let start_config = result.unwrap();
1305        assert_eq!(start_config.service_label, "Syncing");
1306        assert_eq!(start_config.foreground_service_type, "dataSync");
1307    }
1308
1309    #[test]
1310    fn auto_start_config_not_pending_returns_none() {
1311        let json = r#"{"pending": false, "label": null}"#;
1312        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
1313        let result = config.into_start_config();
1314        assert!(result.is_none());
1315    }
1316
1317    #[test]
1318    fn auto_start_config_pending_no_label_returns_none() {
1319        let json = r#"{"pending": true, "label": null}"#;
1320        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
1321        let result = config.into_start_config();
1322        assert!(result.is_none());
1323    }
1324
1325    #[test]
1326    fn auto_start_config_with_service_type_preserves_it() {
1327        let json = r#"{"pending":true,"label":"test","serviceType":"specialUse"}"#;
1328        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
1329        assert_eq!(config.service_type, Some("specialUse".to_string()));
1330        let result = config.into_start_config();
1331        assert!(result.is_some());
1332        let start_config = result.unwrap();
1333        assert_eq!(start_config.foreground_service_type, "specialUse");
1334    }
1335
1336    #[test]
1337    fn auto_start_config_without_service_type_uses_default() {
1338        let json = r#"{"pending":true,"label":"test"}"#;
1339        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
1340        assert_eq!(config.service_type, None);
1341        let result = config.into_start_config();
1342        assert!(result.is_some());
1343        assert_eq!(result.unwrap().foreground_service_type, "dataSync");
1344    }
1345
1346    #[test]
1347    fn auto_start_config_null_service_type_uses_default() {
1348        let json = r#"{"pending":true,"label":"test","serviceType":null}"#;
1349        let config: AutoStartConfig = serde_json::from_str(json).unwrap();
1350        assert_eq!(config.service_type, None);
1351        let result = config.into_start_config();
1352        assert!(result.is_some());
1353        assert_eq!(result.unwrap().foreground_service_type, "dataSync");
1354    }
1355
1356    // --- PluginConfig tests ---
1357
1358    #[test]
1359    fn plugin_config_default_ios_safety_timeout() {
1360        let json = "{}";
1361        let config: PluginConfig = serde_json::from_str(json).unwrap();
1362        assert_eq!(config.ios_safety_timeout_secs, 28.0);
1363    }
1364
1365    #[test]
1366    fn plugin_config_custom_ios_safety_timeout() {
1367        let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
1368        let config: PluginConfig = serde_json::from_str(json).unwrap();
1369        assert_eq!(config.ios_safety_timeout_secs, 15.0);
1370    }
1371
1372    #[test]
1373    fn plugin_config_serde_roundtrip_preserves_value() {
1374        let config = PluginConfig {
1375            ios_safety_timeout_secs: 30.0,
1376            ios_cancel_listener_timeout_secs: 14400,
1377            ios_processing_safety_timeout_secs: 0.0,
1378            ios_earliest_refresh_begin_minutes: 20.0,
1379            ios_earliest_processing_begin_minutes: 30.0,
1380            ios_requires_external_power: true,
1381            ios_requires_network_connectivity: true,
1382            ..Default::default()
1383        };
1384        let json = serde_json::to_string(&config).unwrap();
1385        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1386        assert_eq!(de.ios_safety_timeout_secs, 30.0);
1387        assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
1388        assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
1389        assert!(de.ios_requires_external_power);
1390        assert!(de.ios_requires_network_connectivity);
1391    }
1392
1393    #[test]
1394    fn plugin_config_default_impl() {
1395        let config = PluginConfig::default();
1396        assert_eq!(config.ios_safety_timeout_secs, 28.0);
1397        assert_eq!(config.channel_capacity, 16);
1398    }
1399
1400    #[test]
1401    fn plugin_config_default_cancel_timeout() {
1402        let json = "{}";
1403        let config: PluginConfig = serde_json::from_str(json).unwrap();
1404        assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
1405    }
1406
1407    #[test]
1408    fn plugin_config_custom_cancel_timeout() {
1409        let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
1410        let config: PluginConfig = serde_json::from_str(json).unwrap();
1411        assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
1412    }
1413
1414    #[test]
1415    fn plugin_config_cancel_timeout_serde_roundtrip() {
1416        let config = PluginConfig {
1417            ios_cancel_listener_timeout_secs: 3600,
1418            ..Default::default()
1419        };
1420        let json = serde_json::to_string(&config).unwrap();
1421        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1422        assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
1423    }
1424
1425    // --- PluginConfig ios_processing_safety_timeout_secs tests ---
1426
1427    #[test]
1428    fn plugin_config_processing_timeout_default() {
1429        let json = "{}";
1430        let config: PluginConfig = serde_json::from_str(json).unwrap();
1431        assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
1432    }
1433
1434    #[test]
1435    fn plugin_config_processing_timeout_custom() {
1436        let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
1437        let config: PluginConfig = serde_json::from_str(json).unwrap();
1438        assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
1439    }
1440
1441    #[test]
1442    fn plugin_config_processing_timeout_serde_roundtrip() {
1443        let config = PluginConfig {
1444            ios_processing_safety_timeout_secs: 120.0,
1445            ..Default::default()
1446        };
1447        let json = serde_json::to_string(&config).unwrap();
1448        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1449        assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
1450    }
1451
1452    // --- StartKeepaliveArgs tests ---
1453
1454    #[test]
1455    fn start_keepalive_args_with_timeout() {
1456        let args = StartKeepaliveArgs {
1457            label: "Test",
1458            foreground_service_type: "dataSync",
1459            ios_safety_timeout_secs: Some(15.0),
1460            ios_processing_safety_timeout_secs: None,
1461            ios_earliest_refresh_begin_minutes: None,
1462            ios_earliest_processing_begin_minutes: None,
1463            ios_requires_external_power: None,
1464            ios_requires_network_connectivity: None,
1465        };
1466        let json = serde_json::to_string(&args).unwrap();
1467        assert!(
1468            json.contains("\"iosSafetyTimeoutSecs\":15.0"),
1469            "JSON should contain iosSafetyTimeoutSecs: {json}"
1470        );
1471    }
1472
1473    #[test]
1474    fn start_keepalive_args_without_timeout() {
1475        let args = StartKeepaliveArgs {
1476            label: "Test",
1477            foreground_service_type: "dataSync",
1478            ios_safety_timeout_secs: None,
1479            ios_processing_safety_timeout_secs: None,
1480            ios_earliest_refresh_begin_minutes: None,
1481            ios_earliest_processing_begin_minutes: None,
1482            ios_requires_external_power: None,
1483            ios_requires_network_connectivity: None,
1484        };
1485        let json = serde_json::to_string(&args).unwrap();
1486        assert!(
1487            !json.contains("iosSafetyTimeoutSecs"),
1488            "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
1489        );
1490    }
1491
1492    #[test]
1493    fn start_keepalive_args_processing_timeout() {
1494        let args = StartKeepaliveArgs {
1495            label: "Test",
1496            foreground_service_type: "dataSync",
1497            ios_safety_timeout_secs: None,
1498            ios_processing_safety_timeout_secs: Some(60.0),
1499            ios_earliest_refresh_begin_minutes: None,
1500            ios_earliest_processing_begin_minutes: None,
1501            ios_requires_external_power: None,
1502            ios_requires_network_connectivity: None,
1503        };
1504        let json = serde_json::to_string(&args).unwrap();
1505        assert!(
1506            json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
1507            "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
1508        );
1509    }
1510
1511    #[test]
1512    fn start_keepalive_args_no_processing_timeout() {
1513        let args = StartKeepaliveArgs {
1514            label: "Test",
1515            foreground_service_type: "dataSync",
1516            ios_safety_timeout_secs: None,
1517            ios_processing_safety_timeout_secs: None,
1518            ios_earliest_refresh_begin_minutes: None,
1519            ios_earliest_processing_begin_minutes: None,
1520            ios_requires_external_power: None,
1521            ios_requires_network_connectivity: None,
1522        };
1523        let json = serde_json::to_string(&args).unwrap();
1524        assert!(
1525            !json.contains("iosProcessingSafetyTimeoutSecs"),
1526            "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
1527        );
1528    }
1529
1530    #[test]
1531    fn start_keepalive_args_camel_case_keys() {
1532        let args = StartKeepaliveArgs {
1533            label: "Test",
1534            foreground_service_type: "specialUse",
1535            ios_safety_timeout_secs: None,
1536            ios_processing_safety_timeout_secs: None,
1537            ios_earliest_refresh_begin_minutes: None,
1538            ios_earliest_processing_begin_minutes: None,
1539            ios_requires_external_power: None,
1540            ios_requires_network_connectivity: None,
1541        };
1542        let json = serde_json::to_string(&args).unwrap();
1543        assert!(json.contains("\"label\""), "label: {json}");
1544        assert!(
1545            json.contains("\"foregroundServiceType\""),
1546            "foregroundServiceType: {json}"
1547        );
1548    }
1549
1550    #[test]
1551    fn start_keepalive_args_scheduling_intervals() {
1552        let args = StartKeepaliveArgs {
1553            label: "Test",
1554            foreground_service_type: "dataSync",
1555            ios_safety_timeout_secs: None,
1556            ios_processing_safety_timeout_secs: None,
1557            ios_earliest_refresh_begin_minutes: Some(30.0),
1558            ios_earliest_processing_begin_minutes: Some(60.0),
1559            ios_requires_external_power: None,
1560            ios_requires_network_connectivity: None,
1561        };
1562        let json = serde_json::to_string(&args).unwrap();
1563        assert!(
1564            json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
1565            "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
1566        );
1567        assert!(
1568            json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
1569            "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
1570        );
1571    }
1572
1573    #[test]
1574    fn start_keepalive_args_processing_options() {
1575        let args = StartKeepaliveArgs {
1576            label: "Test",
1577            foreground_service_type: "dataSync",
1578            ios_safety_timeout_secs: None,
1579            ios_processing_safety_timeout_secs: None,
1580            ios_earliest_refresh_begin_minutes: None,
1581            ios_earliest_processing_begin_minutes: None,
1582            ios_requires_external_power: Some(true),
1583            ios_requires_network_connectivity: Some(true),
1584        };
1585        let json = serde_json::to_string(&args).unwrap();
1586        assert!(
1587            json.contains("\"iosRequiresExternalPower\":true"),
1588            "JSON should contain iosRequiresExternalPower: {json}"
1589        );
1590        assert!(
1591            json.contains("\"iosRequiresNetworkConnectivity\":true"),
1592            "JSON should contain iosRequiresNetworkConnectivity: {json}"
1593        );
1594    }
1595
1596    // --- PluginConfig new scheduling fields tests ---
1597
1598    #[test]
1599    fn plugin_config_earliest_refresh_default() {
1600        let json = "{}";
1601        let config: PluginConfig = serde_json::from_str(json).unwrap();
1602        assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
1603    }
1604
1605    #[test]
1606    fn plugin_config_earliest_processing_default() {
1607        let json = "{}";
1608        let config: PluginConfig = serde_json::from_str(json).unwrap();
1609        assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
1610    }
1611
1612    #[test]
1613    fn plugin_config_requires_external_power_default() {
1614        let json = "{}";
1615        let config: PluginConfig = serde_json::from_str(json).unwrap();
1616        assert!(!config.ios_requires_external_power);
1617    }
1618
1619    #[test]
1620    fn plugin_config_requires_network_connectivity_default() {
1621        let json = "{}";
1622        let config: PluginConfig = serde_json::from_str(json).unwrap();
1623        assert!(!config.ios_requires_network_connectivity);
1624    }
1625
1626    #[test]
1627    fn plugin_config_custom_scheduling_intervals() {
1628        let json =
1629            r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
1630        let config: PluginConfig = serde_json::from_str(json).unwrap();
1631        assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
1632        assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
1633    }
1634
1635    #[test]
1636    fn plugin_config_custom_processing_options() {
1637        let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
1638        let config: PluginConfig = serde_json::from_str(json).unwrap();
1639        assert!(config.ios_requires_external_power);
1640        assert!(config.ios_requires_network_connectivity);
1641    }
1642
1643    // --- PluginConfig channel_capacity tests ---
1644
1645    #[test]
1646    fn plugin_config_channel_capacity_default() {
1647        let json = "{}";
1648        let config: PluginConfig = serde_json::from_str(json).unwrap();
1649        assert_eq!(config.channel_capacity, 16);
1650    }
1651
1652    #[test]
1653    fn plugin_config_channel_capacity_custom() {
1654        let json = r#"{"channelCapacity":32}"#;
1655        let config: PluginConfig = serde_json::from_str(json).unwrap();
1656        assert_eq!(config.channel_capacity, 32);
1657    }
1658
1659    #[test]
1660    fn plugin_config_channel_capacity_serde_roundtrip() {
1661        let config = PluginConfig {
1662            channel_capacity: 64,
1663            ..Default::default()
1664        };
1665        let json = serde_json::to_string(&config).unwrap();
1666        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1667        assert_eq!(de.channel_capacity, 64);
1668    }
1669
1670    #[test]
1671    fn plugin_config_channel_capacity_json_key_camel_case() {
1672        let config = PluginConfig {
1673            channel_capacity: 32,
1674            ..Default::default()
1675        };
1676        let json = serde_json::to_string(&config).unwrap();
1677        assert!(
1678            json.contains("channelCapacity"),
1679            "JSON should use camelCase: {json}"
1680        );
1681    }
1682
1683    // --- PluginConfig android FGS type fields tests ---
1684
1685    #[test]
1686    fn plugin_config_android_fgs_types_default() {
1687        let json = "{}";
1688        let config: PluginConfig = serde_json::from_str(json).unwrap();
1689        assert_eq!(config.android_foreground_service_types, vec!["dataSync"]);
1690    }
1691
1692    #[test]
1693    fn plugin_config_android_fgs_types_custom() {
1694        let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
1695        let config: PluginConfig = serde_json::from_str(json).unwrap();
1696        assert_eq!(
1697            config.android_foreground_service_types,
1698            vec!["dataSync", "specialUse"]
1699        );
1700    }
1701
1702    #[test]
1703    fn plugin_config_android_fgs_types_serde_roundtrip() {
1704        let config = PluginConfig {
1705            android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
1706            ..Default::default()
1707        };
1708        let json = serde_json::to_string(&config).unwrap();
1709        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1710        assert_eq!(
1711            de.android_foreground_service_types,
1712            vec!["location", "connectedDevice"]
1713        );
1714    }
1715
1716    #[test]
1717    fn plugin_config_android_fgs_types_json_key_camel_case() {
1718        let config = PluginConfig {
1719            android_foreground_service_types: vec!["specialUse".into()],
1720            ..Default::default()
1721        };
1722        let json = serde_json::to_string(&config).unwrap();
1723        assert!(
1724            json.contains("androidForegroundServiceTypes"),
1725            "JSON should use camelCase: {json}"
1726        );
1727    }
1728
1729    #[test]
1730    fn plugin_config_android_validate_default() {
1731        let json = "{}";
1732        let config: PluginConfig = serde_json::from_str(json).unwrap();
1733        assert!(config.android_validate_foreground_service_type);
1734    }
1735
1736    #[test]
1737    fn plugin_config_android_validate_false() {
1738        let json = r#"{"androidValidateForegroundServiceType":false}"#;
1739        let config: PluginConfig = serde_json::from_str(json).unwrap();
1740        assert!(!config.android_validate_foreground_service_type);
1741    }
1742
1743    #[test]
1744    fn plugin_config_android_validate_serde_roundtrip() {
1745        let config = PluginConfig {
1746            android_validate_foreground_service_type: false,
1747            ..Default::default()
1748        };
1749        let json = serde_json::to_string(&config).unwrap();
1750        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1751        assert!(!de.android_validate_foreground_service_type);
1752    }
1753
1754    #[test]
1755    fn plugin_config_android_validate_json_key_camel_case() {
1756        let config = PluginConfig {
1757            android_validate_foreground_service_type: false,
1758            ..Default::default()
1759        };
1760        let json = serde_json::to_string(&config).unwrap();
1761        assert!(
1762            json.contains("androidValidateForegroundServiceType"),
1763            "JSON should use camelCase: {json}"
1764        );
1765    }
1766
1767    // --- PluginConfig Android timeout/notification config tests ---
1768
1769    #[test]
1770    fn plugin_config_android_on_timeout_default() {
1771        let json = "{}";
1772        let config: PluginConfig = serde_json::from_str(json).unwrap();
1773        assert_eq!(config.android_on_timeout, "notifyUser");
1774    }
1775
1776    #[test]
1777    fn plugin_config_android_on_timeout_custom() {
1778        let json = r#"{"androidOnTimeout":"stop"}"#;
1779        let config: PluginConfig = serde_json::from_str(json).unwrap();
1780        assert_eq!(config.android_on_timeout, "stop");
1781    }
1782
1783    #[test]
1784    fn plugin_config_android_on_timeout_schedule_recovery() {
1785        let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
1786        let config: PluginConfig = serde_json::from_str(json).unwrap();
1787        assert_eq!(config.android_on_timeout, "scheduleRecovery");
1788    }
1789
1790    #[test]
1791    fn plugin_config_android_on_timeout_serde_roundtrip() {
1792        let config = PluginConfig {
1793            android_on_timeout: "stop".into(),
1794            ..Default::default()
1795        };
1796        let json = serde_json::to_string(&config).unwrap();
1797        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1798        assert_eq!(de.android_on_timeout, "stop");
1799    }
1800
1801    #[test]
1802    fn plugin_config_android_on_timeout_json_key_camel_case() {
1803        let config = PluginConfig {
1804            android_on_timeout: "notifyUser".into(),
1805            ..Default::default()
1806        };
1807        let json = serde_json::to_string(&config).unwrap();
1808        assert!(
1809            json.contains("androidOnTimeout"),
1810            "JSON should use camelCase: {json}"
1811        );
1812    }
1813
1814    #[test]
1815    fn plugin_config_android_notification_channel_id_default() {
1816        let json = "{}";
1817        let config: PluginConfig = serde_json::from_str(json).unwrap();
1818        assert_eq!(config.android_notification_channel_id, "bg_service");
1819    }
1820
1821    #[test]
1822    fn plugin_config_android_notification_channel_id_custom() {
1823        let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
1824        let config: PluginConfig = serde_json::from_str(json).unwrap();
1825        assert_eq!(config.android_notification_channel_id, "my_channel");
1826    }
1827
1828    #[test]
1829    fn plugin_config_android_notification_channel_id_serde_roundtrip() {
1830        let config = PluginConfig {
1831            android_notification_channel_id: "custom_ch".into(),
1832            ..Default::default()
1833        };
1834        let json = serde_json::to_string(&config).unwrap();
1835        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1836        assert_eq!(de.android_notification_channel_id, "custom_ch");
1837    }
1838
1839    #[test]
1840    fn plugin_config_android_notification_channel_id_json_key_camel_case() {
1841        let config = PluginConfig {
1842            android_notification_channel_id: "test".into(),
1843            ..Default::default()
1844        };
1845        let json = serde_json::to_string(&config).unwrap();
1846        assert!(
1847            json.contains("androidNotificationChannelId"),
1848            "JSON should use camelCase: {json}"
1849        );
1850    }
1851
1852    #[test]
1853    fn plugin_config_android_notification_channel_name_default() {
1854        let json = "{}";
1855        let config: PluginConfig = serde_json::from_str(json).unwrap();
1856        assert_eq!(
1857            config.android_notification_channel_name,
1858            "Background Service"
1859        );
1860    }
1861
1862    #[test]
1863    fn plugin_config_android_notification_channel_name_custom() {
1864        let json = r#"{"androidNotificationChannelName":"My Service"}"#;
1865        let config: PluginConfig = serde_json::from_str(json).unwrap();
1866        assert_eq!(config.android_notification_channel_name, "My Service");
1867    }
1868
1869    #[test]
1870    fn plugin_config_android_notification_channel_name_serde_roundtrip() {
1871        let config = PluginConfig {
1872            android_notification_channel_name: "Sync Service".into(),
1873            ..Default::default()
1874        };
1875        let json = serde_json::to_string(&config).unwrap();
1876        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1877        assert_eq!(de.android_notification_channel_name, "Sync Service");
1878    }
1879
1880    #[test]
1881    fn plugin_config_android_notification_channel_name_json_key_camel_case() {
1882        let config = PluginConfig {
1883            android_notification_channel_name: "Test".into(),
1884            ..Default::default()
1885        };
1886        let json = serde_json::to_string(&config).unwrap();
1887        assert!(
1888            json.contains("androidNotificationChannelName"),
1889            "JSON should use camelCase: {json}"
1890        );
1891    }
1892
1893    #[test]
1894    fn plugin_config_android_notification_id_default() {
1895        let json = "{}";
1896        let config: PluginConfig = serde_json::from_str(json).unwrap();
1897        assert_eq!(config.android_notification_id, 9001);
1898    }
1899
1900    #[test]
1901    fn plugin_config_android_notification_id_custom() {
1902        let json = r#"{"androidNotificationId":1234}"#;
1903        let config: PluginConfig = serde_json::from_str(json).unwrap();
1904        assert_eq!(config.android_notification_id, 1234);
1905    }
1906
1907    #[test]
1908    fn plugin_config_android_notification_id_serde_roundtrip() {
1909        let config = PluginConfig {
1910            android_notification_id: 42,
1911            ..Default::default()
1912        };
1913        let json = serde_json::to_string(&config).unwrap();
1914        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1915        assert_eq!(de.android_notification_id, 42);
1916    }
1917
1918    #[test]
1919    fn plugin_config_android_notification_id_json_key_camel_case() {
1920        let config = PluginConfig {
1921            android_notification_id: 5555,
1922            ..Default::default()
1923        };
1924        let json = serde_json::to_string(&config).unwrap();
1925        assert!(
1926            json.contains("androidNotificationId"),
1927            "JSON should use camelCase: {json}"
1928        );
1929    }
1930
1931    #[test]
1932    fn plugin_config_android_notification_small_icon_default() {
1933        let json = "{}";
1934        let config: PluginConfig = serde_json::from_str(json).unwrap();
1935        assert_eq!(config.android_notification_small_icon, None);
1936    }
1937
1938    #[test]
1939    fn plugin_config_android_notification_small_icon_custom() {
1940        let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
1941        let config: PluginConfig = serde_json::from_str(json).unwrap();
1942        assert_eq!(
1943            config.android_notification_small_icon,
1944            Some("ic_notification".to_string())
1945        );
1946    }
1947
1948    #[test]
1949    fn plugin_config_android_notification_small_icon_serde_roundtrip() {
1950        let config = PluginConfig {
1951            android_notification_small_icon: Some("my_icon".into()),
1952            ..Default::default()
1953        };
1954        let json = serde_json::to_string(&config).unwrap();
1955        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1956        assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
1957    }
1958
1959    #[test]
1960    fn plugin_config_android_notification_small_icon_absent_when_none() {
1961        let config = PluginConfig {
1962            android_notification_small_icon: None,
1963            ..Default::default()
1964        };
1965        let json = serde_json::to_string(&config).unwrap();
1966        assert!(
1967            !json.contains("androidNotificationSmallIcon"),
1968            "should be absent when None: {json}"
1969        );
1970    }
1971
1972    #[test]
1973    fn plugin_config_android_notification_small_icon_json_key_camel_case() {
1974        let config = PluginConfig {
1975            android_notification_small_icon: Some("icon".into()),
1976            ..Default::default()
1977        };
1978        let json = serde_json::to_string(&config).unwrap();
1979        assert!(
1980            json.contains("androidNotificationSmallIcon"),
1981            "JSON should use camelCase: {json}"
1982        );
1983    }
1984
1985    #[test]
1986    fn plugin_config_android_show_stop_action_default() {
1987        let json = "{}";
1988        let config: PluginConfig = serde_json::from_str(json).unwrap();
1989        assert!(config.android_show_stop_action);
1990    }
1991
1992    #[test]
1993    fn plugin_config_android_show_stop_action_false() {
1994        let json = r#"{"androidShowStopAction":false}"#;
1995        let config: PluginConfig = serde_json::from_str(json).unwrap();
1996        assert!(!config.android_show_stop_action);
1997    }
1998
1999    #[test]
2000    fn plugin_config_android_show_stop_action_serde_roundtrip() {
2001        let config = PluginConfig {
2002            android_show_stop_action: false,
2003            ..Default::default()
2004        };
2005        let json = serde_json::to_string(&config).unwrap();
2006        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2007        assert!(!de.android_show_stop_action);
2008    }
2009
2010    #[test]
2011    fn plugin_config_android_show_stop_action_json_key_camel_case() {
2012        let config = PluginConfig {
2013            android_show_stop_action: false,
2014            ..Default::default()
2015        };
2016        let json = serde_json::to_string(&config).unwrap();
2017        assert!(
2018            json.contains("androidShowStopAction"),
2019            "JSON should use camelCase: {json}"
2020        );
2021    }
2022
2023    // --- PluginConfig android_request_notification_permission_on_load tests ---
2024
2025    #[test]
2026    fn plugin_config_android_request_notification_permission_default() {
2027        let json = "{}";
2028        let config: PluginConfig = serde_json::from_str(json).unwrap();
2029        assert!(config.android_request_notification_permission_on_load);
2030    }
2031
2032    #[test]
2033    fn plugin_config_android_request_notification_permission_false() {
2034        let json = r#"{"androidRequestNotificationPermissionOnLoad":false}"#;
2035        let config: PluginConfig = serde_json::from_str(json).unwrap();
2036        assert!(!config.android_request_notification_permission_on_load);
2037    }
2038
2039    #[test]
2040    fn plugin_config_android_request_notification_permission_serde_roundtrip() {
2041        let config = PluginConfig {
2042            android_request_notification_permission_on_load: false,
2043            ..Default::default()
2044        };
2045        let json = serde_json::to_string(&config).unwrap();
2046        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2047        assert!(!de.android_request_notification_permission_on_load);
2048    }
2049
2050    #[test]
2051    fn plugin_config_android_timeout_notification_full_roundtrip() {
2052        let config = PluginConfig {
2053            android_on_timeout: "scheduleRecovery".into(),
2054            android_notification_channel_id: "my_ch".into(),
2055            android_notification_channel_name: "My Channel".into(),
2056            android_notification_id: 42,
2057            android_notification_small_icon: Some("ic_bg".into()),
2058            android_show_stop_action: false,
2059            ..Default::default()
2060        };
2061        let json = serde_json::to_string(&config).unwrap();
2062        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2063        assert_eq!(de.android_on_timeout, "scheduleRecovery");
2064        assert_eq!(de.android_notification_channel_id, "my_ch");
2065        assert_eq!(de.android_notification_channel_name, "My Channel");
2066        assert_eq!(de.android_notification_id, 42);
2067        assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
2068        assert!(!de.android_show_stop_action);
2069    }
2070
2071    // --- PluginConfig desktop fields tests (feature-gated) ---
2072
2073    #[cfg(feature = "desktop-service")]
2074    #[test]
2075    fn plugin_config_desktop_mode_default() {
2076        let json = "{}";
2077        let config: PluginConfig = serde_json::from_str(json).unwrap();
2078        assert_eq!(config.desktop_service_mode, "inProcess");
2079    }
2080
2081    #[cfg(feature = "desktop-service")]
2082    #[test]
2083    fn plugin_config_desktop_mode_custom() {
2084        let json = r#"{"desktopServiceMode":"osService"}"#;
2085        let config: PluginConfig = serde_json::from_str(json).unwrap();
2086        assert_eq!(config.desktop_service_mode, "osService");
2087    }
2088
2089    #[cfg(feature = "desktop-service")]
2090    #[test]
2091    fn plugin_config_desktop_mode_serde_roundtrip() {
2092        let config = PluginConfig {
2093            desktop_service_mode: "osService".into(),
2094            ..Default::default()
2095        };
2096        let json = serde_json::to_string(&config).unwrap();
2097        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2098        assert_eq!(de.desktop_service_mode, "osService");
2099    }
2100
2101    #[cfg(feature = "desktop-service")]
2102    #[test]
2103    fn plugin_config_desktop_label_default() {
2104        let json = "{}";
2105        let config: PluginConfig = serde_json::from_str(json).unwrap();
2106        assert_eq!(config.desktop_service_label, None);
2107    }
2108
2109    #[cfg(feature = "desktop-service")]
2110    #[test]
2111    fn plugin_config_desktop_label_custom() {
2112        let json = r#"{"desktopServiceLabel":"my.svc"}"#;
2113        let config: PluginConfig = serde_json::from_str(json).unwrap();
2114        assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
2115    }
2116
2117    // --- PluginConfig desktop autostart/start-if-missing/timeout tests ---
2118
2119    #[cfg(feature = "desktop-service")]
2120    #[test]
2121    fn plugin_config_desktop_autostart_default() {
2122        let json = "{}";
2123        let config: PluginConfig = serde_json::from_str(json).unwrap();
2124        assert!(!config.desktop_service_autostart);
2125    }
2126
2127    #[cfg(feature = "desktop-service")]
2128    #[test]
2129    fn plugin_config_desktop_autostart_true() {
2130        let json = r#"{"desktopServiceAutostart":true}"#;
2131        let config: PluginConfig = serde_json::from_str(json).unwrap();
2132        assert!(config.desktop_service_autostart);
2133    }
2134
2135    #[cfg(feature = "desktop-service")]
2136    #[test]
2137    fn plugin_config_desktop_autostart_serde_roundtrip() {
2138        let config = PluginConfig {
2139            desktop_service_autostart: true,
2140            ..Default::default()
2141        };
2142        let json = serde_json::to_string(&config).unwrap();
2143        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2144        assert!(de.desktop_service_autostart);
2145    }
2146
2147    #[cfg(feature = "desktop-service")]
2148    #[test]
2149    fn plugin_config_desktop_autostart_json_key_camel_case() {
2150        let config = PluginConfig {
2151            desktop_service_autostart: true,
2152            ..Default::default()
2153        };
2154        let json = serde_json::to_string(&config).unwrap();
2155        assert!(
2156            json.contains("desktopServiceAutostart"),
2157            "JSON should use camelCase: {json}"
2158        );
2159    }
2160
2161    #[cfg(feature = "desktop-service")]
2162    #[test]
2163    fn plugin_config_desktop_start_if_missing_default() {
2164        let json = "{}";
2165        let config: PluginConfig = serde_json::from_str(json).unwrap();
2166        assert!(!config.desktop_start_service_if_missing);
2167    }
2168
2169    #[cfg(feature = "desktop-service")]
2170    #[test]
2171    fn plugin_config_desktop_start_if_missing_true() {
2172        let json = r#"{"desktopStartServiceIfMissing":true}"#;
2173        let config: PluginConfig = serde_json::from_str(json).unwrap();
2174        assert!(config.desktop_start_service_if_missing);
2175    }
2176
2177    #[cfg(feature = "desktop-service")]
2178    #[test]
2179    fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
2180        let config = PluginConfig {
2181            desktop_start_service_if_missing: true,
2182            ..Default::default()
2183        };
2184        let json = serde_json::to_string(&config).unwrap();
2185        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2186        assert!(de.desktop_start_service_if_missing);
2187    }
2188
2189    #[cfg(feature = "desktop-service")]
2190    #[test]
2191    fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
2192        let config = PluginConfig {
2193            desktop_start_service_if_missing: true,
2194            ..Default::default()
2195        };
2196        let json = serde_json::to_string(&config).unwrap();
2197        assert!(
2198            json.contains("desktopStartServiceIfMissing"),
2199            "JSON should use camelCase: {json}"
2200        );
2201    }
2202
2203    #[cfg(feature = "desktop-service")]
2204    #[test]
2205    fn plugin_config_desktop_start_timeout_default() {
2206        let json = "{}";
2207        let config: PluginConfig = serde_json::from_str(json).unwrap();
2208        assert_eq!(config.desktop_service_start_timeout_ms, 5000);
2209    }
2210
2211    #[cfg(feature = "desktop-service")]
2212    #[test]
2213    fn plugin_config_desktop_start_timeout_custom() {
2214        let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
2215        let config: PluginConfig = serde_json::from_str(json).unwrap();
2216        assert_eq!(config.desktop_service_start_timeout_ms, 10000);
2217    }
2218
2219    #[cfg(feature = "desktop-service")]
2220    #[test]
2221    fn plugin_config_desktop_start_timeout_serde_roundtrip() {
2222        let config = PluginConfig {
2223            desktop_service_start_timeout_ms: 15000,
2224            ..Default::default()
2225        };
2226        let json = serde_json::to_string(&config).unwrap();
2227        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2228        assert_eq!(de.desktop_service_start_timeout_ms, 15000);
2229    }
2230
2231    #[cfg(feature = "desktop-service")]
2232    #[test]
2233    fn plugin_config_desktop_start_timeout_json_key_camel_case() {
2234        let config = PluginConfig {
2235            desktop_service_start_timeout_ms: 3000,
2236            ..Default::default()
2237        };
2238        let json = serde_json::to_string(&config).unwrap();
2239        assert!(
2240            json.contains("desktopServiceStartTimeoutMs"),
2241            "JSON should use camelCase: {json}"
2242        );
2243    }
2244
2245    #[cfg(feature = "desktop-service")]
2246    #[test]
2247    fn plugin_config_desktop_all_new_fields_roundtrip() {
2248        let config = PluginConfig {
2249            desktop_service_autostart: true,
2250            desktop_start_service_if_missing: true,
2251            desktop_service_start_timeout_ms: 8000,
2252            ..Default::default()
2253        };
2254        let json = serde_json::to_string(&config).unwrap();
2255        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2256        assert!(de.desktop_service_autostart);
2257        assert!(de.desktop_start_service_if_missing);
2258        assert_eq!(de.desktop_service_start_timeout_ms, 8000);
2259    }
2260
2261    use tauri::AppHandle;
2262
2263    // --- ServiceContext mobile fields tests ---
2264
2265    /// Compile-time + runtime test: ServiceContext mobile fields are String.
2266    #[cfg(mobile)]
2267    #[allow(dead_code)]
2268    fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
2269        let ctx = ServiceContext {
2270            notifier: Notifier { app: app.clone() },
2271            app,
2272            shutdown: CancellationToken::new(),
2273            service_label: "Syncing".into(),
2274            foreground_service_type: "dataSync".into(),
2275        };
2276        assert_eq!(ctx.service_label, "Syncing");
2277        assert_eq!(ctx.foreground_service_type, "dataSync");
2278    }
2279
2280    /// Compile-time + runtime test: ServiceContext on desktop has no mobile fields.
2281    #[cfg(not(mobile))]
2282    #[allow(dead_code)]
2283    fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
2284        let ctx = ServiceContext {
2285            notifier: Notifier { app: app.clone() },
2286            app,
2287            shutdown: CancellationToken::new(),
2288        };
2289        // Compiles — service_label and foreground_service_type are absent.
2290        let _ = ctx;
2291    }
2292
2293    // --- Foreground service type validation tests ---
2294
2295    #[test]
2296    fn validate_data_sync_passes() {
2297        assert!(
2298            validate_foreground_service_type("dataSync").is_ok(),
2299            "dataSync should be valid"
2300        );
2301    }
2302
2303    #[test]
2304    fn validate_special_use_passes() {
2305        assert!(
2306            validate_foreground_service_type("specialUse").is_ok(),
2307            "specialUse should be valid"
2308        );
2309    }
2310
2311    #[test]
2312    fn validate_invalid_type_returns_platform_error() {
2313        let result = validate_foreground_service_type("invalidType");
2314        assert!(result.is_err(), "invalidType should be rejected");
2315        match result {
2316            Err(crate::error::ServiceError::Platform(msg)) => {
2317                assert!(
2318                    msg.contains("invalidType"),
2319                    "error should mention the type: {msg}"
2320                );
2321            }
2322            other => panic!("Expected Platform error, got: {other:?}"),
2323        }
2324    }
2325
2326    #[test]
2327    fn validate_all_14_types_pass() {
2328        for &t in VALID_FOREGROUND_SERVICE_TYPES {
2329            assert!(
2330                validate_foreground_service_type(t).is_ok(),
2331                "{t} should be valid"
2332            );
2333        }
2334    }
2335
2336    #[test]
2337    fn valid_types_count_is_14() {
2338        assert_eq!(
2339            VALID_FOREGROUND_SERVICE_TYPES.len(),
2340            14,
2341            "should have exactly 14 valid types"
2342        );
2343    }
2344
2345    #[test]
2346    fn validate_empty_string_returns_error() {
2347        let result = validate_foreground_service_type("");
2348        assert!(result.is_err(), "empty string should be rejected");
2349    }
2350
2351    #[test]
2352    fn validate_case_sensitive() {
2353        // "DataSync" (capitalized) should NOT pass — case-sensitive.
2354        let result = validate_foreground_service_type("DataSync");
2355        assert!(
2356            result.is_err(),
2357            "validation should be case-sensitive: DataSync should fail"
2358        );
2359    }
2360
2361    // --- ServiceState serde tests ---
2362
2363    #[test]
2364    fn service_state_idle_serde_roundtrip() {
2365        let state = ServiceState::Idle;
2366        let json = serde_json::to_string(&state).unwrap();
2367        let de: ServiceState = serde_json::from_str(&json).unwrap();
2368        assert_eq!(de, ServiceState::Idle);
2369    }
2370
2371    #[test]
2372    fn service_state_initializing_serde_roundtrip() {
2373        let state = ServiceState::Initializing;
2374        let json = serde_json::to_string(&state).unwrap();
2375        let de: ServiceState = serde_json::from_str(&json).unwrap();
2376        assert_eq!(de, ServiceState::Initializing);
2377    }
2378
2379    #[test]
2380    fn service_state_running_serde_roundtrip() {
2381        let state = ServiceState::Running;
2382        let json = serde_json::to_string(&state).unwrap();
2383        let de: ServiceState = serde_json::from_str(&json).unwrap();
2384        assert_eq!(de, ServiceState::Running);
2385    }
2386
2387    #[test]
2388    fn service_state_stopped_serde_roundtrip() {
2389        let state = ServiceState::Stopped;
2390        let json = serde_json::to_string(&state).unwrap();
2391        let de: ServiceState = serde_json::from_str(&json).unwrap();
2392        assert_eq!(de, ServiceState::Stopped);
2393    }
2394
2395    #[test]
2396    fn service_state_json_values_are_camel_case() {
2397        assert_eq!(
2398            serde_json::to_string(&ServiceState::Idle).unwrap(),
2399            "\"idle\""
2400        );
2401        assert_eq!(
2402            serde_json::to_string(&ServiceState::Initializing).unwrap(),
2403            "\"initializing\""
2404        );
2405        assert_eq!(
2406            serde_json::to_string(&ServiceState::Running).unwrap(),
2407            "\"running\""
2408        );
2409        assert_eq!(
2410            serde_json::to_string(&ServiceState::Stopped).unwrap(),
2411            "\"stopped\""
2412        );
2413    }
2414
2415    // --- ServiceStatus serde tests ---
2416
2417    #[test]
2418    fn service_status_serde_roundtrip_idle() {
2419        let status = ServiceStatus {
2420            state: ServiceState::Idle,
2421            ..Default::default()
2422        };
2423        let json = serde_json::to_string(&status).unwrap();
2424        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
2425        assert_eq!(de.state, ServiceState::Idle);
2426        assert_eq!(de.last_error, None);
2427    }
2428
2429    #[test]
2430    fn service_status_serde_roundtrip_with_error() {
2431        let status = ServiceStatus {
2432            state: ServiceState::Stopped,
2433            last_error: Some("init failed".into()),
2434            ..Default::default()
2435        };
2436        let json = serde_json::to_string(&status).unwrap();
2437        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
2438        assert_eq!(de.state, ServiceState::Stopped);
2439        assert_eq!(de.last_error, Some("init failed".into()));
2440    }
2441
2442    #[test]
2443    fn service_status_json_keys_camel_case() {
2444        let status = ServiceStatus {
2445            state: ServiceState::Running,
2446            ..Default::default()
2447        };
2448        let json = serde_json::to_string(&status).unwrap();
2449        assert!(json.contains("\"state\":"), "state key: {json}");
2450        assert!(json.contains("\"lastError\":"), "lastError key: {json}");
2451    }
2452
2453    #[test]
2454    fn service_status_json_null_last_error() {
2455        let status = ServiceStatus {
2456            state: ServiceState::Idle,
2457            ..Default::default()
2458        };
2459        let json = serde_json::to_string(&status).unwrap();
2460        assert!(
2461            json.contains("\"lastError\":null"),
2462            "lastError should be null: {json}"
2463        );
2464    }
2465
2466    // --- Platform tests ---
2467
2468    #[test]
2469    fn platform_serde_roundtrip() {
2470        for variant in [
2471            Platform::Android,
2472            Platform::Ios,
2473            Platform::Windows,
2474            Platform::Macos,
2475            Platform::Linux,
2476            Platform::Unknown,
2477        ] {
2478            let json = serde_json::to_string(&variant).unwrap();
2479            let de: Platform = serde_json::from_str(&json).unwrap();
2480            assert_eq!(de, variant);
2481        }
2482    }
2483
2484    #[test]
2485    fn platform_json_values_are_camel_case() {
2486        assert_eq!(
2487            serde_json::to_string(&Platform::Android).unwrap(),
2488            "\"android\""
2489        );
2490        assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
2491        assert_eq!(
2492            serde_json::to_string(&Platform::Windows).unwrap(),
2493            "\"windows\""
2494        );
2495        assert_eq!(
2496            serde_json::to_string(&Platform::Macos).unwrap(),
2497            "\"macos\""
2498        );
2499        assert_eq!(
2500            serde_json::to_string(&Platform::Linux).unwrap(),
2501            "\"linux\""
2502        );
2503        assert_eq!(
2504            serde_json::to_string(&Platform::Unknown).unwrap(),
2505            "\"unknown\""
2506        );
2507    }
2508
2509    // --- LifecycleMode tests ---
2510
2511    #[test]
2512    fn lifecycle_mode_serde_roundtrip() {
2513        for variant in [
2514            LifecycleMode::AndroidForegroundService,
2515            LifecycleMode::IosBgTaskScheduler,
2516            LifecycleMode::DesktopInProcess,
2517            LifecycleMode::DesktopOsService,
2518        ] {
2519            let json = serde_json::to_string(&variant).unwrap();
2520            let de: LifecycleMode = serde_json::from_str(&json).unwrap();
2521            assert_eq!(de, variant);
2522        }
2523    }
2524
2525    #[test]
2526    fn lifecycle_mode_json_values_are_camel_case() {
2527        assert_eq!(
2528            serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
2529            "\"androidForegroundService\""
2530        );
2531        assert_eq!(
2532            serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
2533            "\"iosBgTaskScheduler\""
2534        );
2535        assert_eq!(
2536            serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
2537            "\"desktopInProcess\""
2538        );
2539        assert_eq!(
2540            serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
2541            "\"desktopOsService\""
2542        );
2543    }
2544
2545    // --- LifecycleGuarantee tests ---
2546
2547    #[test]
2548    fn lifecycle_guarantee_serde_roundtrip() {
2549        for variant in [
2550            LifecycleGuarantee::Guaranteed,
2551            LifecycleGuarantee::BestEffort,
2552            LifecycleGuarantee::Unsupported,
2553        ] {
2554            let json = serde_json::to_string(&variant).unwrap();
2555            let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
2556            assert_eq!(de, variant);
2557        }
2558    }
2559
2560    #[test]
2561    fn lifecycle_guarantee_json_values_are_camel_case() {
2562        assert_eq!(
2563            serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
2564            "\"guaranteed\""
2565        );
2566        assert_eq!(
2567            serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
2568            "\"bestEffort\""
2569        );
2570        assert_eq!(
2571            serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
2572            "\"unsupported\""
2573        );
2574    }
2575
2576    // --- PlatformCapabilities tests ---
2577
2578    #[test]
2579    fn platform_capabilities_serde_roundtrip() {
2580        let caps = PlatformCapabilities {
2581            platform: Platform::Android,
2582            lifecycle_mode: LifecycleMode::AndroidForegroundService,
2583            survives_app_close: LifecycleGuarantee::BestEffort,
2584            survives_reboot: LifecycleGuarantee::BestEffort,
2585            survives_force_quit: LifecycleGuarantee::Unsupported,
2586            background_execution: LifecycleGuarantee::Guaranteed,
2587            limitations: vec!["OEM battery optimization".into()],
2588            required_setup: vec!["FOREGROUND_SERVICE permission".into()],
2589        };
2590        let json = serde_json::to_string(&caps).unwrap();
2591        let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
2592        assert_eq!(de, caps);
2593    }
2594
2595    #[test]
2596    fn platform_capabilities_json_keys_camel_case() {
2597        let caps = PlatformCapabilities {
2598            platform: Platform::Linux,
2599            lifecycle_mode: LifecycleMode::DesktopInProcess,
2600            survives_app_close: LifecycleGuarantee::Unsupported,
2601            survives_reboot: LifecycleGuarantee::Unsupported,
2602            survives_force_quit: LifecycleGuarantee::Unsupported,
2603            background_execution: LifecycleGuarantee::Guaranteed,
2604            limitations: vec![],
2605            required_setup: vec![],
2606        };
2607        let json = serde_json::to_string(&caps).unwrap();
2608        assert!(json.contains("\"platform\":"), "platform: {json}");
2609        assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
2610        assert!(
2611            json.contains("\"survivesAppClose\":"),
2612            "survivesAppClose: {json}"
2613        );
2614        assert!(
2615            json.contains("\"survivesReboot\":"),
2616            "survivesReboot: {json}"
2617        );
2618        assert!(
2619            json.contains("\"survivesForceQuit\":"),
2620            "survivesForceQuit: {json}"
2621        );
2622        assert!(
2623            json.contains("\"backgroundExecution\":"),
2624            "backgroundExecution: {json}"
2625        );
2626        assert!(json.contains("\"limitations\":"), "limitations: {json}");
2627        assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
2628    }
2629
2630    #[test]
2631    fn platform_capabilities_empty_collections_serialize() {
2632        let caps = PlatformCapabilities {
2633            platform: Platform::Unknown,
2634            lifecycle_mode: LifecycleMode::DesktopInProcess,
2635            survives_app_close: LifecycleGuarantee::Unsupported,
2636            survives_reboot: LifecycleGuarantee::Unsupported,
2637            survives_force_quit: LifecycleGuarantee::Unsupported,
2638            background_execution: LifecycleGuarantee::Unsupported,
2639            limitations: vec![],
2640            required_setup: vec![],
2641        };
2642        let json = serde_json::to_string(&caps).unwrap();
2643        assert!(json.contains("\"limitations\":[]"), "{json}");
2644        assert!(json.contains("\"requiredSetup\":[]"), "{json}");
2645    }
2646
2647    // --- NativeState tests ---
2648
2649    #[test]
2650    fn native_state_serde_roundtrip() {
2651        for variant in [
2652            NativeState::Idle,
2653            NativeState::Starting,
2654            NativeState::Running,
2655            NativeState::Stopping,
2656            NativeState::Timeout,
2657            NativeState::Expired,
2658            NativeState::Recovering,
2659            NativeState::Error,
2660        ] {
2661            let json = serde_json::to_string(&variant).unwrap();
2662            let de: NativeState = serde_json::from_str(&json).unwrap();
2663            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
2664        }
2665    }
2666
2667    #[test]
2668    fn native_state_json_values_are_camel_case() {
2669        assert_eq!(
2670            serde_json::to_string(&NativeState::Idle).unwrap(),
2671            "\"idle\""
2672        );
2673        assert_eq!(
2674            serde_json::to_string(&NativeState::Starting).unwrap(),
2675            "\"starting\""
2676        );
2677        assert_eq!(
2678            serde_json::to_string(&NativeState::Running).unwrap(),
2679            "\"running\""
2680        );
2681        assert_eq!(
2682            serde_json::to_string(&NativeState::Stopping).unwrap(),
2683            "\"stopping\""
2684        );
2685        assert_eq!(
2686            serde_json::to_string(&NativeState::Timeout).unwrap(),
2687            "\"timeout\""
2688        );
2689        assert_eq!(
2690            serde_json::to_string(&NativeState::Expired).unwrap(),
2691            "\"expired\""
2692        );
2693        assert_eq!(
2694            serde_json::to_string(&NativeState::Recovering).unwrap(),
2695            "\"recovering\""
2696        );
2697        assert_eq!(
2698            serde_json::to_string(&NativeState::Error).unwrap(),
2699            "\"error\""
2700        );
2701    }
2702
2703    // --- Extended ServiceStatus tests ---
2704
2705    #[test]
2706    fn service_status_backward_compat_deserialize_old_json() {
2707        let old_json = r#"{"state":"running","lastError":null}"#;
2708        let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
2709        assert_eq!(status.state, ServiceState::Running);
2710        assert_eq!(status.last_error, None);
2711        assert_eq!(status.desired_running, None);
2712        assert_eq!(status.native_state, None);
2713        assert_eq!(status.platform_mode, None);
2714        assert_eq!(status.last_start_config, None);
2715        assert_eq!(status.last_heartbeat_at, None);
2716        assert_eq!(status.restart_attempt, None);
2717        assert_eq!(status.recovery_reason, None);
2718        assert_eq!(status.platform_error, None);
2719    }
2720
2721    #[test]
2722    fn service_status_new_fields_serialize_when_present() {
2723        let status = ServiceStatus {
2724            state: ServiceState::Running,
2725            last_error: None,
2726            desired_running: Some(true),
2727            native_state: Some(NativeState::Running),
2728            platform_mode: Some(LifecycleMode::AndroidForegroundService),
2729            last_start_config: Some(StartConfig::default()),
2730            last_heartbeat_at: Some(1234567890),
2731            restart_attempt: Some(2),
2732            recovery_reason: Some("boot recovery".into()),
2733            platform_error: Some("timeout exceeded".into()),
2734        };
2735        let json = serde_json::to_string(&status).unwrap();
2736        assert!(json.contains("\"desiredRunning\":true"), "{json}");
2737        assert!(json.contains("\"nativeState\":\"running\""), "{json}");
2738        assert!(
2739            json.contains("\"platformMode\":\"androidForegroundService\""),
2740            "{json}"
2741        );
2742        assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
2743        assert!(json.contains("\"restartAttempt\":2"), "{json}");
2744        assert!(
2745            json.contains("\"recoveryReason\":\"boot recovery\""),
2746            "{json}"
2747        );
2748        assert!(
2749            json.contains("\"platformError\":\"timeout exceeded\""),
2750            "{json}"
2751        );
2752    }
2753
2754    #[test]
2755    fn service_status_new_fields_absent_when_none() {
2756        let status = ServiceStatus {
2757            state: ServiceState::Idle,
2758            last_error: None,
2759            desired_running: None,
2760            native_state: None,
2761            platform_mode: None,
2762            last_start_config: None,
2763            last_heartbeat_at: None,
2764            restart_attempt: None,
2765            recovery_reason: None,
2766            platform_error: None,
2767        };
2768        let json = serde_json::to_string(&status).unwrap();
2769        assert!(!json.contains("desiredRunning"), "should be absent: {json}");
2770        assert!(!json.contains("nativeState"), "should be absent: {json}");
2771        assert!(!json.contains("platformMode"), "should be absent: {json}");
2772        assert!(
2773            !json.contains("lastStartConfig"),
2774            "should be absent: {json}"
2775        );
2776        assert!(
2777            !json.contains("lastHeartbeatAt"),
2778            "should be absent: {json}"
2779        );
2780        assert!(!json.contains("restartAttempt"), "should be absent: {json}");
2781        assert!(!json.contains("recoveryReason"), "should be absent: {json}");
2782        assert!(!json.contains("platformError"), "should be absent: {json}");
2783    }
2784
2785    #[test]
2786    fn service_status_default_impl() {
2787        let status = ServiceStatus::default();
2788        assert_eq!(status.state, ServiceState::Idle);
2789        assert_eq!(status.last_error, None);
2790        assert_eq!(status.desired_running, None);
2791        assert_eq!(status.native_state, None);
2792        assert_eq!(status.platform_mode, None);
2793        assert_eq!(status.last_start_config, None);
2794        assert_eq!(status.last_heartbeat_at, None);
2795        assert_eq!(status.restart_attempt, None);
2796        assert_eq!(status.recovery_reason, None);
2797        assert_eq!(status.platform_error, None);
2798    }
2799
2800    #[test]
2801    fn service_status_full_roundtrip_with_all_fields() {
2802        let status = ServiceStatus {
2803            state: ServiceState::Running,
2804            last_error: Some("previous crash".into()),
2805            desired_running: Some(true),
2806            native_state: Some(NativeState::Recovering),
2807            platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
2808            last_start_config: Some(StartConfig {
2809                service_label: "Sync".into(),
2810                foreground_service_type: "dataSync".into(),
2811            }),
2812            last_heartbeat_at: Some(999),
2813            restart_attempt: Some(3),
2814            recovery_reason: Some("force stop".into()),
2815            platform_error: Some("scheduler busy".into()),
2816        };
2817        let json = serde_json::to_string(&status).unwrap();
2818        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
2819        assert_eq!(de.state, ServiceState::Running);
2820        assert_eq!(de.last_error, Some("previous crash".into()));
2821        assert_eq!(de.desired_running, Some(true));
2822        assert_eq!(de.native_state, Some(NativeState::Recovering));
2823        assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
2824        assert!(de.last_start_config.is_some());
2825        assert_eq!(de.last_heartbeat_at, Some(999));
2826        assert_eq!(de.restart_attempt, Some(3));
2827        assert_eq!(de.recovery_reason, Some("force stop".into()));
2828        assert_eq!(de.platform_error, Some("scheduler busy".into()));
2829    }
2830
2831    #[test]
2832    fn platform_capabilities_deserialize_from_json() {
2833        let json = r#"{
2834            "platform":"ios",
2835            "lifecycleMode":"iosBgTaskScheduler",
2836            "survivesAppClose":"bestEffort",
2837            "survivesReboot":"bestEffort",
2838            "survivesForceQuit":"unsupported",
2839            "backgroundExecution":"bestEffort",
2840            "limitations":["Cannot guarantee continuous execution"],
2841            "requiredSetup":["UIBackgroundModes in Info.plist"]
2842        }"#;
2843        let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
2844        assert_eq!(caps.platform, Platform::Ios);
2845        assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
2846        assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
2847        assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
2848        assert_eq!(caps.limitations.len(), 1);
2849        assert_eq!(caps.required_setup.len(), 1);
2850    }
2851
2852    // --- IOSSchedulingStatus tests ---
2853
2854    #[test]
2855    fn ios_scheduling_status_both_scheduled() {
2856        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
2857        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2858        assert!(status.refresh_scheduled);
2859        assert!(status.processing_scheduled);
2860        assert_eq!(status.refresh_error, None);
2861        assert_eq!(status.processing_error, None);
2862    }
2863
2864    #[test]
2865    fn ios_scheduling_status_partial_success() {
2866        let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
2867        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2868        assert!(status.refresh_scheduled);
2869        assert!(!status.processing_scheduled);
2870        assert_eq!(status.refresh_error, None);
2871        assert_eq!(status.processing_error, Some("not permitted".to_string()));
2872    }
2873
2874    #[test]
2875    fn ios_scheduling_status_with_errors() {
2876        let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
2877        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2878        assert!(!status.refresh_scheduled);
2879        assert!(!status.processing_scheduled);
2880        assert_eq!(status.refresh_error, Some("err1".to_string()));
2881        assert_eq!(status.processing_error, Some("err2".to_string()));
2882    }
2883
2884    #[test]
2885    fn ios_scheduling_status_serde_roundtrip() {
2886        let status = IOSSchedulingStatus {
2887            refresh_scheduled: true,
2888            processing_scheduled: false,
2889            refresh_error: None,
2890            processing_error: Some("busy".into()),
2891        };
2892        let json = serde_json::to_string(&status).unwrap();
2893        let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
2894        assert_eq!(de, status);
2895    }
2896
2897    #[test]
2898    fn ios_scheduling_status_json_keys_camel_case() {
2899        let status = IOSSchedulingStatus {
2900            refresh_scheduled: true,
2901            processing_scheduled: true,
2902            refresh_error: Some("err".into()),
2903            processing_error: None,
2904        };
2905        let json = serde_json::to_string(&status).unwrap();
2906        assert!(json.contains("\"refreshScheduled\":"), "{json}");
2907        assert!(json.contains("\"processingScheduled\":"), "{json}");
2908        assert!(json.contains("\"refreshError\":"), "{json}");
2909        assert!(
2910            !json.contains("processingError"),
2911            "None fields should be absent: {json}"
2912        );
2913    }
2914
2915    #[test]
2916    fn ios_scheduling_status_from_value_null_errors() {
2917        // Simulates the Swift response where errors are NSNull()
2918        let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
2919        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2920        assert!(status.refresh_scheduled);
2921        assert!(status.processing_scheduled);
2922        assert_eq!(status.refresh_error, None);
2923        assert_eq!(status.processing_error, None);
2924    }
2925
2926    #[test]
2927    fn ios_scheduling_status_from_value_missing_errors() {
2928        // Swift may not include error fields when scheduling succeeds
2929        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
2930        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2931        assert!(status.refresh_scheduled);
2932        assert!(status.processing_scheduled);
2933        assert_eq!(status.refresh_error, None);
2934        assert_eq!(status.processing_error, None);
2935    }
2936
2937    // --- OsServiceInstallState tests ---
2938
2939    #[test]
2940    fn os_service_install_state_serde_roundtrip() {
2941        for variant in [
2942            OsServiceInstallState::NotInstalled,
2943            OsServiceInstallState::Installed,
2944            OsServiceInstallState::Running,
2945        ] {
2946            let json = serde_json::to_string(&variant).unwrap();
2947            let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
2948            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
2949        }
2950    }
2951
2952    #[test]
2953    fn os_service_install_state_json_values_camel_case() {
2954        assert_eq!(
2955            serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
2956            "\"notInstalled\""
2957        );
2958        assert_eq!(
2959            serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
2960            "\"installed\""
2961        );
2962        assert_eq!(
2963            serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
2964            "\"running\""
2965        );
2966    }
2967
2968    // --- OsServiceStatus tests ---
2969
2970    #[test]
2971    fn os_service_status_serde_roundtrip() {
2972        let status = OsServiceStatus {
2973            label: "com.example.bg-service".into(),
2974            mode: "systemd".into(),
2975            installed: OsServiceInstallState::Running,
2976            ipc_connected: true,
2977            socket_path: Some("/tmp/test.sock".into()),
2978            last_error: None,
2979        };
2980        let json = serde_json::to_string(&status).unwrap();
2981        let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
2982        assert_eq!(de.label, "com.example.bg-service");
2983        assert_eq!(de.mode, "systemd");
2984        assert_eq!(de.installed, OsServiceInstallState::Running);
2985        assert!(de.ipc_connected);
2986        assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
2987        assert_eq!(de.last_error, None);
2988    }
2989
2990    #[test]
2991    fn os_service_status_json_keys_camel_case() {
2992        let status = OsServiceStatus {
2993            label: "test".into(),
2994            mode: "launchd".into(),
2995            installed: OsServiceInstallState::Installed,
2996            ipc_connected: false,
2997            socket_path: Some("/run/test.sock".into()),
2998            last_error: Some("timeout".into()),
2999        };
3000        let json = serde_json::to_string(&status).unwrap();
3001        assert!(json.contains("\"label\":"), "{json}");
3002        assert!(json.contains("\"mode\":"), "{json}");
3003        assert!(json.contains("\"installed\":"), "{json}");
3004        assert!(json.contains("\"ipcConnected\":"), "{json}");
3005        assert!(json.contains("\"socketPath\":"), "{json}");
3006        assert!(json.contains("\"lastError\":"), "{json}");
3007    }
3008
3009    #[test]
3010    fn os_service_status_optional_fields_absent_when_none() {
3011        let status = OsServiceStatus {
3012            label: "test".into(),
3013            mode: "systemd".into(),
3014            installed: OsServiceInstallState::NotInstalled,
3015            ipc_connected: false,
3016            socket_path: None,
3017            last_error: None,
3018        };
3019        let json = serde_json::to_string(&status).unwrap();
3020        assert!(!json.contains("socketPath"), "should be absent: {json}");
3021        assert!(!json.contains("lastError"), "should be absent: {json}");
3022    }
3023
3024    #[test]
3025    fn os_service_status_with_all_optional_fields() {
3026        let status = OsServiceStatus {
3027            label: "com.test".into(),
3028            mode: "launchd".into(),
3029            installed: OsServiceInstallState::Running,
3030            ipc_connected: true,
3031            socket_path: Some("/var/run/com.test.sock".into()),
3032            last_error: Some("connection refused".into()),
3033        };
3034        let json = serde_json::to_string(&status).unwrap();
3035        assert!(
3036            json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
3037            "{json}"
3038        );
3039        assert!(
3040            json.contains("\"lastError\":\"connection refused\""),
3041            "{json}"
3042        );
3043    }
3044
3045    #[test]
3046    fn os_service_status_deserialize_from_json() {
3047        let json = r#"{
3048            "label":"com.example.svc",
3049            "mode":"systemd",
3050            "installed":"running",
3051            "ipcConnected":true,
3052            "socketPath":"/tmp/test.sock"
3053        }"#;
3054        let status: OsServiceStatus = serde_json::from_str(json).unwrap();
3055        assert_eq!(status.label, "com.example.svc");
3056        assert_eq!(status.mode, "systemd");
3057        assert_eq!(status.installed, OsServiceInstallState::Running);
3058        assert!(status.ipc_connected);
3059        assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
3060        assert_eq!(status.last_error, None);
3061    }
3062
3063    // --- PendingTaskInfo tests ---
3064
3065    #[test]
3066    fn pending_task_info_serde_roundtrip() {
3067        let info = PendingTaskInfo {
3068            task_kind: "refresh".into(),
3069            identifier: "com.example.app.bg-refresh".into(),
3070            received_at: 1700000000.123,
3071            consumed_at: None,
3072        };
3073        let json = serde_json::to_string(&info).unwrap();
3074        let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
3075        assert_eq!(de, info);
3076    }
3077
3078    #[test]
3079    fn pending_task_info_json_keys_camel_case() {
3080        let info = PendingTaskInfo {
3081            task_kind: "processing".into(),
3082            identifier: "test-id".into(),
3083            received_at: 123456.0,
3084            consumed_at: Some(123500.0),
3085        };
3086        let json = serde_json::to_string(&info).unwrap();
3087        assert!(json.contains("\"taskKind\":"), "{json}");
3088        assert!(json.contains("\"identifier\":"), "{json}");
3089        assert!(json.contains("\"receivedAt\":"), "{json}");
3090        assert!(json.contains("\"consumedAt\":"), "{json}");
3091    }
3092
3093    #[test]
3094    fn pending_task_info_from_native_response() {
3095        // Simulates the Swift response when a pending task exists
3096        let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
3097        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3098        assert_eq!(info.task_kind, "refresh");
3099        assert_eq!(info.identifier, "com.example.bg-refresh");
3100        assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
3101        assert_eq!(info.consumed_at, None);
3102    }
3103
3104    #[test]
3105    fn pending_task_info_processing_kind() {
3106        let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
3107        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3108        assert_eq!(info.task_kind, "processing");
3109        assert_eq!(info.identifier, "com.example.bg-processing");
3110        assert_eq!(info.consumed_at, None);
3111    }
3112
3113    #[test]
3114    fn pending_task_info_consumed_at_roundtrip() {
3115        let info = PendingTaskInfo {
3116            task_kind: "refresh".into(),
3117            identifier: "com.example.bg-refresh".into(),
3118            received_at: 1700000000.0,
3119            consumed_at: Some(1700000060.5),
3120        };
3121        let json = serde_json::to_string(&info).unwrap();
3122        assert!(json.contains("\"consumedAt\":1700000060.5"), "{json}");
3123        let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
3124        assert_eq!(de.consumed_at, Some(1700000060.5));
3125    }
3126
3127    #[test]
3128    fn pending_task_info_consumed_at_null_deserializes_to_none() {
3129        let json = r#"{"taskKind":"refresh","identifier":"id","receivedAt":1.0,"consumedAt":null}"#;
3130        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3131        assert_eq!(info.consumed_at, None);
3132    }
3133
3134    // --- LifecycleState tests ---
3135
3136    #[test]
3137    fn lifecycle_state_all_variants_serde_roundtrip() {
3138        for variant in [
3139            LifecycleState::Idle,
3140            LifecycleState::Starting,
3141            LifecycleState::Running,
3142            LifecycleState::Stopping,
3143            LifecycleState::Stopped,
3144            LifecycleState::Recovering,
3145            LifecycleState::RecoveryPending,
3146            LifecycleState::Expired,
3147            LifecycleState::Blocked,
3148            LifecycleState::Error,
3149        ] {
3150            let json = serde_json::to_string(&variant).unwrap();
3151            let de: LifecycleState = serde_json::from_str(&json).unwrap();
3152            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3153        }
3154    }
3155
3156    #[test]
3157    fn lifecycle_state_json_values_are_camel_case() {
3158        assert_eq!(
3159            serde_json::to_string(&LifecycleState::Idle).unwrap(),
3160            "\"idle\""
3161        );
3162        assert_eq!(
3163            serde_json::to_string(&LifecycleState::Starting).unwrap(),
3164            "\"starting\""
3165        );
3166        assert_eq!(
3167            serde_json::to_string(&LifecycleState::Running).unwrap(),
3168            "\"running\""
3169        );
3170        assert_eq!(
3171            serde_json::to_string(&LifecycleState::Stopping).unwrap(),
3172            "\"stopping\""
3173        );
3174        assert_eq!(
3175            serde_json::to_string(&LifecycleState::Stopped).unwrap(),
3176            "\"stopped\""
3177        );
3178        assert_eq!(
3179            serde_json::to_string(&LifecycleState::Recovering).unwrap(),
3180            "\"recovering\""
3181        );
3182        assert_eq!(
3183            serde_json::to_string(&LifecycleState::RecoveryPending).unwrap(),
3184            "\"recoveryPending\""
3185        );
3186        assert_eq!(
3187            serde_json::to_string(&LifecycleState::Expired).unwrap(),
3188            "\"expired\""
3189        );
3190        assert_eq!(
3191            serde_json::to_string(&LifecycleState::Blocked).unwrap(),
3192            "\"blocked\""
3193        );
3194        assert_eq!(
3195            serde_json::to_string(&LifecycleState::Error).unwrap(),
3196            "\"error\""
3197        );
3198    }
3199
3200    // --- ServiceState → LifecycleState computation tests ---
3201
3202    #[test]
3203    fn service_state_idle_maps_to_lifecycle_idle() {
3204        assert_eq!(
3205            LifecycleState::from(ServiceState::Idle),
3206            LifecycleState::Idle
3207        );
3208    }
3209
3210    #[test]
3211    fn service_state_initializing_maps_to_lifecycle_starting() {
3212        assert_eq!(
3213            LifecycleState::from(ServiceState::Initializing),
3214            LifecycleState::Starting
3215        );
3216    }
3217
3218    #[test]
3219    fn service_state_running_maps_to_lifecycle_running() {
3220        assert_eq!(
3221            LifecycleState::from(ServiceState::Running),
3222            LifecycleState::Running
3223        );
3224    }
3225
3226    #[test]
3227    fn service_state_stopped_maps_to_lifecycle_stopped() {
3228        assert_eq!(
3229            LifecycleState::from(ServiceState::Stopped),
3230            LifecycleState::Stopped
3231        );
3232    }
3233
3234    // --- Severity tests ---
3235
3236    #[test]
3237    fn severity_all_variants_serde_roundtrip() {
3238        for variant in [Severity::Error, Severity::Warning, Severity::Info] {
3239            let json = serde_json::to_string(&variant).unwrap();
3240            let de: Severity = serde_json::from_str(&json).unwrap();
3241            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3242        }
3243    }
3244
3245    #[test]
3246    fn severity_json_values_are_camel_case() {
3247        assert_eq!(
3248            serde_json::to_string(&Severity::Error).unwrap(),
3249            "\"error\""
3250        );
3251        assert_eq!(
3252            serde_json::to_string(&Severity::Warning).unwrap(),
3253            "\"warning\""
3254        );
3255        assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
3256    }
3257
3258    // --- ValidationIssue tests ---
3259
3260    #[test]
3261    fn validation_issue_serde_roundtrip() {
3262        let issue = ValidationIssue {
3263            severity: Severity::Error,
3264            code: "ANDROID_MISSING_PERMISSION".into(),
3265            message: "Missing FOREGROUND_SERVICE permission".into(),
3266            fix: Some("Add FOREGROUND_SERVICE permission to AndroidManifest.xml".into()),
3267            platform: Platform::Android,
3268        };
3269        let json = serde_json::to_string(&issue).unwrap();
3270        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
3271        assert_eq!(de, issue);
3272    }
3273
3274    #[test]
3275    fn validation_issue_without_fix() {
3276        let issue = ValidationIssue {
3277            severity: Severity::Warning,
3278            code: "IOS_SCHEDULER_BUSY".into(),
3279            message: "BGTaskScheduler is busy".into(),
3280            fix: None,
3281            platform: Platform::Ios,
3282        };
3283        let json = serde_json::to_string(&issue).unwrap();
3284        assert!(
3285            !json.contains("fix"),
3286            "fix should be absent when None: {json}"
3287        );
3288        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
3289        assert_eq!(de, issue);
3290    }
3291
3292    #[test]
3293    fn validation_issue_json_keys_camel_case() {
3294        let issue = ValidationIssue {
3295            severity: Severity::Info,
3296            code: "TEST".into(),
3297            message: "test".into(),
3298            fix: Some("do something".into()),
3299            platform: Platform::Linux,
3300        };
3301        let json = serde_json::to_string(&issue).unwrap();
3302        assert!(json.contains("\"severity\":"), "{json}");
3303        assert!(json.contains("\"code\":"), "{json}");
3304        assert!(json.contains("\"message\":"), "{json}");
3305        assert!(json.contains("\"fix\":"), "{json}");
3306        assert!(json.contains("\"platform\":"), "{json}");
3307    }
3308
3309    // --- LifecycleStatus tests ---
3310
3311    #[test]
3312    fn lifecycle_status_serde_roundtrip_minimal() {
3313        let status = LifecycleStatus {
3314            state: LifecycleState::Idle,
3315            desired_running: false,
3316            recovery_enabled: false,
3317            recovery_pending: false,
3318            recovery_reason: None,
3319            last_start_config: None,
3320            last_platform_state: None,
3321            last_platform_error: None,
3322            last_error: None,
3323            platform: Platform::Unknown,
3324            capabilities: PlatformCapabilities {
3325                platform: Platform::Unknown,
3326                lifecycle_mode: LifecycleMode::DesktopInProcess,
3327                survives_app_close: LifecycleGuarantee::Unsupported,
3328                survives_reboot: LifecycleGuarantee::Unsupported,
3329                survives_force_quit: LifecycleGuarantee::Unsupported,
3330                background_execution: LifecycleGuarantee::Unsupported,
3331                limitations: vec![],
3332                required_setup: vec![],
3333            },
3334            issues: vec![],
3335        };
3336        let json = serde_json::to_string(&status).unwrap();
3337        let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
3338        assert_eq!(de.state, LifecycleState::Idle);
3339        assert!(!de.desired_running);
3340        assert!(!de.recovery_enabled);
3341        assert!(!de.recovery_pending);
3342        assert_eq!(de.recovery_reason, None);
3343        assert_eq!(de.last_start_config, None);
3344        assert_eq!(de.last_platform_state, None);
3345        assert_eq!(de.last_platform_error, None);
3346        assert_eq!(de.last_error, None);
3347        assert_eq!(de.platform, Platform::Unknown);
3348        assert!(de.issues.is_empty());
3349    }
3350
3351    #[test]
3352    fn lifecycle_status_optional_fields_absent_when_none() {
3353        let status = LifecycleStatus {
3354            state: LifecycleState::Idle,
3355            desired_running: false,
3356            recovery_enabled: false,
3357            recovery_pending: false,
3358            recovery_reason: None,
3359            last_start_config: None,
3360            last_platform_state: None,
3361            last_platform_error: None,
3362            last_error: None,
3363            platform: Platform::Unknown,
3364            capabilities: PlatformCapabilities {
3365                platform: Platform::Unknown,
3366                lifecycle_mode: LifecycleMode::DesktopInProcess,
3367                survives_app_close: LifecycleGuarantee::Unsupported,
3368                survives_reboot: LifecycleGuarantee::Unsupported,
3369                survives_force_quit: LifecycleGuarantee::Unsupported,
3370                background_execution: LifecycleGuarantee::Unsupported,
3371                limitations: vec![],
3372                required_setup: vec![],
3373            },
3374            issues: vec![],
3375        };
3376        let json = serde_json::to_string(&status).unwrap();
3377        assert!(!json.contains("recoveryReason"), "should be absent: {json}");
3378        assert!(
3379            !json.contains("lastStartConfig"),
3380            "should be absent: {json}"
3381        );
3382        assert!(
3383            !json.contains("lastPlatformState"),
3384            "should be absent: {json}"
3385        );
3386        assert!(
3387            !json.contains("lastPlatformError"),
3388            "should be absent: {json}"
3389        );
3390        assert!(!json.contains("lastError"), "should be absent: {json}");
3391    }
3392
3393    #[test]
3394    fn lifecycle_status_full_roundtrip_with_all_fields() {
3395        let status = LifecycleStatus {
3396            state: LifecycleState::Running,
3397            desired_running: true,
3398            recovery_enabled: true,
3399            recovery_pending: false,
3400            recovery_reason: Some("boot recovery".into()),
3401            last_start_config: Some(StartConfig {
3402                service_label: "Sync".into(),
3403                foreground_service_type: "dataSync".into(),
3404            }),
3405            last_platform_state: Some("running".into()),
3406            last_platform_error: Some("timeout exceeded".into()),
3407            last_error: Some("previous crash".into()),
3408            platform: Platform::Android,
3409            capabilities: PlatformCapabilities {
3410                platform: Platform::Android,
3411                lifecycle_mode: LifecycleMode::AndroidForegroundService,
3412                survives_app_close: LifecycleGuarantee::BestEffort,
3413                survives_reboot: LifecycleGuarantee::BestEffort,
3414                survives_force_quit: LifecycleGuarantee::Unsupported,
3415                background_execution: LifecycleGuarantee::Guaranteed,
3416                limitations: vec!["OEM battery optimization".into()],
3417                required_setup: vec!["FOREGROUND_SERVICE permission".into()],
3418            },
3419            issues: vec![ValidationIssue {
3420                severity: Severity::Warning,
3421                code: "ANDROID_BATTERY_OPTIMIZED".into(),
3422                message: "Battery optimization may kill the service".into(),
3423                fix: Some("Request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS".into()),
3424                platform: Platform::Android,
3425            }],
3426        };
3427        let json = serde_json::to_string(&status).unwrap();
3428        let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
3429        assert_eq!(de.state, LifecycleState::Running);
3430        assert!(de.desired_running);
3431        assert!(de.recovery_enabled);
3432        assert!(!de.recovery_pending);
3433        assert_eq!(de.recovery_reason, Some("boot recovery".into()));
3434        assert!(de.last_start_config.is_some());
3435        assert_eq!(de.last_platform_state, Some("running".into()));
3436        assert_eq!(de.last_platform_error, Some("timeout exceeded".into()));
3437        assert_eq!(de.last_error, Some("previous crash".into()));
3438        assert_eq!(de.platform, Platform::Android);
3439        assert_eq!(de.issues.len(), 1);
3440    }
3441
3442    #[test]
3443    fn lifecycle_status_json_keys_camel_case() {
3444        let status = LifecycleStatus {
3445            state: LifecycleState::RecoveryPending,
3446            desired_running: true,
3447            recovery_enabled: true,
3448            recovery_pending: true,
3449            recovery_reason: Some("platform timeout".into()),
3450            last_start_config: None,
3451            last_platform_state: Some("timeout".into()),
3452            last_platform_error: None,
3453            last_error: None,
3454            platform: Platform::Ios,
3455            capabilities: PlatformCapabilities {
3456                platform: Platform::Ios,
3457                lifecycle_mode: LifecycleMode::IosBgTaskScheduler,
3458                survives_app_close: LifecycleGuarantee::BestEffort,
3459                survives_reboot: LifecycleGuarantee::BestEffort,
3460                survives_force_quit: LifecycleGuarantee::Unsupported,
3461                background_execution: LifecycleGuarantee::BestEffort,
3462                limitations: vec![],
3463                required_setup: vec![],
3464            },
3465            issues: vec![],
3466        };
3467        let json = serde_json::to_string(&status).unwrap();
3468        assert!(json.contains("\"state\":"), "{json}");
3469        assert!(json.contains("\"desiredRunning\":"), "{json}");
3470        assert!(json.contains("\"recoveryEnabled\":"), "{json}");
3471        assert!(json.contains("\"recoveryPending\":"), "{json}");
3472        assert!(json.contains("\"recoveryReason\":"), "{json}");
3473        assert!(json.contains("\"lastPlatformState\":"), "{json}");
3474        assert!(json.contains("\"platform\":"), "{json}");
3475        assert!(json.contains("\"capabilities\":"), "{json}");
3476        assert!(json.contains("\"issues\":"), "{json}");
3477    }
3478}