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/// Validate a foreground service type against the plugin config allowlist.
52///
53/// Returns `Ok(())` if:
54/// - `validate` is `false` (skip check), OR
55/// - `fg_type` is empty (handled by [`validate_foreground_service_type`]), OR
56/// - `fg_type` matches an entry in `allowlist` (case-insensitive).
57///
58/// Returns `Err(ServiceError::Platform)` if:
59/// - `fg_type` is empty (when validate is true), OR
60/// - `fg_type` is not in the allowlist.
61pub fn validate_fg_type_against_allowlist(
62    fg_type: &str,
63    allowlist: &[String],
64    validate: bool,
65) -> Result<(), ServiceError> {
66    if !validate {
67        return Ok(());
68    }
69    if fg_type.is_empty() {
70        return Err(ServiceError::Platform(
71            "foreground_service_type must not be empty".into(),
72        ));
73    }
74    let fg_type_lower = fg_type.to_lowercase();
75    if allowlist.iter().any(|t| t.to_lowercase() == fg_type_lower) {
76        Ok(())
77    } else {
78        Err(ServiceError::Platform(format!(
79            "foreground_service_type '{}' is not allowed. Allowed types: {:?}",
80            fg_type, allowlist
81        )))
82    }
83}
84
85/// Passed into both `init` and `run`.
86/// Gives your service everything it needs to interact with the outside world.
87pub struct ServiceContext<R: Runtime> {
88    /// Fire a local notification. Works on all platforms.
89    pub notifier: Notifier<R>,
90
91    /// Emit an event to the JS UI layer.
92    pub app: tauri::AppHandle<R>,
93
94    /// Cancelled when `stopService()` is called.
95    pub shutdown: CancellationToken,
96
97    /// Text shown in the Android persistent notification.
98    /// Only available on mobile platforms.
99    #[cfg(mobile)]
100    pub service_label: String,
101
102    /// Android foreground service type (e.g. "dataSync", "specialUse").
103    /// Only available on mobile platforms.
104    #[cfg(mobile)]
105    pub foreground_service_type: String,
106}
107
108/// Optional startup configuration forwarded from JS through the plugin.
109#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct StartConfig {
112    /// Text shown in the Android persistent foreground notification.
113    #[serde(default = "default_label", alias = "label")]
114    pub service_label: String,
115
116    /// Android foreground service type (e.g. "dataSync", "specialUse").
117    #[serde(default = "default_foreground_service_type")]
118    pub foreground_service_type: String,
119}
120
121fn default_label() -> String {
122    "Service running".into()
123}
124
125fn default_foreground_service_type() -> String {
126    "remoteMessaging".into()
127}
128
129/// Plugin-level configuration, deserialized from the Tauri plugin config.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct PluginConfig {
133    /// iOS safety timeout in seconds for the expiration handler.
134    /// Default: 28.0 (Apple recommends keeping BG tasks under ~30s).
135    #[serde(default = "default_ios_safety_timeout")]
136    pub ios_safety_timeout_secs: f64,
137
138    /// iOS cancel listener timeout in seconds.
139    /// Default: 14400 (4 hours). Balances leak risk vs. service lifetime.
140    #[serde(default = "default_ios_cancel_listener_timeout_secs")]
141    pub ios_cancel_listener_timeout_secs: u64,
142
143    /// iOS BGProcessingTask safety timeout in seconds.
144    /// Default: 0.0 (no cap). Processing tasks can run for minutes/hours,
145    /// so unlike BGAppRefreshTask (28s default), this defaults to uncapped.
146    /// Set to a positive value to impose a hard cap on processing task duration.
147    #[serde(default = "default_ios_processing_safety_timeout_secs")]
148    pub ios_processing_safety_timeout_secs: f64,
149
150    /// iOS `BGAppRefreshTask` earliest begin date in minutes from now.
151    /// Default: 15.0. Controls how soon iOS can launch the refresh task.
152    #[serde(default = "default_ios_earliest_refresh_begin_minutes")]
153    pub ios_earliest_refresh_begin_minutes: f64,
154
155    /// iOS `BGProcessingTask` earliest begin date in minutes from now.
156    /// Default: 15.0. Controls how soon iOS can launch the processing task.
157    #[serde(default = "default_ios_earliest_processing_begin_minutes")]
158    pub ios_earliest_processing_begin_minutes: f64,
159
160    /// iOS `BGProcessingTask` requires external power.
161    /// Default: false. When true, iOS only launches the processing task
162    /// while the device is connected to power.
163    #[serde(default)]
164    pub ios_requires_external_power: bool,
165
166    /// iOS `BGProcessingTask` requires network connectivity.
167    /// Default: false. When true, iOS only launches the processing task
168    /// when the device has network access.
169    #[serde(default)]
170    pub ios_requires_network_connectivity: bool,
171
172    /// iOS adaptive `BGProcessingTask` scheduling ceiling, as a multiplier of
173    /// `ios_earliest_processing_begin_minutes`. Default: 4.0. The Swift
174    /// adaptive scheduler backs off the processing request's earliest begin
175    /// date after expired runs, never beyond `configured * multiplier`.
176    #[serde(default = "default_ios_processing_ceiling_multiplier")]
177    pub ios_processing_ceiling_multiplier: f64,
178
179    /// Capacity for the manager command channel (mpsc).
180    /// Default: 16. Increase for high-throughput scenarios with many
181    /// concurrent start/stop/is-running calls.
182    #[serde(default = "default_channel_capacity")]
183    pub channel_capacity: usize,
184
185    /// Android foreground service types allowed for `startService()`.
186    /// Default: `["remoteMessaging"]`. The preflight validation rejects any type
187    /// not in this list when `android_validate_foreground_service_type` is true.
188    #[serde(default = "default_android_foreground_service_types")]
189    pub android_foreground_service_types: Vec<String>,
190
191    /// Whether to validate the requested foreground service type against
192    /// `android_foreground_service_types` before starting the native service.
193    /// Default: true. Set to false to skip the allowlist check.
194    #[serde(default = "default_true")]
195    pub android_validate_foreground_service_type: bool,
196
197    /// Timeout policy for Android foreground service.
198    /// Values: "stop", "notifyUser" (default), "scheduleRecovery".
199    /// - "stop": set desiredRunning=false, stop service.
200    /// - "notifyUser": stop service, post timeout notification, keep desiredRunning=true.
201    /// - "scheduleRecovery": stop service, set recoveryPending=true, attempt reschedule.
202    #[serde(default = "default_android_on_timeout")]
203    pub android_on_timeout: String,
204
205    /// Android notification channel ID for the foreground service notification.
206    /// Default: "bg_service".
207    #[serde(default = "default_android_notification_channel_id")]
208    pub android_notification_channel_id: String,
209
210    /// Android notification channel name (visible to the user in system settings).
211    /// Default: "Background Service".
212    #[serde(default = "default_android_notification_channel_name")]
213    pub android_notification_channel_name: String,
214
215    /// Android notification ID for the foreground service notification.
216    /// Default: 9001.
217    #[serde(default = "default_android_notification_id")]
218    pub android_notification_id: u32,
219
220    /// Custom small icon resource name for the foreground notification.
221    /// When `None`, the system default (`android.R.drawable.ic_dialog_info`) is used.
222    #[serde(default)]
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub android_notification_small_icon: Option<String>,
225
226    /// Whether to show a stop action button on the foreground notification.
227    /// Default: true.
228    #[serde(default = "default_true")]
229    pub android_show_stop_action: bool,
230
231    /// Whether to automatically request POST_NOTIFICATIONS permission on
232    /// Android API 33+ when the plugin loads. Default: false (NTF-09: the
233    /// request is consented via an explainer rather than fired unconditionally
234    /// on load). Note the load-bearing gate lives in the Kotlin `load()` /
235    /// `optBoolean` fallback — Tauri forwards the RAW plugin config to mobile,
236    /// so this Rust default governs only the Rust-side `PluginConfig`.
237    #[serde(default)]
238    pub android_request_notification_permission_on_load: bool,
239
240    /// Post a local notification when the OS pauses background delivery
241    /// (platform timeout/expiration). Default: false (opt-in).
242    ///
243    /// On Android this is suppressed when `androidOnTimeout` is
244    /// `"notifyUser"`: the Kotlin service already posts a native timeout
245    /// notification there, and plugin-side notification ids cannot dedupe
246    /// against `NotificationManager` ids (DEC-002).
247    #[serde(default)]
248    pub notify_on_timeout: bool,
249
250    /// Post a local notification when background delivery is restored
251    /// after an OS restart or boot recovery. Default: false (opt-in).
252    ///
253    /// Always suppressed on Android: the native BootReceiver recovery
254    /// notification path is unconditionally active there, so a plugin-side
255    /// notice would double-notify (DEC-002).
256    #[serde(default)]
257    pub notify_on_recovery: bool,
258
259    /// Desktop service mode: "inProcess" (default) or "osService".
260    /// Controls whether the background service runs in-process or as a
261    /// registered OS service/daemon.
262    #[cfg(feature = "desktop-service")]
263    #[serde(default = "default_desktop_service_mode")]
264    pub desktop_service_mode: String,
265
266    /// Optional custom label for the desktop OS service registration.
267    /// When `None`, the label is auto-derived from the app identifier.
268    #[cfg(feature = "desktop-service")]
269    #[serde(default)]
270    pub desktop_service_label: Option<String>,
271
272    /// Whether the OS service should start automatically on boot (Linux) or
273    /// login (macOS). Only applies when `desktop_service_mode` is `"osService"`.
274    /// Default: false.
275    #[cfg(feature = "desktop-service")]
276    #[serde(default)]
277    pub desktop_service_autostart: bool,
278
279    /// When `true`, calling `startService()` will automatically start the OS
280    /// service if it is not already running (i.e. IPC is disconnected).
281    /// Only applies when `desktop_service_mode` is `"osService"`.
282    /// Default: false.
283    #[cfg(feature = "desktop-service")]
284    #[serde(default)]
285    pub desktop_start_service_if_missing: bool,
286
287    /// Timeout in milliseconds to wait for the IPC connection to become ready
288    /// after starting the OS service sidecar. Only applies when
289    /// `desktop_start_service_if_missing` is `true`.
290    /// Default: 5000 (5 seconds).
291    #[cfg(feature = "desktop-service")]
292    #[serde(default = "default_desktop_service_start_timeout_ms")]
293    pub desktop_service_start_timeout_ms: u64,
294
295    /// When `true`, the Windows GUI uses the daemon-first backend path
296    /// (identical to Unix). Default: false — Windows stays on the
297    /// in-process Local backend until the daemon suite passes on Windows
298    /// CI (spec 01 §6 D3 compat clause). Unix ignores this flag.
299    #[cfg(feature = "desktop-service")]
300    #[serde(default)]
301    pub desktop_windows_daemon_opt_in: bool,
302}
303
304fn default_ios_safety_timeout() -> f64 {
305    28.0
306}
307
308fn default_ios_cancel_listener_timeout_secs() -> u64 {
309    14400
310}
311
312fn default_ios_processing_safety_timeout_secs() -> f64 {
313    0.0
314}
315
316fn default_ios_earliest_refresh_begin_minutes() -> f64 {
317    15.0
318}
319
320fn default_ios_earliest_processing_begin_minutes() -> f64 {
321    15.0
322}
323
324fn default_ios_processing_ceiling_multiplier() -> f64 {
325    4.0
326}
327
328fn default_android_foreground_service_types() -> Vec<String> {
329    vec!["remoteMessaging".into()]
330}
331
332fn default_android_on_timeout() -> String {
333    "notifyUser".into()
334}
335
336fn default_android_notification_channel_id() -> String {
337    "bg_service".into()
338}
339
340fn default_android_notification_channel_name() -> String {
341    "Background Service".into()
342}
343
344fn default_android_notification_id() -> u32 {
345    9001
346}
347
348fn default_true() -> bool {
349    true
350}
351
352fn default_channel_capacity() -> usize {
353    16
354}
355
356#[cfg(feature = "desktop-service")]
357fn default_desktop_service_mode() -> String {
358    "inProcess".into()
359}
360
361#[cfg(feature = "desktop-service")]
362fn default_desktop_service_start_timeout_ms() -> u64 {
363    5000
364}
365
366impl Default for StartConfig {
367    fn default() -> Self {
368        Self {
369            service_label: default_label(),
370            foreground_service_type: default_foreground_service_type(),
371        }
372    }
373}
374
375/// Lifecycle state of the background service.
376///
377/// Exposed via the `get-service-state` command to provide
378/// fine-grained status beyond a simple boolean.
379#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "camelCase")]
381#[non_exhaustive]
382pub enum ServiceState {
383    /// No service has been started, or the last run has fully cleaned up.
384    Idle,
385    /// `init()` is in progress (between `Start` and successful `init()`).
386    Initializing,
387    /// `init()` succeeded; `run()` is executing.
388    Running,
389    /// Service stopped (by `stop()`, natural completion, or error).
390    Stopped,
391}
392
393/// Unified lifecycle state for the background service.
394///
395/// Provides fine-grained visibility into the service's current state,
396/// combining internal state, recovery status, and platform conditions.
397#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399#[non_exhaustive]
400pub enum LifecycleState {
401    /// No service has been started.
402    Idle,
403    /// Service init() is in progress.
404    Starting,
405    /// Service run() is executing.
406    Running,
407    /// Service is being stopped (cancellation requested).
408    Stopping,
409    /// Service has stopped.
410    Stopped,
411    /// Service is recovering after a platform timeout or expiration.
412    Recovering,
413    /// Recovery is pending (waiting for platform conditions).
414    RecoveryPending,
415    /// Background execution window has expired (e.g. iOS BGTask).
416    Expired,
417    /// Service is blocked by a platform issue (e.g. missing permission).
418    Blocked,
419    /// Service encountered an error.
420    Error,
421    /// Core is running but account setup is needed (Android native state).
422    SetupIdle,
423    /// Core is running but account is locked, awaiting unlock (Android native state).
424    LockedIdle,
425}
426
427impl From<ServiceState> for LifecycleState {
428    fn from(state: ServiceState) -> Self {
429        match state {
430            ServiceState::Idle => LifecycleState::Idle,
431            ServiceState::Initializing => LifecycleState::Starting,
432            ServiceState::Running => LifecycleState::Running,
433            ServiceState::Stopped => LifecycleState::Stopped,
434        }
435    }
436}
437
438/// Native platform-side state reported by the OS service layer.
439///
440/// Reflects the state as observed by the Android foreground service, iOS
441/// BGTask handler, or desktop OS-service process — distinct from the
442/// plugin-internal [`ServiceState`].
443#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445#[non_exhaustive]
446pub enum NativeState {
447    Idle,
448    Starting,
449    Running,
450    Stopping,
451    Timeout,
452    Expired,
453    Recovering,
454    Error,
455}
456
457/// Snapshot of the service lifecycle status.
458///
459/// Returned by the `get-service-state` command.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461#[serde(rename_all = "camelCase")]
462pub struct ServiceStatus {
463    /// Current lifecycle state.
464    pub state: ServiceState,
465    /// Last error message, if the service stopped due to an error.
466    pub last_error: Option<String>,
467
468    // --- Extended fields (Step 4) ---
469    /// Whether the service is desired to be running (persisted across restarts).
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub desired_running: Option<bool>,
472    /// Platform-native state as reported by the OS service layer.
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub native_state: Option<NativeState>,
475    /// The lifecycle mechanism in use on the current platform.
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub platform_mode: Option<LifecycleMode>,
478    /// Configuration used for the last successful start.
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub last_start_config: Option<StartConfig>,
481    /// Epoch milliseconds of the last heartbeat received from the service.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub last_heartbeat_at: Option<u64>,
484    /// How many restart attempts have been made since the last clean start.
485    #[serde(skip_serializing_if = "Option::is_none")]
486    pub restart_attempt: Option<u32>,
487    /// Human-readable reason for the current recovery attempt.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub recovery_reason: Option<String>,
490    /// Last platform-specific error message.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub platform_error: Option<String>,
493}
494
495impl Default for ServiceStatus {
496    fn default() -> Self {
497        Self {
498            state: ServiceState::Idle,
499            last_error: None,
500            desired_running: None,
501            native_state: None,
502            platform_mode: None,
503            last_start_config: None,
504            last_heartbeat_at: None,
505            restart_attempt: None,
506            recovery_reason: None,
507            platform_error: None,
508        }
509    }
510}
511
512/// The operating system platform.
513///
514/// Returned by `get_platform_capabilities` to identify the current runtime environment.
515#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "camelCase")]
517#[non_exhaustive]
518pub enum Platform {
519    Android,
520    Ios,
521    Windows,
522    Macos,
523    Linux,
524    Unknown,
525}
526
527/// Severity level for a validation issue.
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
529#[serde(rename_all = "camelCase")]
530#[non_exhaustive]
531pub enum Severity {
532    Error,
533    Warning,
534    Info,
535}
536
537/// The lifecycle mechanism used by the plugin on the current platform.
538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
539#[serde(rename_all = "camelCase")]
540#[non_exhaustive]
541pub enum LifecycleMode {
542    AndroidForegroundService,
543    IosBgTaskScheduler,
544    DesktopInProcess,
545    DesktopOsService,
546}
547
548/// Guarantee level for a specific background execution scenario.
549///
550/// - `Guaranteed`: The platform reliably supports this scenario.
551/// - `BestEffort`: The platform may support this scenario but cannot guarantee it.
552/// - `Unsupported`: The platform does not support this scenario.
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554#[serde(rename_all = "camelCase")]
555#[non_exhaustive]
556pub enum LifecycleGuarantee {
557    Guaranteed,
558    BestEffort,
559    Unsupported,
560}
561
562/// Platform-specific background execution capabilities.
563///
564/// Returned by the `get_platform_capabilities` Tauri command. Provides honest
565/// reporting of what each platform can guarantee for background service survival.
566#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
567#[serde(rename_all = "camelCase")]
568#[non_exhaustive]
569pub struct PlatformCapabilities {
570    pub platform: Platform,
571    pub lifecycle_mode: LifecycleMode,
572    pub survives_app_close: LifecycleGuarantee,
573    pub survives_reboot: LifecycleGuarantee,
574    pub survives_force_quit: LifecycleGuarantee,
575    pub background_execution: LifecycleGuarantee,
576    pub limitations: Vec<String>,
577    pub required_setup: Vec<String>,
578}
579
580/// OS service install/running state.
581///
582/// Reported by [`OsServiceStatus`] to indicate whether the OS-level service
583/// (systemd, launchd, etc.) is installed and/or currently running.
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
585#[serde(rename_all = "camelCase")]
586#[non_exhaustive]
587pub enum OsServiceInstallState {
588    /// The OS service is not installed.
589    NotInstalled,
590    /// The OS service is installed but not currently running.
591    Installed,
592    /// The OS service is installed and currently running.
593    Running,
594}
595
596/// Snapshot of an OS-level service's status.
597///
598/// Returned by `get_os_service_status` to report the state of the desktop
599/// OS service (systemd user service, launchd agent).
600#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
601#[serde(rename_all = "camelCase")]
602#[non_exhaustive]
603pub struct OsServiceStatus {
604    /// The service label (e.g. `com.example.background-service`).
605    pub label: String,
606    /// The service manager kind (e.g. `systemd`, `launchd`).
607    pub mode: String,
608    /// Whether the service is installed and/or running.
609    pub installed: OsServiceInstallState,
610    /// Whether the IPC connection to the service sidecar is active.
611    pub ipc_connected: bool,
612    /// Path to the Unix domain socket used for IPC.
613    #[serde(skip_serializing_if = "Option::is_none")]
614    pub socket_path: Option<String>,
615    /// Last error message from the OS service, if any.
616    #[serde(skip_serializing_if = "Option::is_none")]
617    pub last_error: Option<String>,
618}
619
620/// Reason why the background service stopped.
621///
622/// Structured stop reasons that distinguish between user-initiated stops,
623/// platform-imposed terminations, and natural task completion.
624#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
625#[serde(rename_all = "camelCase")]
626#[non_exhaustive]
627pub enum StopReason {
628    /// User called stopService().
629    UserStop,
630    /// Application is shutting down gracefully.
631    AppStop,
632    /// Platform killed the service due to a timeout (e.g. Android FGS timeout).
633    PlatformTimeout,
634    /// Platform expired the background execution window (e.g. iOS BGTask).
635    PlatformExpiration,
636    /// User pressed stop on the native notification.
637    NativeNotificationStop,
638    /// OS restarted the service after a reboot.
639    OsRestart,
640    /// Service recovered after device boot.
641    BootRecovery,
642    /// Service's run() returned Ok(()) naturally.
643    TaskCompleted,
644    /// Service's run() returned an error.
645    Error,
646    /// The host process is exiting (e.g. iOS app backgrounded/terminated by the
647    /// OS). Not a user stop: desired state and BGTask scheduling are preserved so
648    /// recovery can resume background delivery (H2).
649    ProcessExit,
650}
651
652impl<'de> serde::Deserialize<'de> for StopReason {
653    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
654        let s = String::deserialize(deserializer)?;
655        match s.as_str() {
656            "userStop" => Ok(Self::UserStop),
657            "appStop" => Ok(Self::AppStop),
658            "platformTimeout" => Ok(Self::PlatformTimeout),
659            "platformExpiration" => Ok(Self::PlatformExpiration),
660            "nativeNotificationStop" => Ok(Self::NativeNotificationStop),
661            "osRestart" => Ok(Self::OsRestart),
662            "bootRecovery" => Ok(Self::BootRecovery),
663            "taskCompleted" => Ok(Self::TaskCompleted),
664            "error" => Ok(Self::Error),
665            "processExit" => Ok(Self::ProcessExit),
666            // Legacy string mappings for backward compatibility
667            "completed" => Ok(Self::TaskCompleted),
668            "cancelled" | "user" => Ok(Self::UserStop),
669            _ => Err(serde::de::Error::unknown_variant(
670                &s,
671                &[
672                    "userStop",
673                    "appStop",
674                    "platformTimeout",
675                    "platformExpiration",
676                    "nativeNotificationStop",
677                    "osRestart",
678                    "bootRecovery",
679                    "taskCompleted",
680                    "error",
681                    "processExit",
682                ],
683            )),
684        }
685    }
686}
687
688/// Native platform lifecycle events sent from Kotlin/Swift to the Rust actor.
689///
690/// These events originate in the native layer and are forwarded to the actor
691/// via [`ManagerCommand::NativeLifecycleEvent`](crate::manager::ManagerCommand).
692/// The actor maps each variant to the appropriate [`StopReason`].
693#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(rename_all = "camelCase", tag = "type")]
695#[non_exhaustive]
696pub enum NativeLifecycleEvent {
697    /// User pressed stop on the Android foreground service notification.
698    AndroidNotificationStop,
699    /// Android system killed the foreground service due to a timeout.
700    AndroidTimeout {
701        /// The foreground service type that timed out (e.g. "dataSync").
702        #[serde(skip_serializing_if = "Option::is_none")]
703        fgs_type: Option<String>,
704    },
705    /// Android restarted the service (START_STICKY) and the core start was
706    /// accepted — background delivery is restored.
707    AndroidOsRestartAccepted,
708    /// The service was recovered after device boot or package replacement
709    /// and the core start was accepted.
710    AndroidBootRecoveryAccepted,
711    /// The iOS `BGTask` execution window expired (the OS invoked the task's
712    /// expiration handler). Maps to [`StopReason::PlatformExpiration`]: the
713    /// BGTask schedule and desired state are preserved so recovery resumes
714    /// background delivery on the next window (H6).
715    IosBgTaskExpired,
716}
717
718impl NativeLifecycleEvent {
719    /// Map this native event to the corresponding [`StopReason`].
720    pub fn to_stop_reason(&self) -> StopReason {
721        match self {
722            Self::AndroidNotificationStop => StopReason::NativeNotificationStop,
723            Self::AndroidTimeout { .. } => StopReason::PlatformTimeout,
724            Self::AndroidOsRestartAccepted => StopReason::OsRestart,
725            Self::AndroidBootRecoveryAccepted => StopReason::BootRecovery,
726            Self::IosBgTaskExpired => StopReason::PlatformExpiration,
727        }
728    }
729
730    /// `true` for start-ACCEPTANCE events: the native layer restarted the
731    /// service and the core start succeeded. These are not stops — the
732    /// manager fires the recovery notification instead of tearing down.
733    pub fn is_recovery_acceptance(&self) -> bool {
734        matches!(
735            self,
736            Self::AndroidOsRestartAccepted | Self::AndroidBootRecoveryAccepted
737        )
738    }
739}
740
741/// Built-in event types emitted by the runner itself.
742#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase", tag = "type")]
744#[non_exhaustive]
745pub enum PluginEvent {
746    /// init() completed successfully
747    Started,
748    /// run() returned or was cancelled
749    Stopped { reason: StopReason },
750    /// init() or run() returned an error
751    Error { message: String },
752}
753
754impl Default for PluginConfig {
755    fn default() -> Self {
756        Self {
757            ios_safety_timeout_secs: default_ios_safety_timeout(),
758            ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
759            ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
760            ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
761            ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
762            ios_requires_external_power: false,
763            ios_requires_network_connectivity: false,
764            ios_processing_ceiling_multiplier: default_ios_processing_ceiling_multiplier(),
765            channel_capacity: default_channel_capacity(),
766            android_foreground_service_types: default_android_foreground_service_types(),
767            android_validate_foreground_service_type: default_true(),
768            android_on_timeout: default_android_on_timeout(),
769            android_notification_channel_id: default_android_notification_channel_id(),
770            android_notification_channel_name: default_android_notification_channel_name(),
771            android_notification_id: default_android_notification_id(),
772            android_notification_small_icon: None,
773            android_show_stop_action: default_true(),
774            android_request_notification_permission_on_load: false,
775            notify_on_timeout: false,
776            notify_on_recovery: false,
777            #[cfg(feature = "desktop-service")]
778            desktop_service_mode: default_desktop_service_mode(),
779            #[cfg(feature = "desktop-service")]
780            desktop_service_label: None,
781            #[cfg(feature = "desktop-service")]
782            desktop_service_autostart: false,
783            #[cfg(feature = "desktop-service")]
784            desktop_start_service_if_missing: false,
785            #[cfg(feature = "desktop-service")]
786            desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
787            #[cfg(feature = "desktop-service")]
788            desktop_windows_daemon_opt_in: false,
789        }
790    }
791}
792
793/// Arguments sent to the native `startKeepalive` handler.
794///
795/// Lives in `models.rs` (not `mobile.rs`) so serde tests run on all platforms.
796#[derive(Debug, Serialize)]
797#[serde(rename_all = "camelCase")]
798#[allow(dead_code)]
799pub(crate) struct StartKeepaliveArgs<'a> {
800    pub label: &'a str,
801    pub foreground_service_type: &'a str,
802    /// iOS safety timeout in seconds. Only sent to iOS; `None` omits the key.
803    #[serde(skip_serializing_if = "Option::is_none")]
804    pub ios_safety_timeout_secs: Option<f64>,
805    /// iOS BGProcessingTask safety timeout in seconds. Only sent to iOS; `None` omits the key.
806    /// When `Some(positive)`, caps the processing task duration.
807    #[serde(skip_serializing_if = "Option::is_none")]
808    pub ios_processing_safety_timeout_secs: Option<f64>,
809    /// iOS BGAppRefreshTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
810    #[serde(skip_serializing_if = "Option::is_none")]
811    pub ios_earliest_refresh_begin_minutes: Option<f64>,
812    /// iOS BGProcessingTask earliest begin date in minutes. Only sent to iOS; `None` omits the key.
813    #[serde(skip_serializing_if = "Option::is_none")]
814    pub ios_earliest_processing_begin_minutes: Option<f64>,
815    /// iOS BGProcessingTask requires external power. Only sent to iOS; `None` omits the key.
816    #[serde(skip_serializing_if = "Option::is_none")]
817    pub ios_requires_external_power: Option<bool>,
818    /// iOS BGProcessingTask requires network connectivity. Only sent to iOS; `None` omits the key.
819    #[serde(skip_serializing_if = "Option::is_none")]
820    pub ios_requires_network_connectivity: Option<bool>,
821    /// iOS adaptive processing ceiling multiplier. Only sent to iOS; `None` omits the key.
822    #[serde(skip_serializing_if = "Option::is_none")]
823    pub ios_processing_ceiling_multiplier: Option<f64>,
824}
825
826/// Information about a pending iOS background task that launched the app.
827///
828/// Returned by `getPendingBgTask()` on iOS when the app was launched by iOS
829/// for a background task (BGAppRefreshTask or BGProcessingTask). Used by the
830/// Rust auto-start logic to detect OS-initiated launches and automatically
831/// start the service if `desired_running` is true.
832#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
833#[serde(rename_all = "camelCase")]
834#[non_exhaustive]
835pub struct PendingTaskInfo {
836    /// The kind of background task: "refresh" or "processing".
837    pub task_kind: String,
838    /// The task identifier (e.g. "com.example.app.bg-refresh").
839    pub identifier: String,
840    /// Epoch timestamp (seconds) when the task was received by the native layer.
841    pub received_at: f64,
842    /// Epoch timestamp (seconds) when the pending task was consumed by the Rust
843    /// auto-start logic. `None` until `clear_pending_bg_task` is called.
844    #[serde(default)]
845    pub consumed_at: Option<f64>,
846}
847
848impl PendingTaskInfo {
849    /// Resolve a raw `getPendingBgTask` payload to an *active* pending task.
850    ///
851    /// Enforces the H5/M14 invariant `pending ⟺ consumedAt == nil`: a payload
852    /// with a null `taskKind` (no record) **or** a non-null `consumedAt`
853    /// (already consumed/stale) yields `None`; only a fresh, unconsumed record
854    /// is surfaced. This mirrors the Swift `getPendingBgTask` gate on the Rust
855    /// side so a consumed record can't re-arm a cold auto-start even if the
856    /// native payload still carries the keys — gating on `consumed_at`, not
857    /// only `taskKind.is_null()`.
858    pub fn from_pending_payload(
859        value: &serde_json::Value,
860    ) -> Result<Option<Self>, serde_json::Error> {
861        if value
862            .get("taskKind")
863            .map_or(true, serde_json::Value::is_null)
864        {
865            return Ok(None);
866        }
867        let info: Self = serde_json::from_value(value.clone())?;
868        if info.consumed_at.is_some() {
869            return Ok(None);
870        }
871        Ok(Some(info))
872    }
873}
874
875/// iOS scheduling status returned by the native layer.
876///
877/// Reports which background task types were successfully scheduled
878/// and any errors that occurred during scheduling. Returned by
879/// `get_scheduling_status` and parsed from `startKeepalive` on iOS.
880#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
881#[serde(rename_all = "camelCase")]
882#[non_exhaustive]
883pub struct IOSSchedulingStatus {
884    /// Whether a `BGAppRefreshTask` was successfully scheduled.
885    pub refresh_scheduled: bool,
886    /// Whether a `BGProcessingTask` was successfully scheduled.
887    pub processing_scheduled: bool,
888    /// Error from `BGAppRefreshTask` scheduling, if any.
889    #[serde(default)]
890    #[serde(skip_serializing_if = "Option::is_none")]
891    pub refresh_error: Option<String>,
892    /// Error from `BGProcessingTask` scheduling, if any.
893    #[serde(default)]
894    #[serde(skip_serializing_if = "Option::is_none")]
895    pub processing_error: Option<String>,
896}
897
898/// Persisted iOS desired-state facts, queried from the native layer.
899///
900/// Returned by `getDesiredStateStatus` on iOS — the *persisted* half of the
901/// status split (C1). Distinct from [`IOSSchedulingStatus`], which carries the
902/// *submit-result* facts of the most recent scheduling attempt. The iOS
903/// auto-start path reads `desired_running` + `last_start_config` from this DTO
904/// (replacing the deleted untyped `get_scheduling_status_raw` bypass).
905///
906/// All fields except `desired_running` are genuinely optional: Swift resolves
907/// them as `NSNull()` (JSON `null`) before any task has run, so they carry
908/// `#[serde(default)]`. Timestamps are wall-clock seconds (`TimeInterval`),
909/// which serialize as JSON floats.
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
911#[serde(rename_all = "camelCase")]
912#[non_exhaustive]
913pub struct IOSDesiredStateStatus {
914    /// Whether the user desires the service running (persisted across launches).
915    pub desired_running: bool,
916    /// JSON-serialized `StartConfig` of the last successful start, if any.
917    #[serde(default)]
918    #[serde(skip_serializing_if = "Option::is_none")]
919    pub last_start_config: Option<String>,
920    /// Kind of the most recent BGTask: "refresh" or "processing".
921    #[serde(default)]
922    #[serde(skip_serializing_if = "Option::is_none")]
923    pub last_task_kind: Option<String>,
924    /// Wall-clock seconds when the most recent BGTask started, if any.
925    #[serde(default)]
926    #[serde(skip_serializing_if = "Option::is_none")]
927    pub last_task_started_at: Option<f64>,
928    /// Wall-clock seconds when the most recent BGTask completed, if any.
929    #[serde(default)]
930    #[serde(skip_serializing_if = "Option::is_none")]
931    pub last_task_completed_at: Option<f64>,
932    /// Last persisted scheduling error, if any.
933    #[serde(default)]
934    #[serde(skip_serializing_if = "Option::is_none")]
935    pub last_schedule_error: Option<String>,
936    /// Durable reason the most recent BGTask run ended ("completed" | "expired"),
937    /// persisted under `ios_last_completion_reason`. Unlike the one-shot
938    /// adaptation outcome (`ios_last_task_outcome`, consumed by `scheduleNext`),
939    /// this is never wiped, so the "why did the last run end?" question (M7) is
940    /// always answerable.
941    #[serde(default)]
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub last_completion_reason: Option<String>,
944    /// Whether notification authorization was granted on iOS (M4), persisted under
945    /// `ios_notification_granted`. The deferred authorization request (service
946    /// start, not plugin load) forwards its decision here so the Notifier can
947    /// degrade when notifications are denied. `None` until the first
948    /// notification-requiring intent has requested authorization.
949    #[serde(default)]
950    #[serde(skip_serializing_if = "Option::is_none")]
951    pub notification_granted: Option<bool>,
952}
953
954/// Notification-permission status (NTF-09), returned by the Android
955/// `getNotificationPermissionStatus` / `requestNotificationPermission` Kotlin
956/// commands. A permissive `status` string accommodates both vocabularies:
957/// `getNotificationPermissionStatus` resolves `granted` | `notDetermined` |
958/// `denied`, while the `requestNotificationPermission` `@PermissionCallback`
959/// (Step 10a) resolves `granted` | `denied`.
960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
961pub struct NotificationPermissionStatus {
962    /// Current POST_NOTIFICATIONS authorization.
963    pub status: String,
964}
965
966/// A single setup issue found during validation.
967///
968/// Part of [`SetupValidationReport`]. Each issue has a machine-readable code,
969/// a human-readable message, the platform it applies to, and an optional fix.
970#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
971#[serde(rename_all = "camelCase")]
972#[non_exhaustive]
973pub struct SetupIssue {
974    /// Machine-readable error code (e.g. "android_fgs_type").
975    pub code: String,
976    /// Human-readable description of the issue.
977    pub message: String,
978    /// The platform this issue applies to.
979    pub platform: Platform,
980    /// Suggested fix for the issue.
981    #[serde(skip_serializing_if = "Option::is_none")]
982    pub fix: Option<String>,
983}
984
985impl SetupIssue {
986    /// Convert this `SetupIssue` into a [`ValidationIssue`] with the given severity.
987    pub fn to_validation_issue(&self, severity: Severity) -> ValidationIssue {
988        ValidationIssue {
989            severity,
990            code: self.code.clone(),
991            message: self.message.clone(),
992            fix: self.fix.clone(),
993            platform: self.platform,
994        }
995    }
996}
997
998/// Result of validating background service setup prerequisites.
999///
1000/// Returned by `validateBackgroundServiceSetup()`. Contains `errors` (blocking
1001/// issues that prevent the service from working) and `warnings` (non-blocking
1002/// issues that may cause degraded behavior).
1003#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1004#[serde(rename_all = "camelCase")]
1005#[non_exhaustive]
1006pub struct SetupValidationReport {
1007    /// `true` when `errors` is empty (warnings do not affect this).
1008    pub ok: bool,
1009    /// Blocking issues that prevent the service from working correctly.
1010    pub errors: Vec<SetupIssue>,
1011    /// Non-blocking issues that may cause degraded behavior.
1012    pub warnings: Vec<SetupIssue>,
1013    /// Unified issues with typed severity.
1014    ///
1015    /// Combines `errors` (as [`Severity::Error`]) and `warnings` (as
1016    /// [`Severity::Warning`]) into a single list. Populated automatically
1017    /// by [`crate::validator::SetupValidator::validate`].
1018    #[serde(default)]
1019    pub issues: Vec<ValidationIssue>,
1020}
1021
1022/// A single validation issue found during lifecycle validation.
1023///
1024/// Each issue has a severity level, machine-readable code, human-readable
1025/// message, optional fix suggestion, and the platform it applies to.
1026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1027#[serde(rename_all = "camelCase")]
1028#[non_exhaustive]
1029pub struct ValidationIssue {
1030    pub severity: Severity,
1031    pub code: String,
1032    pub message: String,
1033    #[serde(skip_serializing_if = "Option::is_none")]
1034    pub fix: Option<String>,
1035    pub platform: Platform,
1036}
1037
1038/// Complete snapshot of the background service lifecycle status.
1039///
1040/// Provides a unified view of service state, desired state, recovery status,
1041/// platform capabilities, and validation issues.
1042#[derive(Debug, Clone, Serialize, Deserialize)]
1043#[serde(rename_all = "camelCase")]
1044#[non_exhaustive]
1045pub struct LifecycleStatus {
1046    pub state: LifecycleState,
1047    pub desired_running: bool,
1048    pub recovery_enabled: bool,
1049    pub recovery_pending: bool,
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    pub recovery_reason: Option<String>,
1052    #[serde(skip_serializing_if = "Option::is_none")]
1053    pub last_start_config: Option<StartConfig>,
1054    #[serde(skip_serializing_if = "Option::is_none")]
1055    pub last_platform_state: Option<String>,
1056    #[serde(skip_serializing_if = "Option::is_none")]
1057    pub last_platform_error: Option<String>,
1058    #[serde(skip_serializing_if = "Option::is_none")]
1059    pub last_error: Option<String>,
1060    pub platform: Platform,
1061    pub capabilities: PlatformCapabilities,
1062    pub issues: Vec<ValidationIssue>,
1063    #[serde(skip_serializing_if = "Option::is_none")]
1064    pub native_running: Option<bool>,
1065    #[serde(skip_serializing_if = "Option::is_none")]
1066    pub native_foreground: Option<bool>,
1067    #[serde(skip_serializing_if = "Option::is_none")]
1068    pub adopted: Option<bool>,
1069    #[serde(skip_serializing_if = "Option::is_none")]
1070    pub degraded: Option<bool>,
1071    #[serde(skip_serializing_if = "Option::is_none")]
1072    pub degraded_reason: Option<String>,
1073    #[serde(skip_serializing_if = "Option::is_none")]
1074    pub data_dir: Option<String>,
1075}
1076
1077/// Android native service state, bridged from Kotlin via `getAndroidServiceState`.
1078///
1079/// Mirrors the Kotlin `AndroidServiceState` data class field-for-field.
1080/// Only populated on Android (gated by `self.mobile.is_some()`).
1081#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1082#[serde(rename_all = "camelCase")]
1083#[non_exhaustive]
1084pub struct AndroidServiceState {
1085    pub native_running: bool,
1086    pub native_foreground: bool,
1087    pub desired_running: bool,
1088    pub durable_state: String,
1089    #[serde(skip_serializing_if = "Option::is_none")]
1090    pub service_label: Option<String>,
1091    #[serde(skip_serializing_if = "Option::is_none")]
1092    pub foreground_service_type: Option<String>,
1093    #[serde(skip_serializing_if = "Option::is_none")]
1094    pub notification_id: Option<i64>,
1095    #[serde(skip_serializing_if = "Option::is_none")]
1096    pub notification_channel_id: Option<String>,
1097    pub recovery_pending: bool,
1098    #[serde(skip_serializing_if = "Option::is_none")]
1099    pub recovery_reason: Option<String>,
1100    #[serde(skip_serializing_if = "Option::is_none")]
1101    pub last_platform_error: Option<String>,
1102    pub data_dir: String,
1103}
1104
1105/// iOS native background-task state, assembled from the typed iOS status
1106/// queries (`getSchedulingStatus`, `getDesiredStateStatus`, `getPendingBgTask`).
1107///
1108/// Unlike Android — which runs a long-lived foreground service whose
1109/// `native_running` bit is authoritative — iOS has no persistent native
1110/// service: a `BGTask` executes for a short window and the Rust actor is the
1111/// runtime authority during it. This snapshot therefore reports *scheduling /
1112/// budget health* facts (H6) so `build_lifecycle_status` can surface the real
1113/// native situation (a pending task waiting, a scheduling error, an exhausted
1114/// budget) instead of relying on the actor's possibly-stale in-memory state.
1115/// Only populated on iOS (the mobile bridge tags it via `query_native_state`).
1116#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1117#[serde(rename_all = "camelCase")]
1118#[non_exhaustive]
1119pub struct IosNativeState {
1120    /// Persisted desired-running intent (mirror of the Rust-backed authority).
1121    /// Answers the "desired?" status question.
1122    pub desired_running: bool,
1123    /// Whether a `BGAppRefreshTask` is currently scheduled (submit succeeded on
1124    /// the most recent attempt). Part of the "scheduled?" answer (M7).
1125    #[serde(default)]
1126    pub refresh_scheduled: bool,
1127    /// Whether a `BGProcessingTask` is currently scheduled. Part of the
1128    /// "scheduled?" answer (M7).
1129    #[serde(default)]
1130    pub processing_scheduled: bool,
1131    /// Kind ("refresh"/"processing") of the BGTask currently executing, or
1132    /// `None` when no task is running (idle / expired / between windows).
1133    /// Answers the "active?" question.
1134    #[serde(default)]
1135    #[serde(skip_serializing_if = "Option::is_none")]
1136    pub active_task_kind: Option<String>,
1137    /// An unconsumed pending BGTask that launched / woke the app, if any.
1138    /// Answers the "pending?" question.
1139    #[serde(default)]
1140    #[serde(skip_serializing_if = "Option::is_none")]
1141    pub pending_task: Option<PendingTaskInfo>,
1142    /// Wall-clock seconds when the most recent BGTask completed, if any.
1143    /// Answers the "last-completed?" question.
1144    #[serde(default)]
1145    #[serde(skip_serializing_if = "Option::is_none")]
1146    pub last_completed_at: Option<f64>,
1147    /// Durable reason the most recent BGTask run ended ("completed" | "expired").
1148    /// Answers the "why?" question (M7). Unlike the one-shot adaptation outcome,
1149    /// this survives the next `scheduleNext()` so the last completion is always
1150    /// reportable.
1151    #[serde(default)]
1152    #[serde(skip_serializing_if = "Option::is_none")]
1153    pub last_completion_reason: Option<String>,
1154    /// Error from the most recent `BGAppRefreshTask` scheduling attempt, if any.
1155    /// Half of the split "last-failed?" answer (M7) — kept distinct from the
1156    /// processing error rather than collapsed into one aggregate.
1157    #[serde(default)]
1158    #[serde(skip_serializing_if = "Option::is_none")]
1159    pub last_refresh_error: Option<String>,
1160    /// Error from the most recent `BGProcessingTask` scheduling attempt, if any.
1161    /// The other half of the split "last-failed?" answer (M7).
1162    #[serde(default)]
1163    #[serde(skip_serializing_if = "Option::is_none")]
1164    pub last_processing_error: Option<String>,
1165    /// Whether the app still has background-execution budget. `false` means iOS
1166    /// is unlikely to run further BGTasks (surfaced as degraded).
1167    pub in_budget: bool,
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::*;
1173
1174    // --- Default foreground service type tests ---
1175
1176    #[test]
1177    fn default_foreground_service_type_is_remote_messaging() {
1178        assert_eq!(default_foreground_service_type(), "remoteMessaging");
1179    }
1180
1181    #[test]
1182    fn default_android_foreground_service_types_is_remote_messaging() {
1183        assert_eq!(
1184            default_android_foreground_service_types(),
1185            vec!["remoteMessaging"]
1186        );
1187    }
1188
1189    #[test]
1190    fn start_config_default_uses_remote_messaging() {
1191        let config = StartConfig::default();
1192        assert_eq!(config.foreground_service_type, "remoteMessaging");
1193    }
1194
1195    // --- StartConfig tests ---
1196
1197    #[test]
1198    fn start_config_default_label() {
1199        let config = StartConfig::default();
1200        assert_eq!(config.service_label, "Service running");
1201    }
1202
1203    #[test]
1204    fn start_config_custom_label() {
1205        let config = StartConfig {
1206            service_label: "Syncing data".into(),
1207            ..Default::default()
1208        };
1209        assert_eq!(config.service_label, "Syncing data");
1210    }
1211
1212    #[test]
1213    fn start_config_serde_roundtrip_default() {
1214        let config = StartConfig::default();
1215        let json = serde_json::to_string(&config).unwrap();
1216        let de: StartConfig = serde_json::from_str(&json).unwrap();
1217        assert_eq!(de.service_label, config.service_label);
1218    }
1219
1220    #[test]
1221    fn start_config_serde_roundtrip_custom() {
1222        let config = StartConfig {
1223            service_label: "My service".into(),
1224            ..Default::default()
1225        };
1226        let json = serde_json::to_string(&config).unwrap();
1227        let de: StartConfig = serde_json::from_str(&json).unwrap();
1228        assert_eq!(de.service_label, "My service");
1229    }
1230
1231    #[test]
1232    fn start_config_deserialize_missing_field_uses_default() {
1233        // An empty JSON object should produce the default label
1234        let json = "{}";
1235        let de: StartConfig = serde_json::from_str(json).unwrap();
1236        assert_eq!(de.service_label, "Service running");
1237    }
1238
1239    #[test]
1240    fn start_config_json_key_is_camel_case() {
1241        let config = StartConfig {
1242            service_label: "test".into(),
1243            ..Default::default()
1244        };
1245        let json = serde_json::to_string(&config).unwrap();
1246        assert!(
1247            json.contains("serviceLabel"),
1248            "JSON should use camelCase: {json}"
1249        );
1250    }
1251
1252    // --- StartConfig alias tests ---
1253
1254    #[test]
1255    fn start_config_legacy_label_alias_decodes() {
1256        let json = r#"{"label":"Legacy name"}"#;
1257        let de: StartConfig = serde_json::from_str(json).unwrap();
1258        assert_eq!(de.service_label, "Legacy name");
1259    }
1260
1261    #[test]
1262    fn start_config_both_label_and_service_label_rejected() {
1263        let json = r#"{"serviceLabel":"New name","label":"Old name"}"#;
1264        let result = serde_json::from_str::<StartConfig>(json);
1265        assert!(result.is_err(), "should reject duplicate field via alias");
1266    }
1267
1268    #[test]
1269    fn start_config_unknown_fields_ignored() {
1270        let json = r#"{"serviceLabel":"test","unknownField":42,"extra":"data"}"#;
1271        let de: StartConfig = serde_json::from_str(json).unwrap();
1272        assert_eq!(de.service_label, "test");
1273        assert_eq!(de.foreground_service_type, "remoteMessaging");
1274    }
1275
1276    #[test]
1277    fn start_config_camel_case_key_still_works() {
1278        let json = r#"{"serviceLabel":"Modern name"}"#;
1279        let de: StartConfig = serde_json::from_str(json).unwrap();
1280        assert_eq!(de.service_label, "Modern name");
1281    }
1282
1283    // --- PluginEvent tests ---
1284
1285    #[test]
1286    fn plugin_event_started_serde_roundtrip() {
1287        let event = PluginEvent::Started;
1288        let json = serde_json::to_string(&event).unwrap();
1289        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1290        assert!(matches!(de, PluginEvent::Started));
1291    }
1292
1293    #[test]
1294    fn plugin_event_stopped_serde_roundtrip() {
1295        let event = PluginEvent::Stopped {
1296            reason: StopReason::UserStop,
1297        };
1298        let json = serde_json::to_string(&event).unwrap();
1299        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1300        match de {
1301            PluginEvent::Stopped { reason } => assert_eq!(reason, StopReason::UserStop),
1302            other => panic!("Expected Stopped, got {other:?}"),
1303        }
1304    }
1305
1306    #[test]
1307    fn plugin_event_error_serde_roundtrip() {
1308        let event = PluginEvent::Error {
1309            message: "init failed".into(),
1310        };
1311        let json = serde_json::to_string(&event).unwrap();
1312        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1313        match de {
1314            PluginEvent::Error { message } => assert_eq!(message, "init failed"),
1315            other => panic!("Expected Error, got {other:?}"),
1316        }
1317    }
1318
1319    #[test]
1320    fn plugin_event_tagged_json_format() {
1321        let event = PluginEvent::Started;
1322        let json = serde_json::to_string(&event).unwrap();
1323        assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
1324    }
1325
1326    #[test]
1327    fn plugin_event_stopped_json_keys_camel_case() {
1328        let event = PluginEvent::Stopped {
1329            reason: StopReason::TaskCompleted,
1330        };
1331        let json = serde_json::to_string(&event).unwrap();
1332        assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
1333        assert!(
1334            json.contains("\"reason\":\"taskCompleted\""),
1335            "Reason: {json}"
1336        );
1337    }
1338
1339    #[test]
1340    fn plugin_event_error_json_keys_camel_case() {
1341        let event = PluginEvent::Error {
1342            message: "oops".into(),
1343        };
1344        let json = serde_json::to_string(&event).unwrap();
1345        assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
1346        assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
1347    }
1348
1349    // --- StopReason tests ---
1350
1351    #[test]
1352    fn stop_reason_all_variants_serialize_to_camel_case() {
1353        assert_eq!(
1354            serde_json::to_string(&StopReason::UserStop).unwrap(),
1355            "\"userStop\""
1356        );
1357        assert_eq!(
1358            serde_json::to_string(&StopReason::AppStop).unwrap(),
1359            "\"appStop\""
1360        );
1361        assert_eq!(
1362            serde_json::to_string(&StopReason::PlatformTimeout).unwrap(),
1363            "\"platformTimeout\""
1364        );
1365        assert_eq!(
1366            serde_json::to_string(&StopReason::PlatformExpiration).unwrap(),
1367            "\"platformExpiration\""
1368        );
1369        assert_eq!(
1370            serde_json::to_string(&StopReason::NativeNotificationStop).unwrap(),
1371            "\"nativeNotificationStop\""
1372        );
1373        assert_eq!(
1374            serde_json::to_string(&StopReason::OsRestart).unwrap(),
1375            "\"osRestart\""
1376        );
1377        assert_eq!(
1378            serde_json::to_string(&StopReason::BootRecovery).unwrap(),
1379            "\"bootRecovery\""
1380        );
1381        assert_eq!(
1382            serde_json::to_string(&StopReason::TaskCompleted).unwrap(),
1383            "\"taskCompleted\""
1384        );
1385        assert_eq!(
1386            serde_json::to_string(&StopReason::Error).unwrap(),
1387            "\"error\""
1388        );
1389        assert_eq!(
1390            serde_json::to_string(&StopReason::ProcessExit).unwrap(),
1391            "\"processExit\""
1392        );
1393    }
1394
1395    #[test]
1396    fn stop_reason_roundtrip_all_variants() {
1397        for variant in [
1398            StopReason::UserStop,
1399            StopReason::AppStop,
1400            StopReason::PlatformTimeout,
1401            StopReason::PlatformExpiration,
1402            StopReason::NativeNotificationStop,
1403            StopReason::OsRestart,
1404            StopReason::BootRecovery,
1405            StopReason::TaskCompleted,
1406            StopReason::Error,
1407            StopReason::ProcessExit,
1408        ] {
1409            let json = serde_json::to_string(&variant).unwrap();
1410            let de: StopReason = serde_json::from_str(&json).unwrap();
1411            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
1412        }
1413    }
1414
1415    #[test]
1416    fn stop_reason_process_exit_deserializes_from_camel_case() {
1417        // H2: OS-driven process exit is a distinct, non-user stop reason.
1418        let de: StopReason = serde_json::from_str("\"processExit\"").unwrap();
1419        assert_eq!(de, StopReason::ProcessExit);
1420    }
1421
1422    #[test]
1423    fn stop_reason_legacy_completed_maps_to_task_completed() {
1424        let json = "\"completed\"";
1425        let de: StopReason = serde_json::from_str(json).unwrap();
1426        assert_eq!(de, StopReason::TaskCompleted);
1427    }
1428
1429    #[test]
1430    fn stop_reason_legacy_cancelled_maps_to_user_stop() {
1431        let json = "\"cancelled\"";
1432        let de: StopReason = serde_json::from_str(json).unwrap();
1433        assert_eq!(de, StopReason::UserStop);
1434    }
1435
1436    #[test]
1437    fn stop_reason_legacy_user_maps_to_user_stop() {
1438        let json = "\"user\"";
1439        let de: StopReason = serde_json::from_str(json).unwrap();
1440        assert_eq!(de, StopReason::UserStop);
1441    }
1442
1443    #[test]
1444    fn stop_reason_unknown_variant_returns_error() {
1445        let json = "\"unknownReason\"";
1446        let result = serde_json::from_str::<StopReason>(json);
1447        assert!(
1448            result.is_err(),
1449            "unknown variant should fail to deserialize"
1450        );
1451    }
1452
1453    // --- NativeLifecycleEvent tests ---
1454
1455    #[test]
1456    fn native_lifecycle_event_android_notification_stop_roundtrip() {
1457        let event = NativeLifecycleEvent::AndroidNotificationStop;
1458        let json = serde_json::to_string(&event).unwrap();
1459        assert_eq!(json, r#"{"type":"androidNotificationStop"}"#);
1460        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1461        assert_eq!(de, event);
1462    }
1463
1464    #[test]
1465    fn native_lifecycle_event_android_timeout_roundtrip() {
1466        let event = NativeLifecycleEvent::AndroidTimeout {
1467            fgs_type: Some("dataSync".into()),
1468        };
1469        let json = serde_json::to_string(&event).unwrap();
1470        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1471        assert_eq!(de, event);
1472    }
1473
1474    #[test]
1475    fn native_lifecycle_event_android_timeout_without_fgs_type() {
1476        let event = NativeLifecycleEvent::AndroidTimeout { fgs_type: None };
1477        let json = serde_json::to_string(&event).unwrap();
1478        // skip_serializing_if means fgs_type is omitted when None
1479        assert!(!json.contains("fgsType"), "{json}");
1480        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1481        assert_eq!(de, event);
1482    }
1483
1484    #[test]
1485    fn native_lifecycle_event_to_stop_reason_mapping() {
1486        assert_eq!(
1487            NativeLifecycleEvent::AndroidNotificationStop.to_stop_reason(),
1488            StopReason::NativeNotificationStop
1489        );
1490        assert_eq!(
1491            NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.to_stop_reason(),
1492            StopReason::PlatformTimeout
1493        );
1494        assert_eq!(
1495            NativeLifecycleEvent::AndroidTimeout {
1496                fgs_type: Some("dataSync".into())
1497            }
1498            .to_stop_reason(),
1499            StopReason::PlatformTimeout
1500        );
1501    }
1502
1503    #[test]
1504    fn native_lifecycle_event_recovery_acceptance_roundtrips() {
1505        let event = NativeLifecycleEvent::AndroidOsRestartAccepted;
1506        let json = serde_json::to_string(&event).unwrap();
1507        assert_eq!(json, r#"{"type":"androidOsRestartAccepted"}"#);
1508        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1509        assert_eq!(de, event);
1510
1511        let event = NativeLifecycleEvent::AndroidBootRecoveryAccepted;
1512        let json = serde_json::to_string(&event).unwrap();
1513        assert_eq!(json, r#"{"type":"androidBootRecoveryAccepted"}"#);
1514        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1515        assert_eq!(de, event);
1516    }
1517
1518    #[test]
1519    fn native_lifecycle_event_recovery_acceptance_stop_reasons() {
1520        assert_eq!(
1521            NativeLifecycleEvent::AndroidOsRestartAccepted.to_stop_reason(),
1522            StopReason::OsRestart
1523        );
1524        assert_eq!(
1525            NativeLifecycleEvent::AndroidBootRecoveryAccepted.to_stop_reason(),
1526            StopReason::BootRecovery
1527        );
1528    }
1529
1530    #[test]
1531    fn native_lifecycle_event_recovery_acceptance_classification() {
1532        assert!(NativeLifecycleEvent::AndroidOsRestartAccepted.is_recovery_acceptance());
1533        assert!(NativeLifecycleEvent::AndroidBootRecoveryAccepted.is_recovery_acceptance());
1534        assert!(!NativeLifecycleEvent::AndroidNotificationStop.is_recovery_acceptance());
1535        assert!(!NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.is_recovery_acceptance());
1536    }
1537
1538    /// H6: the iOS BGTask-expiration event maps to `PlatformExpiration` (so the
1539    /// schedule + desired state survive), is not a recovery acceptance, and
1540    /// round-trips through the camelCase wire format.
1541    #[test]
1542    fn native_lifecycle_event_ios_bg_task_expired() {
1543        let event = NativeLifecycleEvent::IosBgTaskExpired;
1544        assert_eq!(event.to_stop_reason(), StopReason::PlatformExpiration);
1545        assert!(!event.is_recovery_acceptance());
1546
1547        let json = serde_json::to_string(&event).unwrap();
1548        assert_eq!(json, r#"{"type":"iosBgTaskExpired"}"#);
1549        let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1550        assert_eq!(de, event);
1551    }
1552
1553    /// H6: `IosNativeState` round-trips through camelCase JSON, including the
1554    /// nested pending task and the optional health fields.
1555    #[test]
1556    fn ios_native_state_serde_roundtrip() {
1557        let state = IosNativeState {
1558            desired_running: true,
1559            refresh_scheduled: true,
1560            processing_scheduled: false,
1561            active_task_kind: Some("refresh".into()),
1562            pending_task: Some(PendingTaskInfo {
1563                task_kind: "processing".into(),
1564                identifier: "com.example.app.bg-processing".into(),
1565                received_at: 1000.0,
1566                consumed_at: None,
1567            }),
1568            last_completed_at: Some(900.0),
1569            last_completion_reason: Some("completed".into()),
1570            last_refresh_error: Some("BGTaskSchedulerErrorDomain code 1".into()),
1571            last_processing_error: None,
1572            in_budget: false,
1573        };
1574        let json = serde_json::to_string(&state).unwrap();
1575        assert!(json.contains("\"desiredRunning\":true"));
1576        assert!(json.contains("\"refreshScheduled\":true"));
1577        assert!(json.contains("\"activeTaskKind\":\"refresh\""));
1578        assert!(json.contains("\"inBudget\":false"));
1579        let de: IosNativeState = serde_json::from_str(&json).unwrap();
1580        assert_eq!(de, state);
1581    }
1582
1583    /// H6: optional health fields default to `None` when absent from the wire.
1584    #[test]
1585    fn ios_native_state_defaults_optional_fields() {
1586        let json = r#"{"desiredRunning":false,"inBudget":true}"#;
1587        let de: IosNativeState = serde_json::from_str(json).unwrap();
1588        assert!(!de.desired_running);
1589        assert!(de.in_budget);
1590        assert!(!de.refresh_scheduled);
1591        assert!(!de.processing_scheduled);
1592        assert_eq!(de.active_task_kind, None);
1593        assert_eq!(de.pending_task, None);
1594        assert_eq!(de.last_completed_at, None);
1595        assert_eq!(de.last_completion_reason, None);
1596        assert_eq!(de.last_refresh_error, None);
1597        assert_eq!(de.last_processing_error, None);
1598    }
1599
1600    /// M7: one `IosNativeState` snapshot answers all seven status questions —
1601    /// desired? scheduled? pending? active? last-completed? last-failed? why?
1602    /// Each question maps to an explicit, non-derived field on the snapshot.
1603    #[test]
1604    fn ios_native_state_answers_seven_questions() {
1605        let state = IosNativeState {
1606            desired_running: true,                    // desired?
1607            refresh_scheduled: true,                  // scheduled?
1608            processing_scheduled: true,               // scheduled?
1609            active_task_kind: Some("refresh".into()), // active?
1610            pending_task: Some(PendingTaskInfo {
1611                // pending?
1612                task_kind: "refresh".into(),
1613                identifier: "com.example.app.bg-refresh".into(),
1614                received_at: 1000.0,
1615                consumed_at: None,
1616            }),
1617            last_completed_at: Some(950.0), // last-completed?
1618            last_completion_reason: Some("completed".into()), // why?
1619            last_refresh_error: Some("refresh boom".into()), // last-failed? (refresh)
1620            last_processing_error: Some("processing boom".into()), // last-failed? (processing)
1621            in_budget: false,
1622        };
1623
1624        // 1 desired?      2 scheduled?                        3 pending?
1625        assert!(state.desired_running);
1626        assert!(state.refresh_scheduled && state.processing_scheduled);
1627        assert!(state.pending_task.is_some());
1628        // 4 active?       5 last-completed?                   7 why?
1629        assert_eq!(state.active_task_kind.as_deref(), Some("refresh"));
1630        assert_eq!(state.last_completed_at, Some(950.0));
1631        assert_eq!(state.last_completion_reason.as_deref(), Some("completed"));
1632        // 6 last-failed? — split per task type, not a single aggregate.
1633        assert_eq!(state.last_refresh_error.as_deref(), Some("refresh boom"));
1634        assert_eq!(
1635            state.last_processing_error.as_deref(),
1636            Some("processing boom")
1637        );
1638    }
1639
1640    /// M7 (req 2): the two scheduling errors round-trip independently — a refresh
1641    /// failure and a processing failure are distinguishable on the wire, not
1642    /// collapsed into one aggregate `lastScheduleError`.
1643    #[test]
1644    fn ios_native_state_splits_schedule_errors() {
1645        let state = IosNativeState {
1646            desired_running: true,
1647            refresh_scheduled: false,
1648            processing_scheduled: true,
1649            active_task_kind: None,
1650            pending_task: None,
1651            last_completed_at: None,
1652            last_completion_reason: None,
1653            last_refresh_error: Some("only refresh failed".into()),
1654            last_processing_error: None,
1655            in_budget: true,
1656        };
1657        let json = serde_json::to_string(&state).unwrap();
1658        assert!(
1659            json.contains("\"lastRefreshError\":\"only refresh failed\""),
1660            "{json}"
1661        );
1662        // The processing error is absent (skip_serializing_if None), proving the
1663        // split: a refresh-only failure does not surface as a processing error.
1664        assert!(!json.contains("lastProcessingError"), "{json}");
1665        let de: IosNativeState = serde_json::from_str(&json).unwrap();
1666        assert_eq!(de, state);
1667        assert_eq!(
1668            de.last_refresh_error.as_deref(),
1669            Some("only refresh failed")
1670        );
1671        assert_eq!(de.last_processing_error, None);
1672    }
1673
1674    /// M7 (req 3): the durable last-completion reason round-trips through the
1675    /// camelCase wire as `lastCompletionReason`.
1676    #[test]
1677    fn ios_native_state_carries_last_completion_reason() {
1678        let state = IosNativeState {
1679            desired_running: true,
1680            refresh_scheduled: true,
1681            processing_scheduled: true,
1682            active_task_kind: None,
1683            pending_task: None,
1684            last_completed_at: Some(900.0),
1685            last_completion_reason: Some("expired".into()),
1686            last_refresh_error: None,
1687            last_processing_error: None,
1688            in_budget: true,
1689        };
1690        let json = serde_json::to_string(&state).unwrap();
1691        assert!(
1692            json.contains("\"lastCompletionReason\":\"expired\""),
1693            "{json}"
1694        );
1695        let de: IosNativeState = serde_json::from_str(&json).unwrap();
1696        assert_eq!(de.last_completion_reason.as_deref(), Some("expired"));
1697    }
1698
1699    #[test]
1700    fn plugin_event_stopped_with_stop_reason_roundtrip() {
1701        let event = PluginEvent::Stopped {
1702            reason: StopReason::TaskCompleted,
1703        };
1704        let json = serde_json::to_string(&event).unwrap();
1705        let de: PluginEvent = serde_json::from_str(&json).unwrap();
1706        assert_eq!(
1707            de,
1708            PluginEvent::Stopped {
1709                reason: StopReason::TaskCompleted
1710            }
1711        );
1712    }
1713
1714    #[test]
1715    fn plugin_event_stopped_legacy_reason_deserializes() {
1716        // Simulates receiving a legacy event with reason "completed"
1717        let json = r#"{"type":"stopped","reason":"completed"}"#;
1718        let de: PluginEvent = serde_json::from_str(json).unwrap();
1719        match de {
1720            PluginEvent::Stopped { reason } => {
1721                assert_eq!(reason, StopReason::TaskCompleted);
1722            }
1723            other => panic!("Expected Stopped, got {other:?}"),
1724        }
1725    }
1726
1727    #[test]
1728    fn plugin_event_stopped_legacy_cancelled_deserializes() {
1729        let json = r#"{"type":"stopped","reason":"cancelled"}"#;
1730        let de: PluginEvent = serde_json::from_str(json).unwrap();
1731        match de {
1732            PluginEvent::Stopped { reason } => {
1733                assert_eq!(reason, StopReason::UserStop);
1734            }
1735            other => panic!("Expected Stopped, got {other:?}"),
1736        }
1737    }
1738
1739    // --- StartConfig foreground_service_type tests ---
1740
1741    #[test]
1742    fn start_config_default_service_type() {
1743        let config = StartConfig::default();
1744        assert_eq!(config.foreground_service_type, "remoteMessaging");
1745    }
1746
1747    #[test]
1748    fn start_config_custom_service_type() {
1749        let config = StartConfig {
1750            service_label: "test".into(),
1751            foreground_service_type: "specialUse".into(),
1752        };
1753        assert_eq!(config.foreground_service_type, "specialUse");
1754    }
1755
1756    #[test]
1757    fn start_config_serde_roundtrip_service_type() {
1758        let config = StartConfig {
1759            service_label: "test".into(),
1760            foreground_service_type: "specialUse".into(),
1761        };
1762        let json = serde_json::to_string(&config).unwrap();
1763        let de: StartConfig = serde_json::from_str(&json).unwrap();
1764        assert_eq!(de.foreground_service_type, "specialUse");
1765    }
1766
1767    #[test]
1768    fn start_config_deserialize_missing_service_type() {
1769        let json = r#"{"serviceLabel":"test"}"#;
1770        let de: StartConfig = serde_json::from_str(json).unwrap();
1771        assert_eq!(de.foreground_service_type, "remoteMessaging");
1772    }
1773
1774    #[test]
1775    fn start_config_deserialize_special_use() {
1776        let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
1777        let de: StartConfig = serde_json::from_str(json).unwrap();
1778        assert_eq!(de.foreground_service_type, "specialUse");
1779    }
1780
1781    #[test]
1782    fn start_config_unrecognized_type_rejected_by_validation() {
1783        // Deserialization still passes through any string.
1784        let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
1785        let de: StartConfig = serde_json::from_str(json).unwrap();
1786        assert_eq!(de.foreground_service_type, "customType");
1787        // But validation rejects it.
1788        let result = validate_foreground_service_type(&de.foreground_service_type);
1789        assert!(
1790            result.is_err(),
1791            "validation should reject unrecognized type"
1792        );
1793        let err_msg = result.unwrap_err().to_string();
1794        assert!(
1795            err_msg.contains("customType"),
1796            "error should mention the invalid type: {err_msg}"
1797        );
1798    }
1799
1800    #[test]
1801    fn start_config_json_key_is_camel_case_service_type() {
1802        let config = StartConfig {
1803            service_label: "test".into(),
1804            foreground_service_type: "specialUse".into(),
1805        };
1806        let json = serde_json::to_string(&config).unwrap();
1807        assert!(
1808            json.contains("foregroundServiceType"),
1809            "JSON should use camelCase: {json}"
1810        );
1811    }
1812
1813    // --- PluginConfig tests ---
1814
1815    #[test]
1816    fn plugin_config_default_ios_safety_timeout() {
1817        let json = "{}";
1818        let config: PluginConfig = serde_json::from_str(json).unwrap();
1819        assert_eq!(config.ios_safety_timeout_secs, 28.0);
1820    }
1821
1822    #[test]
1823    fn plugin_config_custom_ios_safety_timeout() {
1824        let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
1825        let config: PluginConfig = serde_json::from_str(json).unwrap();
1826        assert_eq!(config.ios_safety_timeout_secs, 15.0);
1827    }
1828
1829    #[test]
1830    fn plugin_config_serde_roundtrip_preserves_value() {
1831        let config = PluginConfig {
1832            ios_safety_timeout_secs: 30.0,
1833            ios_cancel_listener_timeout_secs: 14400,
1834            ios_processing_safety_timeout_secs: 0.0,
1835            ios_earliest_refresh_begin_minutes: 20.0,
1836            ios_earliest_processing_begin_minutes: 30.0,
1837            ios_requires_external_power: true,
1838            ios_requires_network_connectivity: true,
1839            ..Default::default()
1840        };
1841        let json = serde_json::to_string(&config).unwrap();
1842        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1843        assert_eq!(de.ios_safety_timeout_secs, 30.0);
1844        assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
1845        assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
1846        assert!(de.ios_requires_external_power);
1847        assert!(de.ios_requires_network_connectivity);
1848    }
1849
1850    #[test]
1851    fn plugin_config_default_impl() {
1852        let config = PluginConfig::default();
1853        assert_eq!(config.ios_safety_timeout_secs, 28.0);
1854        assert_eq!(config.channel_capacity, 16);
1855    }
1856
1857    #[test]
1858    fn plugin_config_default_cancel_timeout() {
1859        let json = "{}";
1860        let config: PluginConfig = serde_json::from_str(json).unwrap();
1861        assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
1862    }
1863
1864    #[test]
1865    fn plugin_config_custom_cancel_timeout() {
1866        let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
1867        let config: PluginConfig = serde_json::from_str(json).unwrap();
1868        assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
1869    }
1870
1871    #[test]
1872    fn plugin_config_cancel_timeout_serde_roundtrip() {
1873        let config = PluginConfig {
1874            ios_cancel_listener_timeout_secs: 3600,
1875            ..Default::default()
1876        };
1877        let json = serde_json::to_string(&config).unwrap();
1878        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1879        assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
1880    }
1881
1882    // --- PluginConfig ios_processing_safety_timeout_secs tests ---
1883
1884    #[test]
1885    fn plugin_config_processing_timeout_default() {
1886        let json = "{}";
1887        let config: PluginConfig = serde_json::from_str(json).unwrap();
1888        assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
1889    }
1890
1891    #[test]
1892    fn plugin_config_processing_timeout_custom() {
1893        let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
1894        let config: PluginConfig = serde_json::from_str(json).unwrap();
1895        assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
1896    }
1897
1898    #[test]
1899    fn plugin_config_processing_timeout_serde_roundtrip() {
1900        let config = PluginConfig {
1901            ios_processing_safety_timeout_secs: 120.0,
1902            ..Default::default()
1903        };
1904        let json = serde_json::to_string(&config).unwrap();
1905        let de: PluginConfig = serde_json::from_str(&json).unwrap();
1906        assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
1907    }
1908
1909    // --- StartKeepaliveArgs tests ---
1910
1911    #[test]
1912    fn start_keepalive_args_with_timeout() {
1913        let args = StartKeepaliveArgs {
1914            label: "Test",
1915            foreground_service_type: "dataSync",
1916            ios_safety_timeout_secs: Some(15.0),
1917            ios_processing_safety_timeout_secs: None,
1918            ios_earliest_refresh_begin_minutes: None,
1919            ios_earliest_processing_begin_minutes: None,
1920            ios_requires_external_power: None,
1921            ios_requires_network_connectivity: None,
1922            ios_processing_ceiling_multiplier: None,
1923        };
1924        let json = serde_json::to_string(&args).unwrap();
1925        assert!(
1926            json.contains("\"iosSafetyTimeoutSecs\":15.0"),
1927            "JSON should contain iosSafetyTimeoutSecs: {json}"
1928        );
1929    }
1930
1931    #[test]
1932    fn start_keepalive_args_without_timeout() {
1933        let args = StartKeepaliveArgs {
1934            label: "Test",
1935            foreground_service_type: "dataSync",
1936            ios_safety_timeout_secs: None,
1937            ios_processing_safety_timeout_secs: None,
1938            ios_earliest_refresh_begin_minutes: None,
1939            ios_earliest_processing_begin_minutes: None,
1940            ios_requires_external_power: None,
1941            ios_requires_network_connectivity: None,
1942            ios_processing_ceiling_multiplier: None,
1943        };
1944        let json = serde_json::to_string(&args).unwrap();
1945        assert!(
1946            !json.contains("iosSafetyTimeoutSecs"),
1947            "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
1948        );
1949    }
1950
1951    #[test]
1952    fn start_keepalive_args_processing_timeout() {
1953        let args = StartKeepaliveArgs {
1954            label: "Test",
1955            foreground_service_type: "dataSync",
1956            ios_safety_timeout_secs: None,
1957            ios_processing_safety_timeout_secs: Some(60.0),
1958            ios_earliest_refresh_begin_minutes: None,
1959            ios_earliest_processing_begin_minutes: None,
1960            ios_requires_external_power: None,
1961            ios_requires_network_connectivity: None,
1962            ios_processing_ceiling_multiplier: None,
1963        };
1964        let json = serde_json::to_string(&args).unwrap();
1965        assert!(
1966            json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
1967            "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
1968        );
1969    }
1970
1971    #[test]
1972    fn start_keepalive_args_no_processing_timeout() {
1973        let args = StartKeepaliveArgs {
1974            label: "Test",
1975            foreground_service_type: "dataSync",
1976            ios_safety_timeout_secs: None,
1977            ios_processing_safety_timeout_secs: None,
1978            ios_earliest_refresh_begin_minutes: None,
1979            ios_earliest_processing_begin_minutes: None,
1980            ios_requires_external_power: None,
1981            ios_requires_network_connectivity: None,
1982            ios_processing_ceiling_multiplier: None,
1983        };
1984        let json = serde_json::to_string(&args).unwrap();
1985        assert!(
1986            !json.contains("iosProcessingSafetyTimeoutSecs"),
1987            "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
1988        );
1989    }
1990
1991    #[test]
1992    fn start_keepalive_args_camel_case_keys() {
1993        let args = StartKeepaliveArgs {
1994            label: "Test",
1995            foreground_service_type: "specialUse",
1996            ios_safety_timeout_secs: None,
1997            ios_processing_safety_timeout_secs: None,
1998            ios_earliest_refresh_begin_minutes: None,
1999            ios_earliest_processing_begin_minutes: None,
2000            ios_requires_external_power: None,
2001            ios_requires_network_connectivity: None,
2002            ios_processing_ceiling_multiplier: None,
2003        };
2004        let json = serde_json::to_string(&args).unwrap();
2005        assert!(json.contains("\"label\""), "label: {json}");
2006        assert!(
2007            json.contains("\"foregroundServiceType\""),
2008            "foregroundServiceType: {json}"
2009        );
2010    }
2011
2012    #[test]
2013    fn start_keepalive_args_scheduling_intervals() {
2014        let args = StartKeepaliveArgs {
2015            label: "Test",
2016            foreground_service_type: "dataSync",
2017            ios_safety_timeout_secs: None,
2018            ios_processing_safety_timeout_secs: None,
2019            ios_earliest_refresh_begin_minutes: Some(30.0),
2020            ios_earliest_processing_begin_minutes: Some(60.0),
2021            ios_requires_external_power: None,
2022            ios_requires_network_connectivity: None,
2023            ios_processing_ceiling_multiplier: None,
2024        };
2025        let json = serde_json::to_string(&args).unwrap();
2026        assert!(
2027            json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
2028            "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
2029        );
2030        assert!(
2031            json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
2032            "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
2033        );
2034    }
2035
2036    #[test]
2037    fn start_keepalive_args_processing_options() {
2038        let args = StartKeepaliveArgs {
2039            label: "Test",
2040            foreground_service_type: "dataSync",
2041            ios_safety_timeout_secs: None,
2042            ios_processing_safety_timeout_secs: None,
2043            ios_earliest_refresh_begin_minutes: None,
2044            ios_earliest_processing_begin_minutes: None,
2045            ios_requires_external_power: Some(true),
2046            ios_requires_network_connectivity: Some(true),
2047            ios_processing_ceiling_multiplier: None,
2048        };
2049        let json = serde_json::to_string(&args).unwrap();
2050        assert!(
2051            json.contains("\"iosRequiresExternalPower\":true"),
2052            "JSON should contain iosRequiresExternalPower: {json}"
2053        );
2054        assert!(
2055            json.contains("\"iosRequiresNetworkConnectivity\":true"),
2056            "JSON should contain iosRequiresNetworkConnectivity: {json}"
2057        );
2058    }
2059
2060    #[test]
2061    fn start_keepalive_args_processing_ceiling_multiplier() {
2062        let args = StartKeepaliveArgs {
2063            label: "Test",
2064            foreground_service_type: "dataSync",
2065            ios_safety_timeout_secs: None,
2066            ios_processing_safety_timeout_secs: None,
2067            ios_earliest_refresh_begin_minutes: None,
2068            ios_earliest_processing_begin_minutes: None,
2069            ios_requires_external_power: None,
2070            ios_requires_network_connectivity: None,
2071            ios_processing_ceiling_multiplier: Some(4.0),
2072        };
2073        let json = serde_json::to_string(&args).unwrap();
2074        assert!(
2075            json.contains("\"iosProcessingCeilingMultiplier\":4.0"),
2076            "JSON should contain iosProcessingCeilingMultiplier: {json}"
2077        );
2078    }
2079
2080    // --- PluginConfig new scheduling fields tests ---
2081
2082    #[test]
2083    fn plugin_config_earliest_refresh_default() {
2084        let json = "{}";
2085        let config: PluginConfig = serde_json::from_str(json).unwrap();
2086        assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
2087    }
2088
2089    #[test]
2090    fn plugin_config_earliest_processing_default() {
2091        let json = "{}";
2092        let config: PluginConfig = serde_json::from_str(json).unwrap();
2093        assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
2094    }
2095
2096    #[test]
2097    fn plugin_config_requires_external_power_default() {
2098        let json = "{}";
2099        let config: PluginConfig = serde_json::from_str(json).unwrap();
2100        assert!(!config.ios_requires_external_power);
2101    }
2102
2103    #[test]
2104    fn plugin_config_requires_network_connectivity_default() {
2105        let json = "{}";
2106        let config: PluginConfig = serde_json::from_str(json).unwrap();
2107        assert!(!config.ios_requires_network_connectivity);
2108    }
2109
2110    #[test]
2111    fn plugin_config_custom_scheduling_intervals() {
2112        let json =
2113            r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
2114        let config: PluginConfig = serde_json::from_str(json).unwrap();
2115        assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
2116        assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
2117    }
2118
2119    #[test]
2120    fn plugin_config_custom_processing_options() {
2121        let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
2122        let config: PluginConfig = serde_json::from_str(json).unwrap();
2123        assert!(config.ios_requires_external_power);
2124        assert!(config.ios_requires_network_connectivity);
2125    }
2126
2127    // --- PluginConfig ios_processing_ceiling_multiplier tests (D2) ---
2128
2129    #[test]
2130    fn plugin_config_processing_ceiling_multiplier_default() {
2131        let json = "{}";
2132        let config: PluginConfig = serde_json::from_str(json).unwrap();
2133        assert_eq!(config.ios_processing_ceiling_multiplier, 4.0);
2134    }
2135
2136    #[test]
2137    fn plugin_config_processing_ceiling_multiplier_custom() {
2138        let json = r#"{"iosProcessingCeilingMultiplier":6.0}"#;
2139        let config: PluginConfig = serde_json::from_str(json).unwrap();
2140        assert_eq!(config.ios_processing_ceiling_multiplier, 6.0);
2141    }
2142
2143    #[test]
2144    fn plugin_config_processing_ceiling_multiplier_serde_roundtrip() {
2145        let config = PluginConfig {
2146            ios_processing_ceiling_multiplier: 6.0,
2147            ..Default::default()
2148        };
2149        let json = serde_json::to_string(&config).unwrap();
2150        assert!(
2151            json.contains("\"iosProcessingCeilingMultiplier\":6.0"),
2152            "JSON should contain iosProcessingCeilingMultiplier: {json}"
2153        );
2154        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2155        assert_eq!(de.ios_processing_ceiling_multiplier, 6.0);
2156    }
2157
2158    // --- PluginConfig channel_capacity tests ---
2159
2160    #[test]
2161    fn plugin_config_channel_capacity_default() {
2162        let json = "{}";
2163        let config: PluginConfig = serde_json::from_str(json).unwrap();
2164        assert_eq!(config.channel_capacity, 16);
2165    }
2166
2167    #[test]
2168    fn plugin_config_channel_capacity_custom() {
2169        let json = r#"{"channelCapacity":32}"#;
2170        let config: PluginConfig = serde_json::from_str(json).unwrap();
2171        assert_eq!(config.channel_capacity, 32);
2172    }
2173
2174    #[test]
2175    fn plugin_config_channel_capacity_serde_roundtrip() {
2176        let config = PluginConfig {
2177            channel_capacity: 64,
2178            ..Default::default()
2179        };
2180        let json = serde_json::to_string(&config).unwrap();
2181        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2182        assert_eq!(de.channel_capacity, 64);
2183    }
2184
2185    #[test]
2186    fn plugin_config_channel_capacity_json_key_camel_case() {
2187        let config = PluginConfig {
2188            channel_capacity: 32,
2189            ..Default::default()
2190        };
2191        let json = serde_json::to_string(&config).unwrap();
2192        assert!(
2193            json.contains("channelCapacity"),
2194            "JSON should use camelCase: {json}"
2195        );
2196    }
2197
2198    // --- PluginConfig android FGS type fields tests ---
2199
2200    #[test]
2201    fn plugin_config_android_fgs_types_default() {
2202        let json = "{}";
2203        let config: PluginConfig = serde_json::from_str(json).unwrap();
2204        assert_eq!(
2205            config.android_foreground_service_types,
2206            vec!["remoteMessaging"]
2207        );
2208    }
2209
2210    #[test]
2211    fn plugin_config_android_fgs_types_custom() {
2212        let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
2213        let config: PluginConfig = serde_json::from_str(json).unwrap();
2214        assert_eq!(
2215            config.android_foreground_service_types,
2216            vec!["dataSync", "specialUse"]
2217        );
2218    }
2219
2220    #[test]
2221    fn plugin_config_android_fgs_types_serde_roundtrip() {
2222        let config = PluginConfig {
2223            android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
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!(
2229            de.android_foreground_service_types,
2230            vec!["location", "connectedDevice"]
2231        );
2232    }
2233
2234    #[test]
2235    fn plugin_config_android_fgs_types_json_key_camel_case() {
2236        let config = PluginConfig {
2237            android_foreground_service_types: vec!["specialUse".into()],
2238            ..Default::default()
2239        };
2240        let json = serde_json::to_string(&config).unwrap();
2241        assert!(
2242            json.contains("androidForegroundServiceTypes"),
2243            "JSON should use camelCase: {json}"
2244        );
2245    }
2246
2247    #[test]
2248    fn plugin_config_android_validate_default() {
2249        let json = "{}";
2250        let config: PluginConfig = serde_json::from_str(json).unwrap();
2251        assert!(config.android_validate_foreground_service_type);
2252    }
2253
2254    #[test]
2255    fn plugin_config_android_validate_false() {
2256        let json = r#"{"androidValidateForegroundServiceType":false}"#;
2257        let config: PluginConfig = serde_json::from_str(json).unwrap();
2258        assert!(!config.android_validate_foreground_service_type);
2259    }
2260
2261    #[test]
2262    fn plugin_config_android_validate_serde_roundtrip() {
2263        let config = PluginConfig {
2264            android_validate_foreground_service_type: false,
2265            ..Default::default()
2266        };
2267        let json = serde_json::to_string(&config).unwrap();
2268        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2269        assert!(!de.android_validate_foreground_service_type);
2270    }
2271
2272    #[test]
2273    fn plugin_config_android_validate_json_key_camel_case() {
2274        let config = PluginConfig {
2275            android_validate_foreground_service_type: false,
2276            ..Default::default()
2277        };
2278        let json = serde_json::to_string(&config).unwrap();
2279        assert!(
2280            json.contains("androidValidateForegroundServiceType"),
2281            "JSON should use camelCase: {json}"
2282        );
2283    }
2284
2285    // --- PluginConfig Android timeout/notification config tests ---
2286
2287    #[test]
2288    fn plugin_config_android_on_timeout_default() {
2289        let json = "{}";
2290        let config: PluginConfig = serde_json::from_str(json).unwrap();
2291        assert_eq!(config.android_on_timeout, "notifyUser");
2292    }
2293
2294    #[test]
2295    fn plugin_config_android_on_timeout_custom() {
2296        let json = r#"{"androidOnTimeout":"stop"}"#;
2297        let config: PluginConfig = serde_json::from_str(json).unwrap();
2298        assert_eq!(config.android_on_timeout, "stop");
2299    }
2300
2301    #[test]
2302    fn plugin_config_android_on_timeout_schedule_recovery() {
2303        let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
2304        let config: PluginConfig = serde_json::from_str(json).unwrap();
2305        assert_eq!(config.android_on_timeout, "scheduleRecovery");
2306    }
2307
2308    #[test]
2309    fn plugin_config_android_on_timeout_serde_roundtrip() {
2310        let config = PluginConfig {
2311            android_on_timeout: "stop".into(),
2312            ..Default::default()
2313        };
2314        let json = serde_json::to_string(&config).unwrap();
2315        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2316        assert_eq!(de.android_on_timeout, "stop");
2317    }
2318
2319    #[test]
2320    fn plugin_config_android_on_timeout_json_key_camel_case() {
2321        let config = PluginConfig {
2322            android_on_timeout: "notifyUser".into(),
2323            ..Default::default()
2324        };
2325        let json = serde_json::to_string(&config).unwrap();
2326        assert!(
2327            json.contains("androidOnTimeout"),
2328            "JSON should use camelCase: {json}"
2329        );
2330    }
2331
2332    #[test]
2333    fn plugin_config_android_notification_channel_id_default() {
2334        let json = "{}";
2335        let config: PluginConfig = serde_json::from_str(json).unwrap();
2336        assert_eq!(config.android_notification_channel_id, "bg_service");
2337    }
2338
2339    #[test]
2340    fn plugin_config_android_notification_channel_id_custom() {
2341        let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
2342        let config: PluginConfig = serde_json::from_str(json).unwrap();
2343        assert_eq!(config.android_notification_channel_id, "my_channel");
2344    }
2345
2346    #[test]
2347    fn plugin_config_android_notification_channel_id_serde_roundtrip() {
2348        let config = PluginConfig {
2349            android_notification_channel_id: "custom_ch".into(),
2350            ..Default::default()
2351        };
2352        let json = serde_json::to_string(&config).unwrap();
2353        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2354        assert_eq!(de.android_notification_channel_id, "custom_ch");
2355    }
2356
2357    #[test]
2358    fn plugin_config_android_notification_channel_id_json_key_camel_case() {
2359        let config = PluginConfig {
2360            android_notification_channel_id: "test".into(),
2361            ..Default::default()
2362        };
2363        let json = serde_json::to_string(&config).unwrap();
2364        assert!(
2365            json.contains("androidNotificationChannelId"),
2366            "JSON should use camelCase: {json}"
2367        );
2368    }
2369
2370    #[test]
2371    fn plugin_config_android_notification_channel_name_default() {
2372        let json = "{}";
2373        let config: PluginConfig = serde_json::from_str(json).unwrap();
2374        assert_eq!(
2375            config.android_notification_channel_name,
2376            "Background Service"
2377        );
2378    }
2379
2380    #[test]
2381    fn plugin_config_android_notification_channel_name_custom() {
2382        let json = r#"{"androidNotificationChannelName":"My Service"}"#;
2383        let config: PluginConfig = serde_json::from_str(json).unwrap();
2384        assert_eq!(config.android_notification_channel_name, "My Service");
2385    }
2386
2387    #[test]
2388    fn plugin_config_android_notification_channel_name_serde_roundtrip() {
2389        let config = PluginConfig {
2390            android_notification_channel_name: "Sync Service".into(),
2391            ..Default::default()
2392        };
2393        let json = serde_json::to_string(&config).unwrap();
2394        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2395        assert_eq!(de.android_notification_channel_name, "Sync Service");
2396    }
2397
2398    #[test]
2399    fn plugin_config_android_notification_channel_name_json_key_camel_case() {
2400        let config = PluginConfig {
2401            android_notification_channel_name: "Test".into(),
2402            ..Default::default()
2403        };
2404        let json = serde_json::to_string(&config).unwrap();
2405        assert!(
2406            json.contains("androidNotificationChannelName"),
2407            "JSON should use camelCase: {json}"
2408        );
2409    }
2410
2411    #[test]
2412    fn plugin_config_android_notification_id_default() {
2413        let json = "{}";
2414        let config: PluginConfig = serde_json::from_str(json).unwrap();
2415        assert_eq!(config.android_notification_id, 9001);
2416    }
2417
2418    #[test]
2419    fn plugin_config_android_notification_id_custom() {
2420        let json = r#"{"androidNotificationId":1234}"#;
2421        let config: PluginConfig = serde_json::from_str(json).unwrap();
2422        assert_eq!(config.android_notification_id, 1234);
2423    }
2424
2425    #[test]
2426    fn plugin_config_android_notification_id_serde_roundtrip() {
2427        let config = PluginConfig {
2428            android_notification_id: 42,
2429            ..Default::default()
2430        };
2431        let json = serde_json::to_string(&config).unwrap();
2432        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2433        assert_eq!(de.android_notification_id, 42);
2434    }
2435
2436    #[test]
2437    fn plugin_config_android_notification_id_json_key_camel_case() {
2438        let config = PluginConfig {
2439            android_notification_id: 5555,
2440            ..Default::default()
2441        };
2442        let json = serde_json::to_string(&config).unwrap();
2443        assert!(
2444            json.contains("androidNotificationId"),
2445            "JSON should use camelCase: {json}"
2446        );
2447    }
2448
2449    #[test]
2450    fn plugin_config_android_notification_small_icon_default() {
2451        let json = "{}";
2452        let config: PluginConfig = serde_json::from_str(json).unwrap();
2453        assert_eq!(config.android_notification_small_icon, None);
2454    }
2455
2456    #[test]
2457    fn plugin_config_android_notification_small_icon_custom() {
2458        let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
2459        let config: PluginConfig = serde_json::from_str(json).unwrap();
2460        assert_eq!(
2461            config.android_notification_small_icon,
2462            Some("ic_notification".to_string())
2463        );
2464    }
2465
2466    #[test]
2467    fn plugin_config_android_notification_small_icon_serde_roundtrip() {
2468        let config = PluginConfig {
2469            android_notification_small_icon: Some("my_icon".into()),
2470            ..Default::default()
2471        };
2472        let json = serde_json::to_string(&config).unwrap();
2473        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2474        assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
2475    }
2476
2477    #[test]
2478    fn plugin_config_android_notification_small_icon_absent_when_none() {
2479        let config = PluginConfig {
2480            android_notification_small_icon: None,
2481            ..Default::default()
2482        };
2483        let json = serde_json::to_string(&config).unwrap();
2484        assert!(
2485            !json.contains("androidNotificationSmallIcon"),
2486            "should be absent when None: {json}"
2487        );
2488    }
2489
2490    #[test]
2491    fn plugin_config_android_notification_small_icon_json_key_camel_case() {
2492        let config = PluginConfig {
2493            android_notification_small_icon: Some("icon".into()),
2494            ..Default::default()
2495        };
2496        let json = serde_json::to_string(&config).unwrap();
2497        assert!(
2498            json.contains("androidNotificationSmallIcon"),
2499            "JSON should use camelCase: {json}"
2500        );
2501    }
2502
2503    #[test]
2504    fn plugin_config_android_show_stop_action_default() {
2505        let json = "{}";
2506        let config: PluginConfig = serde_json::from_str(json).unwrap();
2507        assert!(config.android_show_stop_action);
2508    }
2509
2510    #[test]
2511    fn plugin_config_android_show_stop_action_false() {
2512        let json = r#"{"androidShowStopAction":false}"#;
2513        let config: PluginConfig = serde_json::from_str(json).unwrap();
2514        assert!(!config.android_show_stop_action);
2515    }
2516
2517    #[test]
2518    fn plugin_config_android_show_stop_action_serde_roundtrip() {
2519        let config = PluginConfig {
2520            android_show_stop_action: false,
2521            ..Default::default()
2522        };
2523        let json = serde_json::to_string(&config).unwrap();
2524        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2525        assert!(!de.android_show_stop_action);
2526    }
2527
2528    #[test]
2529    fn plugin_config_android_show_stop_action_json_key_camel_case() {
2530        let config = PluginConfig {
2531            android_show_stop_action: false,
2532            ..Default::default()
2533        };
2534        let json = serde_json::to_string(&config).unwrap();
2535        assert!(
2536            json.contains("androidShowStopAction"),
2537            "JSON should use camelCase: {json}"
2538        );
2539    }
2540
2541    // --- PluginConfig android_request_notification_permission_on_load tests ---
2542
2543    #[test]
2544    fn plugin_config_android_request_notification_permission_default() {
2545        let json = "{}";
2546        let config: PluginConfig = serde_json::from_str(json).unwrap();
2547        // NTF-09 (Step 10a): the serde default is now false — the load() startup
2548        // prompt is suppressed by default and requested via a consented path.
2549        assert!(!config.android_request_notification_permission_on_load);
2550    }
2551
2552    #[test]
2553    fn plugin_config_android_request_notification_permission_false() {
2554        let json = r#"{"androidRequestNotificationPermissionOnLoad":false}"#;
2555        let config: PluginConfig = serde_json::from_str(json).unwrap();
2556        assert!(!config.android_request_notification_permission_on_load);
2557    }
2558
2559    #[test]
2560    fn plugin_config_android_request_notification_permission_serde_roundtrip() {
2561        let config = PluginConfig {
2562            android_request_notification_permission_on_load: false,
2563            ..Default::default()
2564        };
2565        let json = serde_json::to_string(&config).unwrap();
2566        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2567        assert!(!de.android_request_notification_permission_on_load);
2568    }
2569
2570    #[test]
2571    fn plugin_config_android_timeout_notification_full_roundtrip() {
2572        let config = PluginConfig {
2573            android_on_timeout: "scheduleRecovery".into(),
2574            android_notification_channel_id: "my_ch".into(),
2575            android_notification_channel_name: "My Channel".into(),
2576            android_notification_id: 42,
2577            android_notification_small_icon: Some("ic_bg".into()),
2578            android_show_stop_action: false,
2579            ..Default::default()
2580        };
2581        let json = serde_json::to_string(&config).unwrap();
2582        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2583        assert_eq!(de.android_on_timeout, "scheduleRecovery");
2584        assert_eq!(de.android_notification_channel_id, "my_ch");
2585        assert_eq!(de.android_notification_channel_name, "My Channel");
2586        assert_eq!(de.android_notification_id, 42);
2587        assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
2588        assert!(!de.android_show_stop_action);
2589    }
2590
2591    // --- PluginConfig D1 lifecycle notification opt-in tests ---
2592
2593    #[test]
2594    fn plugin_config_notify_keys_default_false() {
2595        let json = "{}";
2596        let config: PluginConfig = serde_json::from_str(json).unwrap();
2597        assert!(!config.notify_on_timeout);
2598        assert!(!config.notify_on_recovery);
2599    }
2600
2601    #[test]
2602    fn plugin_config_notify_on_timeout_custom() {
2603        let json = r#"{"notifyOnTimeout":true}"#;
2604        let config: PluginConfig = serde_json::from_str(json).unwrap();
2605        assert!(config.notify_on_timeout);
2606        assert!(!config.notify_on_recovery);
2607    }
2608
2609    #[test]
2610    fn plugin_config_notify_on_recovery_custom() {
2611        let json = r#"{"notifyOnRecovery":true}"#;
2612        let config: PluginConfig = serde_json::from_str(json).unwrap();
2613        assert!(!config.notify_on_timeout);
2614        assert!(config.notify_on_recovery);
2615    }
2616
2617    #[test]
2618    fn plugin_config_notify_keys_serde_roundtrip() {
2619        let config = PluginConfig {
2620            notify_on_timeout: true,
2621            notify_on_recovery: true,
2622            ..Default::default()
2623        };
2624        let json = serde_json::to_string(&config).unwrap();
2625        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2626        assert!(de.notify_on_timeout);
2627        assert!(de.notify_on_recovery);
2628    }
2629
2630    #[test]
2631    fn plugin_config_notify_keys_json_camel_case() {
2632        let config = PluginConfig {
2633            notify_on_timeout: true,
2634            notify_on_recovery: true,
2635            ..Default::default()
2636        };
2637        let json = serde_json::to_string(&config).unwrap();
2638        assert!(
2639            json.contains("notifyOnTimeout") && json.contains("notifyOnRecovery"),
2640            "JSON should use camelCase: {json}"
2641        );
2642    }
2643
2644    // --- PluginConfig desktop fields tests (feature-gated) ---
2645
2646    #[cfg(feature = "desktop-service")]
2647    #[test]
2648    fn plugin_config_desktop_mode_default() {
2649        let json = "{}";
2650        let config: PluginConfig = serde_json::from_str(json).unwrap();
2651        assert_eq!(config.desktop_service_mode, "inProcess");
2652    }
2653
2654    #[cfg(feature = "desktop-service")]
2655    #[test]
2656    fn plugin_config_desktop_mode_custom() {
2657        let json = r#"{"desktopServiceMode":"osService"}"#;
2658        let config: PluginConfig = serde_json::from_str(json).unwrap();
2659        assert_eq!(config.desktop_service_mode, "osService");
2660    }
2661
2662    #[cfg(feature = "desktop-service")]
2663    #[test]
2664    fn plugin_config_desktop_mode_serde_roundtrip() {
2665        let config = PluginConfig {
2666            desktop_service_mode: "osService".into(),
2667            ..Default::default()
2668        };
2669        let json = serde_json::to_string(&config).unwrap();
2670        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2671        assert_eq!(de.desktop_service_mode, "osService");
2672    }
2673
2674    #[cfg(feature = "desktop-service")]
2675    #[test]
2676    fn plugin_config_desktop_label_default() {
2677        let json = "{}";
2678        let config: PluginConfig = serde_json::from_str(json).unwrap();
2679        assert_eq!(config.desktop_service_label, None);
2680    }
2681
2682    #[cfg(feature = "desktop-service")]
2683    #[test]
2684    fn plugin_config_desktop_label_custom() {
2685        let json = r#"{"desktopServiceLabel":"my.svc"}"#;
2686        let config: PluginConfig = serde_json::from_str(json).unwrap();
2687        assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
2688    }
2689
2690    // --- PluginConfig desktop autostart/start-if-missing/timeout tests ---
2691
2692    #[cfg(feature = "desktop-service")]
2693    #[test]
2694    fn plugin_config_desktop_autostart_default() {
2695        let json = "{}";
2696        let config: PluginConfig = serde_json::from_str(json).unwrap();
2697        assert!(!config.desktop_service_autostart);
2698    }
2699
2700    #[cfg(feature = "desktop-service")]
2701    #[test]
2702    fn plugin_config_desktop_autostart_true() {
2703        let json = r#"{"desktopServiceAutostart":true}"#;
2704        let config: PluginConfig = serde_json::from_str(json).unwrap();
2705        assert!(config.desktop_service_autostart);
2706    }
2707
2708    #[cfg(feature = "desktop-service")]
2709    #[test]
2710    fn plugin_config_desktop_autostart_serde_roundtrip() {
2711        let config = PluginConfig {
2712            desktop_service_autostart: true,
2713            ..Default::default()
2714        };
2715        let json = serde_json::to_string(&config).unwrap();
2716        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2717        assert!(de.desktop_service_autostart);
2718    }
2719
2720    #[cfg(feature = "desktop-service")]
2721    #[test]
2722    fn plugin_config_desktop_autostart_json_key_camel_case() {
2723        let config = PluginConfig {
2724            desktop_service_autostart: true,
2725            ..Default::default()
2726        };
2727        let json = serde_json::to_string(&config).unwrap();
2728        assert!(
2729            json.contains("desktopServiceAutostart"),
2730            "JSON should use camelCase: {json}"
2731        );
2732    }
2733
2734    #[cfg(feature = "desktop-service")]
2735    #[test]
2736    fn plugin_config_desktop_start_if_missing_default() {
2737        let json = "{}";
2738        let config: PluginConfig = serde_json::from_str(json).unwrap();
2739        assert!(!config.desktop_start_service_if_missing);
2740    }
2741
2742    #[cfg(feature = "desktop-service")]
2743    #[test]
2744    fn plugin_config_desktop_start_if_missing_true() {
2745        let json = r#"{"desktopStartServiceIfMissing":true}"#;
2746        let config: PluginConfig = serde_json::from_str(json).unwrap();
2747        assert!(config.desktop_start_service_if_missing);
2748    }
2749
2750    #[cfg(feature = "desktop-service")]
2751    #[test]
2752    fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
2753        let config = PluginConfig {
2754            desktop_start_service_if_missing: true,
2755            ..Default::default()
2756        };
2757        let json = serde_json::to_string(&config).unwrap();
2758        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2759        assert!(de.desktop_start_service_if_missing);
2760    }
2761
2762    #[cfg(feature = "desktop-service")]
2763    #[test]
2764    fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
2765        let config = PluginConfig {
2766            desktop_start_service_if_missing: true,
2767            ..Default::default()
2768        };
2769        let json = serde_json::to_string(&config).unwrap();
2770        assert!(
2771            json.contains("desktopStartServiceIfMissing"),
2772            "JSON should use camelCase: {json}"
2773        );
2774    }
2775
2776    #[cfg(feature = "desktop-service")]
2777    #[test]
2778    fn plugin_config_windows_daemon_opt_in_default_false() {
2779        let json = "{}";
2780        let config: PluginConfig = serde_json::from_str(json).unwrap();
2781        assert!(!config.desktop_windows_daemon_opt_in);
2782    }
2783
2784    #[cfg(feature = "desktop-service")]
2785    #[test]
2786    fn plugin_config_windows_daemon_opt_in_true() {
2787        let json = r#"{"desktopWindowsDaemonOptIn":true}"#;
2788        let config: PluginConfig = serde_json::from_str(json).unwrap();
2789        assert!(config.desktop_windows_daemon_opt_in);
2790    }
2791
2792    #[cfg(feature = "desktop-service")]
2793    #[test]
2794    fn plugin_config_windows_daemon_opt_in_serde_roundtrip() {
2795        let config = PluginConfig {
2796            desktop_windows_daemon_opt_in: true,
2797            ..Default::default()
2798        };
2799        let json = serde_json::to_string(&config).unwrap();
2800        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2801        assert!(de.desktop_windows_daemon_opt_in);
2802    }
2803
2804    #[cfg(feature = "desktop-service")]
2805    #[test]
2806    fn plugin_config_windows_daemon_opt_in_json_key_camel_case() {
2807        let config = PluginConfig {
2808            desktop_windows_daemon_opt_in: true,
2809            ..Default::default()
2810        };
2811        let json = serde_json::to_string(&config).unwrap();
2812        assert!(
2813            json.contains("desktopWindowsDaemonOptIn"),
2814            "JSON should use camelCase: {json}"
2815        );
2816    }
2817
2818    #[cfg(feature = "desktop-service")]
2819    #[test]
2820    fn plugin_config_desktop_start_timeout_default() {
2821        let json = "{}";
2822        let config: PluginConfig = serde_json::from_str(json).unwrap();
2823        assert_eq!(config.desktop_service_start_timeout_ms, 5000);
2824    }
2825
2826    #[cfg(feature = "desktop-service")]
2827    #[test]
2828    fn plugin_config_desktop_start_timeout_custom() {
2829        let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
2830        let config: PluginConfig = serde_json::from_str(json).unwrap();
2831        assert_eq!(config.desktop_service_start_timeout_ms, 10000);
2832    }
2833
2834    #[cfg(feature = "desktop-service")]
2835    #[test]
2836    fn plugin_config_desktop_start_timeout_serde_roundtrip() {
2837        let config = PluginConfig {
2838            desktop_service_start_timeout_ms: 15000,
2839            ..Default::default()
2840        };
2841        let json = serde_json::to_string(&config).unwrap();
2842        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2843        assert_eq!(de.desktop_service_start_timeout_ms, 15000);
2844    }
2845
2846    #[cfg(feature = "desktop-service")]
2847    #[test]
2848    fn plugin_config_desktop_start_timeout_json_key_camel_case() {
2849        let config = PluginConfig {
2850            desktop_service_start_timeout_ms: 3000,
2851            ..Default::default()
2852        };
2853        let json = serde_json::to_string(&config).unwrap();
2854        assert!(
2855            json.contains("desktopServiceStartTimeoutMs"),
2856            "JSON should use camelCase: {json}"
2857        );
2858    }
2859
2860    #[cfg(feature = "desktop-service")]
2861    #[test]
2862    fn plugin_config_desktop_all_new_fields_roundtrip() {
2863        let config = PluginConfig {
2864            desktop_service_autostart: true,
2865            desktop_start_service_if_missing: true,
2866            desktop_service_start_timeout_ms: 8000,
2867            ..Default::default()
2868        };
2869        let json = serde_json::to_string(&config).unwrap();
2870        let de: PluginConfig = serde_json::from_str(&json).unwrap();
2871        assert!(de.desktop_service_autostart);
2872        assert!(de.desktop_start_service_if_missing);
2873        assert_eq!(de.desktop_service_start_timeout_ms, 8000);
2874    }
2875
2876    use tauri::AppHandle;
2877
2878    // --- ServiceContext mobile fields tests ---
2879
2880    /// Compile-time + runtime test: ServiceContext mobile fields are String.
2881    #[cfg(mobile)]
2882    #[allow(dead_code)]
2883    fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
2884        let ctx = ServiceContext {
2885            notifier: Notifier { app: app.clone() },
2886            app,
2887            shutdown: CancellationToken::new(),
2888            service_label: "Syncing".into(),
2889            foreground_service_type: "dataSync".into(),
2890        };
2891        assert_eq!(ctx.service_label, "Syncing");
2892        assert_eq!(ctx.foreground_service_type, "dataSync");
2893    }
2894
2895    /// Compile-time + runtime test: ServiceContext on desktop has no mobile fields.
2896    #[cfg(not(mobile))]
2897    #[allow(dead_code)]
2898    fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
2899        let ctx = ServiceContext {
2900            notifier: Notifier { app: app.clone() },
2901            app,
2902            shutdown: CancellationToken::new(),
2903        };
2904        // Compiles — service_label and foreground_service_type are absent.
2905        let _ = ctx;
2906    }
2907
2908    // --- Foreground service type validation tests ---
2909
2910    #[test]
2911    fn validate_data_sync_passes() {
2912        assert!(
2913            validate_foreground_service_type("dataSync").is_ok(),
2914            "dataSync should be valid"
2915        );
2916    }
2917
2918    #[test]
2919    fn validate_special_use_passes() {
2920        assert!(
2921            validate_foreground_service_type("specialUse").is_ok(),
2922            "specialUse should be valid"
2923        );
2924    }
2925
2926    #[test]
2927    fn validate_invalid_type_returns_platform_error() {
2928        let result = validate_foreground_service_type("invalidType");
2929        assert!(result.is_err(), "invalidType should be rejected");
2930        match result {
2931            Err(crate::error::ServiceError::Platform(msg)) => {
2932                assert!(
2933                    msg.contains("invalidType"),
2934                    "error should mention the type: {msg}"
2935                );
2936            }
2937            other => panic!("Expected Platform error, got: {other:?}"),
2938        }
2939    }
2940
2941    #[test]
2942    fn validate_all_14_types_pass() {
2943        for &t in VALID_FOREGROUND_SERVICE_TYPES {
2944            assert!(
2945                validate_foreground_service_type(t).is_ok(),
2946                "{t} should be valid"
2947            );
2948        }
2949    }
2950
2951    #[test]
2952    fn valid_types_count_is_14() {
2953        assert_eq!(
2954            VALID_FOREGROUND_SERVICE_TYPES.len(),
2955            14,
2956            "should have exactly 14 valid types"
2957        );
2958    }
2959
2960    #[test]
2961    fn validate_empty_string_returns_error() {
2962        let result = validate_foreground_service_type("");
2963        assert!(result.is_err(), "empty string should be rejected");
2964    }
2965
2966    #[test]
2967    fn validate_case_sensitive() {
2968        // "DataSync" (capitalized) should NOT pass — case-sensitive.
2969        let result = validate_foreground_service_type("DataSync");
2970        assert!(
2971            result.is_err(),
2972            "validation should be case-sensitive: DataSync should fail"
2973        );
2974    }
2975
2976    // --- Foreground service type allowlist validation tests ---
2977
2978    #[test]
2979    fn allowlist_accepted_type_in_list() {
2980        let allowlist = vec!["remoteMessaging".to_string()];
2981        let result = validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true);
2982        assert!(result.is_ok(), "type in allowlist should be accepted");
2983    }
2984
2985    #[test]
2986    fn allowlist_rejected_type_not_in_list() {
2987        let allowlist = vec!["remoteMessaging".to_string()];
2988        let result = validate_fg_type_against_allowlist("specialUse", &allowlist, true);
2989        assert!(result.is_err(), "type not in allowlist should be rejected");
2990        match result {
2991            Err(ServiceError::Platform(msg)) => {
2992                assert!(
2993                    msg.contains("specialUse"),
2994                    "error should mention the type: {msg}"
2995                );
2996                assert!(
2997                    msg.contains("not allowed"),
2998                    "error should say not allowed: {msg}"
2999                );
3000            }
3001            other => panic!("Expected Platform error, got: {other:?}"),
3002        }
3003    }
3004
3005    #[test]
3006    fn allowlist_empty_type_rejected() {
3007        let allowlist = vec!["dataSync".to_string()];
3008        let result = validate_fg_type_against_allowlist("", &allowlist, true);
3009        assert!(result.is_err(), "empty type should be rejected");
3010        match result {
3011            Err(ServiceError::Platform(msg)) => {
3012                assert!(
3013                    msg.contains("must not be empty"),
3014                    "error should mention empty: {msg}"
3015                );
3016            }
3017            other => panic!("Expected Platform error, got: {other:?}"),
3018        }
3019    }
3020
3021    #[test]
3022    fn allowlist_case_insensitive_match() {
3023        let allowlist = vec!["remoteMessaging".to_string()];
3024        let result = validate_fg_type_against_allowlist("RemoteMessaging", &allowlist, true);
3025        assert!(result.is_ok(), "case-insensitive match should be accepted");
3026    }
3027
3028    #[test]
3029    fn allowlist_validation_skipped_when_disabled() {
3030        let allowlist = vec!["dataSync".to_string()];
3031        let result = validate_fg_type_against_allowlist("specialUse", &allowlist, false);
3032        assert!(result.is_ok(), "validation disabled should accept any type");
3033    }
3034
3035    #[test]
3036    fn allowlist_multiple_types() {
3037        let allowlist = vec![
3038            "dataSync".to_string(),
3039            "remoteMessaging".to_string(),
3040            "specialUse".to_string(),
3041        ];
3042        assert!(
3043            validate_fg_type_against_allowlist("dataSync", &allowlist, true).is_ok(),
3044            "dataSync should be in allowlist"
3045        );
3046        assert!(
3047            validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true).is_ok(),
3048            "remoteMessaging should be in allowlist"
3049        );
3050        assert!(
3051            validate_fg_type_against_allowlist("specialUse", &allowlist, true).is_ok(),
3052            "specialUse should be in allowlist"
3053        );
3054        assert!(
3055            validate_fg_type_against_allowlist("camera", &allowlist, true).is_err(),
3056            "camera should NOT be in allowlist"
3057        );
3058    }
3059
3060    // --- ServiceState serde tests ---
3061
3062    #[test]
3063    fn service_state_idle_serde_roundtrip() {
3064        let state = ServiceState::Idle;
3065        let json = serde_json::to_string(&state).unwrap();
3066        let de: ServiceState = serde_json::from_str(&json).unwrap();
3067        assert_eq!(de, ServiceState::Idle);
3068    }
3069
3070    #[test]
3071    fn service_state_initializing_serde_roundtrip() {
3072        let state = ServiceState::Initializing;
3073        let json = serde_json::to_string(&state).unwrap();
3074        let de: ServiceState = serde_json::from_str(&json).unwrap();
3075        assert_eq!(de, ServiceState::Initializing);
3076    }
3077
3078    #[test]
3079    fn service_state_running_serde_roundtrip() {
3080        let state = ServiceState::Running;
3081        let json = serde_json::to_string(&state).unwrap();
3082        let de: ServiceState = serde_json::from_str(&json).unwrap();
3083        assert_eq!(de, ServiceState::Running);
3084    }
3085
3086    #[test]
3087    fn service_state_stopped_serde_roundtrip() {
3088        let state = ServiceState::Stopped;
3089        let json = serde_json::to_string(&state).unwrap();
3090        let de: ServiceState = serde_json::from_str(&json).unwrap();
3091        assert_eq!(de, ServiceState::Stopped);
3092    }
3093
3094    #[test]
3095    fn service_state_json_values_are_camel_case() {
3096        assert_eq!(
3097            serde_json::to_string(&ServiceState::Idle).unwrap(),
3098            "\"idle\""
3099        );
3100        assert_eq!(
3101            serde_json::to_string(&ServiceState::Initializing).unwrap(),
3102            "\"initializing\""
3103        );
3104        assert_eq!(
3105            serde_json::to_string(&ServiceState::Running).unwrap(),
3106            "\"running\""
3107        );
3108        assert_eq!(
3109            serde_json::to_string(&ServiceState::Stopped).unwrap(),
3110            "\"stopped\""
3111        );
3112    }
3113
3114    // --- ServiceStatus serde tests ---
3115
3116    #[test]
3117    fn service_status_serde_roundtrip_idle() {
3118        let status = ServiceStatus {
3119            state: ServiceState::Idle,
3120            ..Default::default()
3121        };
3122        let json = serde_json::to_string(&status).unwrap();
3123        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3124        assert_eq!(de.state, ServiceState::Idle);
3125        assert_eq!(de.last_error, None);
3126    }
3127
3128    #[test]
3129    fn service_status_serde_roundtrip_with_error() {
3130        let status = ServiceStatus {
3131            state: ServiceState::Stopped,
3132            last_error: Some("init failed".into()),
3133            ..Default::default()
3134        };
3135        let json = serde_json::to_string(&status).unwrap();
3136        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3137        assert_eq!(de.state, ServiceState::Stopped);
3138        assert_eq!(de.last_error, Some("init failed".into()));
3139    }
3140
3141    #[test]
3142    fn service_status_json_keys_camel_case() {
3143        let status = ServiceStatus {
3144            state: ServiceState::Running,
3145            ..Default::default()
3146        };
3147        let json = serde_json::to_string(&status).unwrap();
3148        assert!(json.contains("\"state\":"), "state key: {json}");
3149        assert!(json.contains("\"lastError\":"), "lastError key: {json}");
3150    }
3151
3152    #[test]
3153    fn service_status_json_null_last_error() {
3154        let status = ServiceStatus {
3155            state: ServiceState::Idle,
3156            ..Default::default()
3157        };
3158        let json = serde_json::to_string(&status).unwrap();
3159        assert!(
3160            json.contains("\"lastError\":null"),
3161            "lastError should be null: {json}"
3162        );
3163    }
3164
3165    // --- Platform tests ---
3166
3167    #[test]
3168    fn platform_serde_roundtrip() {
3169        for variant in [
3170            Platform::Android,
3171            Platform::Ios,
3172            Platform::Windows,
3173            Platform::Macos,
3174            Platform::Linux,
3175            Platform::Unknown,
3176        ] {
3177            let json = serde_json::to_string(&variant).unwrap();
3178            let de: Platform = serde_json::from_str(&json).unwrap();
3179            assert_eq!(de, variant);
3180        }
3181    }
3182
3183    #[test]
3184    fn platform_json_values_are_camel_case() {
3185        assert_eq!(
3186            serde_json::to_string(&Platform::Android).unwrap(),
3187            "\"android\""
3188        );
3189        assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
3190        assert_eq!(
3191            serde_json::to_string(&Platform::Windows).unwrap(),
3192            "\"windows\""
3193        );
3194        assert_eq!(
3195            serde_json::to_string(&Platform::Macos).unwrap(),
3196            "\"macos\""
3197        );
3198        assert_eq!(
3199            serde_json::to_string(&Platform::Linux).unwrap(),
3200            "\"linux\""
3201        );
3202        assert_eq!(
3203            serde_json::to_string(&Platform::Unknown).unwrap(),
3204            "\"unknown\""
3205        );
3206    }
3207
3208    // --- LifecycleMode tests ---
3209
3210    #[test]
3211    fn lifecycle_mode_serde_roundtrip() {
3212        for variant in [
3213            LifecycleMode::AndroidForegroundService,
3214            LifecycleMode::IosBgTaskScheduler,
3215            LifecycleMode::DesktopInProcess,
3216            LifecycleMode::DesktopOsService,
3217        ] {
3218            let json = serde_json::to_string(&variant).unwrap();
3219            let de: LifecycleMode = serde_json::from_str(&json).unwrap();
3220            assert_eq!(de, variant);
3221        }
3222    }
3223
3224    #[test]
3225    fn lifecycle_mode_json_values_are_camel_case() {
3226        assert_eq!(
3227            serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
3228            "\"androidForegroundService\""
3229        );
3230        assert_eq!(
3231            serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
3232            "\"iosBgTaskScheduler\""
3233        );
3234        assert_eq!(
3235            serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
3236            "\"desktopInProcess\""
3237        );
3238        assert_eq!(
3239            serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
3240            "\"desktopOsService\""
3241        );
3242    }
3243
3244    // --- LifecycleGuarantee tests ---
3245
3246    #[test]
3247    fn lifecycle_guarantee_serde_roundtrip() {
3248        for variant in [
3249            LifecycleGuarantee::Guaranteed,
3250            LifecycleGuarantee::BestEffort,
3251            LifecycleGuarantee::Unsupported,
3252        ] {
3253            let json = serde_json::to_string(&variant).unwrap();
3254            let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
3255            assert_eq!(de, variant);
3256        }
3257    }
3258
3259    #[test]
3260    fn lifecycle_guarantee_json_values_are_camel_case() {
3261        assert_eq!(
3262            serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
3263            "\"guaranteed\""
3264        );
3265        assert_eq!(
3266            serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
3267            "\"bestEffort\""
3268        );
3269        assert_eq!(
3270            serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
3271            "\"unsupported\""
3272        );
3273    }
3274
3275    // --- PlatformCapabilities tests ---
3276
3277    #[test]
3278    fn platform_capabilities_serde_roundtrip() {
3279        let caps = PlatformCapabilities {
3280            platform: Platform::Android,
3281            lifecycle_mode: LifecycleMode::AndroidForegroundService,
3282            survives_app_close: LifecycleGuarantee::BestEffort,
3283            survives_reboot: LifecycleGuarantee::BestEffort,
3284            survives_force_quit: LifecycleGuarantee::Unsupported,
3285            background_execution: LifecycleGuarantee::Guaranteed,
3286            limitations: vec!["OEM battery optimization".into()],
3287            required_setup: vec!["FOREGROUND_SERVICE permission".into()],
3288        };
3289        let json = serde_json::to_string(&caps).unwrap();
3290        let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
3291        assert_eq!(de, caps);
3292    }
3293
3294    #[test]
3295    fn platform_capabilities_json_keys_camel_case() {
3296        let caps = PlatformCapabilities {
3297            platform: Platform::Linux,
3298            lifecycle_mode: LifecycleMode::DesktopInProcess,
3299            survives_app_close: LifecycleGuarantee::Unsupported,
3300            survives_reboot: LifecycleGuarantee::Unsupported,
3301            survives_force_quit: LifecycleGuarantee::Unsupported,
3302            background_execution: LifecycleGuarantee::Guaranteed,
3303            limitations: vec![],
3304            required_setup: vec![],
3305        };
3306        let json = serde_json::to_string(&caps).unwrap();
3307        assert!(json.contains("\"platform\":"), "platform: {json}");
3308        assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
3309        assert!(
3310            json.contains("\"survivesAppClose\":"),
3311            "survivesAppClose: {json}"
3312        );
3313        assert!(
3314            json.contains("\"survivesReboot\":"),
3315            "survivesReboot: {json}"
3316        );
3317        assert!(
3318            json.contains("\"survivesForceQuit\":"),
3319            "survivesForceQuit: {json}"
3320        );
3321        assert!(
3322            json.contains("\"backgroundExecution\":"),
3323            "backgroundExecution: {json}"
3324        );
3325        assert!(json.contains("\"limitations\":"), "limitations: {json}");
3326        assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
3327    }
3328
3329    #[test]
3330    fn platform_capabilities_empty_collections_serialize() {
3331        let caps = PlatformCapabilities {
3332            platform: Platform::Unknown,
3333            lifecycle_mode: LifecycleMode::DesktopInProcess,
3334            survives_app_close: LifecycleGuarantee::Unsupported,
3335            survives_reboot: LifecycleGuarantee::Unsupported,
3336            survives_force_quit: LifecycleGuarantee::Unsupported,
3337            background_execution: LifecycleGuarantee::Unsupported,
3338            limitations: vec![],
3339            required_setup: vec![],
3340        };
3341        let json = serde_json::to_string(&caps).unwrap();
3342        assert!(json.contains("\"limitations\":[]"), "{json}");
3343        assert!(json.contains("\"requiredSetup\":[]"), "{json}");
3344    }
3345
3346    // --- NativeState tests ---
3347
3348    #[test]
3349    fn native_state_serde_roundtrip() {
3350        for variant in [
3351            NativeState::Idle,
3352            NativeState::Starting,
3353            NativeState::Running,
3354            NativeState::Stopping,
3355            NativeState::Timeout,
3356            NativeState::Expired,
3357            NativeState::Recovering,
3358            NativeState::Error,
3359        ] {
3360            let json = serde_json::to_string(&variant).unwrap();
3361            let de: NativeState = serde_json::from_str(&json).unwrap();
3362            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3363        }
3364    }
3365
3366    #[test]
3367    fn native_state_json_values_are_camel_case() {
3368        assert_eq!(
3369            serde_json::to_string(&NativeState::Idle).unwrap(),
3370            "\"idle\""
3371        );
3372        assert_eq!(
3373            serde_json::to_string(&NativeState::Starting).unwrap(),
3374            "\"starting\""
3375        );
3376        assert_eq!(
3377            serde_json::to_string(&NativeState::Running).unwrap(),
3378            "\"running\""
3379        );
3380        assert_eq!(
3381            serde_json::to_string(&NativeState::Stopping).unwrap(),
3382            "\"stopping\""
3383        );
3384        assert_eq!(
3385            serde_json::to_string(&NativeState::Timeout).unwrap(),
3386            "\"timeout\""
3387        );
3388        assert_eq!(
3389            serde_json::to_string(&NativeState::Expired).unwrap(),
3390            "\"expired\""
3391        );
3392        assert_eq!(
3393            serde_json::to_string(&NativeState::Recovering).unwrap(),
3394            "\"recovering\""
3395        );
3396        assert_eq!(
3397            serde_json::to_string(&NativeState::Error).unwrap(),
3398            "\"error\""
3399        );
3400    }
3401
3402    // --- Extended ServiceStatus tests ---
3403
3404    #[test]
3405    fn service_status_backward_compat_deserialize_old_json() {
3406        let old_json = r#"{"state":"running","lastError":null}"#;
3407        let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
3408        assert_eq!(status.state, ServiceState::Running);
3409        assert_eq!(status.last_error, None);
3410        assert_eq!(status.desired_running, None);
3411        assert_eq!(status.native_state, None);
3412        assert_eq!(status.platform_mode, None);
3413        assert_eq!(status.last_start_config, None);
3414        assert_eq!(status.last_heartbeat_at, None);
3415        assert_eq!(status.restart_attempt, None);
3416        assert_eq!(status.recovery_reason, None);
3417        assert_eq!(status.platform_error, None);
3418    }
3419
3420    #[test]
3421    fn service_status_new_fields_serialize_when_present() {
3422        let status = ServiceStatus {
3423            state: ServiceState::Running,
3424            last_error: None,
3425            desired_running: Some(true),
3426            native_state: Some(NativeState::Running),
3427            platform_mode: Some(LifecycleMode::AndroidForegroundService),
3428            last_start_config: Some(StartConfig::default()),
3429            last_heartbeat_at: Some(1234567890),
3430            restart_attempt: Some(2),
3431            recovery_reason: Some("boot recovery".into()),
3432            platform_error: Some("timeout exceeded".into()),
3433        };
3434        let json = serde_json::to_string(&status).unwrap();
3435        assert!(json.contains("\"desiredRunning\":true"), "{json}");
3436        assert!(json.contains("\"nativeState\":\"running\""), "{json}");
3437        assert!(
3438            json.contains("\"platformMode\":\"androidForegroundService\""),
3439            "{json}"
3440        );
3441        assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
3442        assert!(json.contains("\"restartAttempt\":2"), "{json}");
3443        assert!(
3444            json.contains("\"recoveryReason\":\"boot recovery\""),
3445            "{json}"
3446        );
3447        assert!(
3448            json.contains("\"platformError\":\"timeout exceeded\""),
3449            "{json}"
3450        );
3451    }
3452
3453    #[test]
3454    fn service_status_new_fields_absent_when_none() {
3455        let status = ServiceStatus {
3456            state: ServiceState::Idle,
3457            last_error: None,
3458            desired_running: None,
3459            native_state: None,
3460            platform_mode: None,
3461            last_start_config: None,
3462            last_heartbeat_at: None,
3463            restart_attempt: None,
3464            recovery_reason: None,
3465            platform_error: None,
3466        };
3467        let json = serde_json::to_string(&status).unwrap();
3468        assert!(!json.contains("desiredRunning"), "should be absent: {json}");
3469        assert!(!json.contains("nativeState"), "should be absent: {json}");
3470        assert!(!json.contains("platformMode"), "should be absent: {json}");
3471        assert!(
3472            !json.contains("lastStartConfig"),
3473            "should be absent: {json}"
3474        );
3475        assert!(
3476            !json.contains("lastHeartbeatAt"),
3477            "should be absent: {json}"
3478        );
3479        assert!(!json.contains("restartAttempt"), "should be absent: {json}");
3480        assert!(!json.contains("recoveryReason"), "should be absent: {json}");
3481        assert!(!json.contains("platformError"), "should be absent: {json}");
3482    }
3483
3484    #[test]
3485    fn service_status_default_impl() {
3486        let status = ServiceStatus::default();
3487        assert_eq!(status.state, ServiceState::Idle);
3488        assert_eq!(status.last_error, None);
3489        assert_eq!(status.desired_running, None);
3490        assert_eq!(status.native_state, None);
3491        assert_eq!(status.platform_mode, None);
3492        assert_eq!(status.last_start_config, None);
3493        assert_eq!(status.last_heartbeat_at, None);
3494        assert_eq!(status.restart_attempt, None);
3495        assert_eq!(status.recovery_reason, None);
3496        assert_eq!(status.platform_error, None);
3497    }
3498
3499    #[test]
3500    fn service_status_full_roundtrip_with_all_fields() {
3501        let status = ServiceStatus {
3502            state: ServiceState::Running,
3503            last_error: Some("previous crash".into()),
3504            desired_running: Some(true),
3505            native_state: Some(NativeState::Recovering),
3506            platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
3507            last_start_config: Some(StartConfig {
3508                service_label: "Sync".into(),
3509                foreground_service_type: "dataSync".into(),
3510            }),
3511            last_heartbeat_at: Some(999),
3512            restart_attempt: Some(3),
3513            recovery_reason: Some("force stop".into()),
3514            platform_error: Some("scheduler busy".into()),
3515        };
3516        let json = serde_json::to_string(&status).unwrap();
3517        let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3518        assert_eq!(de.state, ServiceState::Running);
3519        assert_eq!(de.last_error, Some("previous crash".into()));
3520        assert_eq!(de.desired_running, Some(true));
3521        assert_eq!(de.native_state, Some(NativeState::Recovering));
3522        assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
3523        assert!(de.last_start_config.is_some());
3524        assert_eq!(de.last_heartbeat_at, Some(999));
3525        assert_eq!(de.restart_attempt, Some(3));
3526        assert_eq!(de.recovery_reason, Some("force stop".into()));
3527        assert_eq!(de.platform_error, Some("scheduler busy".into()));
3528    }
3529
3530    #[test]
3531    fn platform_capabilities_deserialize_from_json() {
3532        let json = r#"{
3533            "platform":"ios",
3534            "lifecycleMode":"iosBgTaskScheduler",
3535            "survivesAppClose":"bestEffort",
3536            "survivesReboot":"bestEffort",
3537            "survivesForceQuit":"unsupported",
3538            "backgroundExecution":"bestEffort",
3539            "limitations":["Cannot guarantee continuous execution"],
3540            "requiredSetup":["UIBackgroundModes in Info.plist"]
3541        }"#;
3542        let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
3543        assert_eq!(caps.platform, Platform::Ios);
3544        assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
3545        assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
3546        assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
3547        assert_eq!(caps.limitations.len(), 1);
3548        assert_eq!(caps.required_setup.len(), 1);
3549    }
3550
3551    // --- IOSSchedulingStatus tests ---
3552
3553    #[test]
3554    fn ios_scheduling_status_both_scheduled() {
3555        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3556        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3557        assert!(status.refresh_scheduled);
3558        assert!(status.processing_scheduled);
3559        assert_eq!(status.refresh_error, None);
3560        assert_eq!(status.processing_error, None);
3561    }
3562
3563    #[test]
3564    fn ios_scheduling_status_partial_success() {
3565        let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
3566        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3567        assert!(status.refresh_scheduled);
3568        assert!(!status.processing_scheduled);
3569        assert_eq!(status.refresh_error, None);
3570        assert_eq!(status.processing_error, Some("not permitted".to_string()));
3571    }
3572
3573    #[test]
3574    fn ios_scheduling_status_with_errors() {
3575        let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
3576        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3577        assert!(!status.refresh_scheduled);
3578        assert!(!status.processing_scheduled);
3579        assert_eq!(status.refresh_error, Some("err1".to_string()));
3580        assert_eq!(status.processing_error, Some("err2".to_string()));
3581    }
3582
3583    #[test]
3584    fn ios_scheduling_status_serde_roundtrip() {
3585        let status = IOSSchedulingStatus {
3586            refresh_scheduled: true,
3587            processing_scheduled: false,
3588            refresh_error: None,
3589            processing_error: Some("busy".into()),
3590        };
3591        let json = serde_json::to_string(&status).unwrap();
3592        let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
3593        assert_eq!(de, status);
3594    }
3595
3596    #[test]
3597    fn ios_scheduling_status_json_keys_camel_case() {
3598        let status = IOSSchedulingStatus {
3599            refresh_scheduled: true,
3600            processing_scheduled: true,
3601            refresh_error: Some("err".into()),
3602            processing_error: None,
3603        };
3604        let json = serde_json::to_string(&status).unwrap();
3605        assert!(json.contains("\"refreshScheduled\":"), "{json}");
3606        assert!(json.contains("\"processingScheduled\":"), "{json}");
3607        assert!(json.contains("\"refreshError\":"), "{json}");
3608        assert!(
3609            !json.contains("processingError"),
3610            "None fields should be absent: {json}"
3611        );
3612    }
3613
3614    #[test]
3615    fn ios_scheduling_status_from_value_null_errors() {
3616        // Simulates the Swift response where errors are NSNull()
3617        let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3618        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3619        assert!(status.refresh_scheduled);
3620        assert!(status.processing_scheduled);
3621        assert_eq!(status.refresh_error, None);
3622        assert_eq!(status.processing_error, None);
3623    }
3624
3625    #[test]
3626    fn ios_scheduling_status_from_value_missing_errors() {
3627        // Swift may not include error fields when scheduling succeeds
3628        let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3629        let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3630        assert!(status.refresh_scheduled);
3631        assert!(status.processing_scheduled);
3632        assert_eq!(status.refresh_error, None);
3633        assert_eq!(status.processing_error, None);
3634    }
3635
3636    // --- C1/M9: exact-Swift-payload drift-guard fixtures ---
3637    //
3638    // The iOS native layer splits its status query into two `@objc` calls:
3639    //   * `getSchedulingStatus`   -> submit-result shape -> `IOSSchedulingStatus`
3640    //   * `getDesiredStateStatus` -> persisted shape     -> `IOSDesiredStateStatus`
3641    //
3642    // These fixtures commit the *exact* JSON those Swift handlers resolve (NSNull
3643    // serializes to JSON `null`, `TimeInterval` to a JSON float) and prove it
3644    // `from_value`s into the typed DTOs. They live here, not in `mobile.rs`,
3645    // because `mobile.rs` is `#[cfg(mobile)]` and is excluded from the canonical
3646    // macOS gate (`cargo test -p tauri-plugin-background-service --lib`); the
3647    // contract being pinned is the serde shape, which is defined in this file.
3648
3649    /// The exact payload `BackgroundServicePlugin.getSchedulingStatus` resolves
3650    /// after a successful `startKeepalive` (both task types scheduled, no errors).
3651    const SWIFT_SCHEDULING_STATUS_PAYLOAD: &str = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3652
3653    /// The exact payload `BackgroundServicePlugin.getDesiredStateStatus` resolves
3654    /// while the service is desired-running with one refresh task started but not
3655    /// yet completed. `lastScheduleError`/`lastTaskCompletedAt`/
3656    /// `lastCompletionReason` are `NSNull()` (no run has completed yet).
3657    const SWIFT_DESIRED_STATE_PAYLOAD: &str = r#"{"desiredRunning":true,"lastStartConfig":"{\"label\":\"App\"}","lastScheduleError":null,"lastTaskKind":"refresh","lastTaskStartedAt":1719500000.5,"lastTaskCompletedAt":null,"lastCompletionReason":null,"notificationGranted":null}"#;
3658
3659    #[test]
3660    fn ios_scheduling_status_parses_exact_swift_payload() {
3661        let status: IOSSchedulingStatus =
3662            serde_json::from_value(serde_json::from_str(SWIFT_SCHEDULING_STATUS_PAYLOAD).unwrap())
3663                .expect("the exact Swift getSchedulingStatus payload must deserialize");
3664        assert!(status.refresh_scheduled);
3665        assert!(status.processing_scheduled);
3666        assert_eq!(status.refresh_error, None);
3667        assert_eq!(status.processing_error, None);
3668    }
3669
3670    #[test]
3671    fn ios_desired_state_status_parses_exact_swift_payload() {
3672        let status: IOSDesiredStateStatus =
3673            serde_json::from_value(serde_json::from_str(SWIFT_DESIRED_STATE_PAYLOAD).unwrap())
3674                .expect("the exact Swift getDesiredStateStatus payload must deserialize");
3675        assert!(status.desired_running);
3676        assert_eq!(
3677            status.last_start_config.as_deref(),
3678            Some("{\"label\":\"App\"}")
3679        );
3680        assert_eq!(status.last_schedule_error, None);
3681        assert_eq!(status.last_task_kind.as_deref(), Some("refresh"));
3682        assert_eq!(status.last_task_started_at, Some(1719500000.5));
3683        assert_eq!(status.last_task_completed_at, None);
3684        assert_eq!(status.last_completion_reason, None);
3685        assert_eq!(status.notification_granted, None);
3686    }
3687
3688    #[test]
3689    fn ios_desired_state_status_defaults_when_never_started() {
3690        // Fresh install: Swift resolves desiredRunning=false with every optional
3691        // field NSNull(). Required `desired_running` is present; the rest default.
3692        let json = r#"{"desiredRunning":false,"lastStartConfig":null,"lastScheduleError":null,"lastTaskKind":null,"lastTaskStartedAt":null,"lastTaskCompletedAt":null}"#;
3693        let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
3694        assert!(!status.desired_running);
3695        assert_eq!(status.last_start_config, None);
3696        assert_eq!(status.last_task_started_at, None);
3697    }
3698
3699    #[test]
3700    fn ios_desired_state_status_camel_case_roundtrip() {
3701        let status = IOSDesiredStateStatus {
3702            desired_running: true,
3703            last_start_config: Some("{}".into()),
3704            last_task_kind: Some("processing".into()),
3705            last_task_started_at: Some(1.0),
3706            last_task_completed_at: None,
3707            last_schedule_error: Some("boom".into()),
3708            last_completion_reason: Some("expired".into()),
3709            notification_granted: Some(true),
3710        };
3711        let json = serde_json::to_string(&status).unwrap();
3712        assert!(json.contains("\"desiredRunning\":"), "{json}");
3713        assert!(json.contains("\"lastStartConfig\":"), "{json}");
3714        assert!(json.contains("\"lastTaskKind\":"), "{json}");
3715        assert!(json.contains("\"lastScheduleError\":"), "{json}");
3716        assert!(json.contains("\"lastCompletionReason\":"), "{json}");
3717        assert!(json.contains("\"notificationGranted\":"), "{json}");
3718        let de: IOSDesiredStateStatus = serde_json::from_str(&json).unwrap();
3719        assert_eq!(de, status);
3720    }
3721
3722    /// M7 (req 3): the persisted desired-state DTO carries the durable
3723    /// last-completion reason the iOS native layer now resolves under
3724    /// `lastCompletionReason`. Absent (legacy) payloads default it to `None`.
3725    #[test]
3726    fn ios_desired_state_status_carries_last_completion_reason() {
3727        let json = r#"{"desiredRunning":true,"lastCompletionReason":"completed"}"#;
3728        let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
3729        assert_eq!(status.last_completion_reason.as_deref(), Some("completed"));
3730
3731        // Legacy payload without the key defaults to None (forward-compatible).
3732        let legacy = r#"{"desiredRunning":false}"#;
3733        let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
3734        assert_eq!(de.last_completion_reason, None);
3735    }
3736
3737    /// M4: the persisted desired-state DTO carries the notification-authorization
3738    /// decision the iOS native layer now forwards under `notificationGranted`, so
3739    /// Rust's Notifier can degrade when notifications are denied. Absent (legacy /
3740    /// pre-intent) payloads default it to `None`.
3741    #[test]
3742    fn ios_desired_state_status_carries_notification_granted() {
3743        let granted = r#"{"desiredRunning":true,"notificationGranted":true}"#;
3744        let status: IOSDesiredStateStatus = serde_json::from_str(granted).unwrap();
3745        assert_eq!(status.notification_granted, Some(true));
3746
3747        let denied = r#"{"desiredRunning":true,"notificationGranted":false}"#;
3748        let status: IOSDesiredStateStatus = serde_json::from_str(denied).unwrap();
3749        assert_eq!(status.notification_granted, Some(false));
3750
3751        // Pre-intent payload without the key defaults to None (unknown decision).
3752        let legacy = r#"{"desiredRunning":false}"#;
3753        let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
3754        assert_eq!(de.notification_granted, None);
3755    }
3756
3757    // --- OsServiceInstallState tests ---
3758
3759    #[test]
3760    fn os_service_install_state_serde_roundtrip() {
3761        for variant in [
3762            OsServiceInstallState::NotInstalled,
3763            OsServiceInstallState::Installed,
3764            OsServiceInstallState::Running,
3765        ] {
3766            let json = serde_json::to_string(&variant).unwrap();
3767            let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
3768            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3769        }
3770    }
3771
3772    #[test]
3773    fn os_service_install_state_json_values_camel_case() {
3774        assert_eq!(
3775            serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
3776            "\"notInstalled\""
3777        );
3778        assert_eq!(
3779            serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
3780            "\"installed\""
3781        );
3782        assert_eq!(
3783            serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
3784            "\"running\""
3785        );
3786    }
3787
3788    // --- OsServiceStatus tests ---
3789
3790    #[test]
3791    fn os_service_status_serde_roundtrip() {
3792        let status = OsServiceStatus {
3793            label: "com.example.bg-service".into(),
3794            mode: "systemd".into(),
3795            installed: OsServiceInstallState::Running,
3796            ipc_connected: true,
3797            socket_path: Some("/tmp/test.sock".into()),
3798            last_error: None,
3799        };
3800        let json = serde_json::to_string(&status).unwrap();
3801        let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
3802        assert_eq!(de.label, "com.example.bg-service");
3803        assert_eq!(de.mode, "systemd");
3804        assert_eq!(de.installed, OsServiceInstallState::Running);
3805        assert!(de.ipc_connected);
3806        assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
3807        assert_eq!(de.last_error, None);
3808    }
3809
3810    #[test]
3811    fn os_service_status_json_keys_camel_case() {
3812        let status = OsServiceStatus {
3813            label: "test".into(),
3814            mode: "launchd".into(),
3815            installed: OsServiceInstallState::Installed,
3816            ipc_connected: false,
3817            socket_path: Some("/run/test.sock".into()),
3818            last_error: Some("timeout".into()),
3819        };
3820        let json = serde_json::to_string(&status).unwrap();
3821        assert!(json.contains("\"label\":"), "{json}");
3822        assert!(json.contains("\"mode\":"), "{json}");
3823        assert!(json.contains("\"installed\":"), "{json}");
3824        assert!(json.contains("\"ipcConnected\":"), "{json}");
3825        assert!(json.contains("\"socketPath\":"), "{json}");
3826        assert!(json.contains("\"lastError\":"), "{json}");
3827    }
3828
3829    #[test]
3830    fn os_service_status_optional_fields_absent_when_none() {
3831        let status = OsServiceStatus {
3832            label: "test".into(),
3833            mode: "systemd".into(),
3834            installed: OsServiceInstallState::NotInstalled,
3835            ipc_connected: false,
3836            socket_path: None,
3837            last_error: None,
3838        };
3839        let json = serde_json::to_string(&status).unwrap();
3840        assert!(!json.contains("socketPath"), "should be absent: {json}");
3841        assert!(!json.contains("lastError"), "should be absent: {json}");
3842    }
3843
3844    #[test]
3845    fn os_service_status_with_all_optional_fields() {
3846        let status = OsServiceStatus {
3847            label: "com.test".into(),
3848            mode: "launchd".into(),
3849            installed: OsServiceInstallState::Running,
3850            ipc_connected: true,
3851            socket_path: Some("/var/run/com.test.sock".into()),
3852            last_error: Some("connection refused".into()),
3853        };
3854        let json = serde_json::to_string(&status).unwrap();
3855        assert!(
3856            json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
3857            "{json}"
3858        );
3859        assert!(
3860            json.contains("\"lastError\":\"connection refused\""),
3861            "{json}"
3862        );
3863    }
3864
3865    #[test]
3866    fn os_service_status_deserialize_from_json() {
3867        let json = r#"{
3868            "label":"com.example.svc",
3869            "mode":"systemd",
3870            "installed":"running",
3871            "ipcConnected":true,
3872            "socketPath":"/tmp/test.sock"
3873        }"#;
3874        let status: OsServiceStatus = serde_json::from_str(json).unwrap();
3875        assert_eq!(status.label, "com.example.svc");
3876        assert_eq!(status.mode, "systemd");
3877        assert_eq!(status.installed, OsServiceInstallState::Running);
3878        assert!(status.ipc_connected);
3879        assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
3880        assert_eq!(status.last_error, None);
3881    }
3882
3883    // --- PendingTaskInfo tests ---
3884
3885    #[test]
3886    fn pending_task_info_serde_roundtrip() {
3887        let info = PendingTaskInfo {
3888            task_kind: "refresh".into(),
3889            identifier: "com.example.app.bg-refresh".into(),
3890            received_at: 1700000000.123,
3891            consumed_at: None,
3892        };
3893        let json = serde_json::to_string(&info).unwrap();
3894        let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
3895        assert_eq!(de, info);
3896    }
3897
3898    #[test]
3899    fn pending_task_info_json_keys_camel_case() {
3900        let info = PendingTaskInfo {
3901            task_kind: "processing".into(),
3902            identifier: "test-id".into(),
3903            received_at: 123456.0,
3904            consumed_at: Some(123500.0),
3905        };
3906        let json = serde_json::to_string(&info).unwrap();
3907        assert!(json.contains("\"taskKind\":"), "{json}");
3908        assert!(json.contains("\"identifier\":"), "{json}");
3909        assert!(json.contains("\"receivedAt\":"), "{json}");
3910        assert!(json.contains("\"consumedAt\":"), "{json}");
3911    }
3912
3913    #[test]
3914    fn pending_task_info_from_native_response() {
3915        // Simulates the Swift response when a pending task exists
3916        let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
3917        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3918        assert_eq!(info.task_kind, "refresh");
3919        assert_eq!(info.identifier, "com.example.bg-refresh");
3920        assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
3921        assert_eq!(info.consumed_at, None);
3922    }
3923
3924    #[test]
3925    fn pending_task_info_processing_kind() {
3926        let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
3927        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3928        assert_eq!(info.task_kind, "processing");
3929        assert_eq!(info.identifier, "com.example.bg-processing");
3930        assert_eq!(info.consumed_at, None);
3931    }
3932
3933    #[test]
3934    fn pending_task_info_consumed_at_roundtrip() {
3935        let info = PendingTaskInfo {
3936            task_kind: "refresh".into(),
3937            identifier: "com.example.bg-refresh".into(),
3938            received_at: 1700000000.0,
3939            consumed_at: Some(1700000060.5),
3940        };
3941        let json = serde_json::to_string(&info).unwrap();
3942        assert!(json.contains("\"consumedAt\":1700000060.5"), "{json}");
3943        let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
3944        assert_eq!(de.consumed_at, Some(1700000060.5));
3945    }
3946
3947    #[test]
3948    fn pending_task_info_consumed_at_null_deserializes_to_none() {
3949        let json = r#"{"taskKind":"refresh","identifier":"id","receivedAt":1.0,"consumedAt":null}"#;
3950        let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3951        assert_eq!(info.consumed_at, None);
3952    }
3953
3954    // --- from_pending_payload gate (H5/M14 part 1): pending ⟺ consumedAt == nil ---
3955
3956    #[test]
3957    fn from_pending_payload_unconsumed_returns_some() {
3958        // A fresh, unconsumed record surfaces as a pending task.
3959        let value = serde_json::json!({
3960            "taskKind": "refresh",
3961            "identifier": "com.example.bg-refresh",
3962            "receivedAt": 1700000000.0,
3963            "consumedAt": serde_json::Value::Null,
3964        });
3965        let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
3966        assert_eq!(
3967            pending,
3968            Some(PendingTaskInfo {
3969                task_kind: "refresh".into(),
3970                identifier: "com.example.bg-refresh".into(),
3971                received_at: 1700000000.0,
3972                consumed_at: None,
3973            })
3974        );
3975    }
3976
3977    #[test]
3978    fn from_pending_payload_consumed_returns_none() {
3979        // H5: a record carrying a non-null `consumedAt` is stale — it must not
3980        // re-arm a cold auto-start. The old `taskKind.is_null()`-only gate would
3981        // have surfaced this as pending; the consumedAt gate suppresses it.
3982        let value = serde_json::json!({
3983            "taskKind": "processing",
3984            "identifier": "com.example.bg-processing",
3985            "receivedAt": 1700000000.0,
3986            "consumedAt": 1700000060.5,
3987        });
3988        let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
3989        assert_eq!(pending, None);
3990    }
3991
3992    #[test]
3993    fn from_pending_payload_no_record_returns_none() {
3994        // The all-null payload Swift resolves when nothing is pending.
3995        let value = serde_json::json!({
3996            "taskKind": serde_json::Value::Null,
3997            "identifier": serde_json::Value::Null,
3998            "receivedAt": serde_json::Value::Null,
3999            "consumedAt": serde_json::Value::Null,
4000        });
4001        let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4002        assert_eq!(pending, None);
4003    }
4004
4005    // --- LifecycleState tests ---
4006
4007    #[test]
4008    fn lifecycle_state_all_variants_serde_roundtrip() {
4009        for variant in [
4010            LifecycleState::Idle,
4011            LifecycleState::Starting,
4012            LifecycleState::Running,
4013            LifecycleState::Stopping,
4014            LifecycleState::Stopped,
4015            LifecycleState::Recovering,
4016            LifecycleState::RecoveryPending,
4017            LifecycleState::Expired,
4018            LifecycleState::Blocked,
4019            LifecycleState::Error,
4020        ] {
4021            let json = serde_json::to_string(&variant).unwrap();
4022            let de: LifecycleState = serde_json::from_str(&json).unwrap();
4023            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4024        }
4025    }
4026
4027    #[test]
4028    fn lifecycle_state_json_values_are_camel_case() {
4029        assert_eq!(
4030            serde_json::to_string(&LifecycleState::Idle).unwrap(),
4031            "\"idle\""
4032        );
4033        assert_eq!(
4034            serde_json::to_string(&LifecycleState::Starting).unwrap(),
4035            "\"starting\""
4036        );
4037        assert_eq!(
4038            serde_json::to_string(&LifecycleState::Running).unwrap(),
4039            "\"running\""
4040        );
4041        assert_eq!(
4042            serde_json::to_string(&LifecycleState::Stopping).unwrap(),
4043            "\"stopping\""
4044        );
4045        assert_eq!(
4046            serde_json::to_string(&LifecycleState::Stopped).unwrap(),
4047            "\"stopped\""
4048        );
4049        assert_eq!(
4050            serde_json::to_string(&LifecycleState::Recovering).unwrap(),
4051            "\"recovering\""
4052        );
4053        assert_eq!(
4054            serde_json::to_string(&LifecycleState::RecoveryPending).unwrap(),
4055            "\"recoveryPending\""
4056        );
4057        assert_eq!(
4058            serde_json::to_string(&LifecycleState::Expired).unwrap(),
4059            "\"expired\""
4060        );
4061        assert_eq!(
4062            serde_json::to_string(&LifecycleState::Blocked).unwrap(),
4063            "\"blocked\""
4064        );
4065        assert_eq!(
4066            serde_json::to_string(&LifecycleState::Error).unwrap(),
4067            "\"error\""
4068        );
4069    }
4070
4071    // --- ServiceState → LifecycleState computation tests ---
4072
4073    #[test]
4074    fn service_state_idle_maps_to_lifecycle_idle() {
4075        assert_eq!(
4076            LifecycleState::from(ServiceState::Idle),
4077            LifecycleState::Idle
4078        );
4079    }
4080
4081    #[test]
4082    fn service_state_initializing_maps_to_lifecycle_starting() {
4083        assert_eq!(
4084            LifecycleState::from(ServiceState::Initializing),
4085            LifecycleState::Starting
4086        );
4087    }
4088
4089    #[test]
4090    fn service_state_running_maps_to_lifecycle_running() {
4091        assert_eq!(
4092            LifecycleState::from(ServiceState::Running),
4093            LifecycleState::Running
4094        );
4095    }
4096
4097    #[test]
4098    fn service_state_stopped_maps_to_lifecycle_stopped() {
4099        assert_eq!(
4100            LifecycleState::from(ServiceState::Stopped),
4101            LifecycleState::Stopped
4102        );
4103    }
4104
4105    // --- Severity tests ---
4106
4107    #[test]
4108    fn severity_all_variants_serde_roundtrip() {
4109        for variant in [Severity::Error, Severity::Warning, Severity::Info] {
4110            let json = serde_json::to_string(&variant).unwrap();
4111            let de: Severity = serde_json::from_str(&json).unwrap();
4112            assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4113        }
4114    }
4115
4116    #[test]
4117    fn severity_json_values_are_camel_case() {
4118        assert_eq!(
4119            serde_json::to_string(&Severity::Error).unwrap(),
4120            "\"error\""
4121        );
4122        assert_eq!(
4123            serde_json::to_string(&Severity::Warning).unwrap(),
4124            "\"warning\""
4125        );
4126        assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
4127    }
4128
4129    // --- ValidationIssue tests ---
4130
4131    #[test]
4132    fn validation_issue_serde_roundtrip() {
4133        let issue = ValidationIssue {
4134            severity: Severity::Error,
4135            code: "ANDROID_MISSING_PERMISSION".into(),
4136            message: "Missing FOREGROUND_SERVICE permission".into(),
4137            fix: Some("Add FOREGROUND_SERVICE permission to AndroidManifest.xml".into()),
4138            platform: Platform::Android,
4139        };
4140        let json = serde_json::to_string(&issue).unwrap();
4141        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4142        assert_eq!(de, issue);
4143    }
4144
4145    #[test]
4146    fn validation_issue_without_fix() {
4147        let issue = ValidationIssue {
4148            severity: Severity::Warning,
4149            code: "IOS_SCHEDULER_BUSY".into(),
4150            message: "BGTaskScheduler is busy".into(),
4151            fix: None,
4152            platform: Platform::Ios,
4153        };
4154        let json = serde_json::to_string(&issue).unwrap();
4155        assert!(
4156            !json.contains("fix"),
4157            "fix should be absent when None: {json}"
4158        );
4159        let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4160        assert_eq!(de, issue);
4161    }
4162
4163    #[test]
4164    fn validation_issue_json_keys_camel_case() {
4165        let issue = ValidationIssue {
4166            severity: Severity::Info,
4167            code: "TEST".into(),
4168            message: "test".into(),
4169            fix: Some("do something".into()),
4170            platform: Platform::Linux,
4171        };
4172        let json = serde_json::to_string(&issue).unwrap();
4173        assert!(json.contains("\"severity\":"), "{json}");
4174        assert!(json.contains("\"code\":"), "{json}");
4175        assert!(json.contains("\"message\":"), "{json}");
4176        assert!(json.contains("\"fix\":"), "{json}");
4177        assert!(json.contains("\"platform\":"), "{json}");
4178    }
4179
4180    // --- LifecycleStatus tests ---
4181
4182    #[test]
4183    fn lifecycle_status_serde_roundtrip_minimal() {
4184        let status = LifecycleStatus {
4185            state: LifecycleState::Idle,
4186            desired_running: false,
4187            recovery_enabled: false,
4188            recovery_pending: false,
4189            recovery_reason: None,
4190            last_start_config: None,
4191            last_platform_state: None,
4192            last_platform_error: None,
4193            last_error: None,
4194            platform: Platform::Unknown,
4195            capabilities: PlatformCapabilities {
4196                platform: Platform::Unknown,
4197                lifecycle_mode: LifecycleMode::DesktopInProcess,
4198                survives_app_close: LifecycleGuarantee::Unsupported,
4199                survives_reboot: LifecycleGuarantee::Unsupported,
4200                survives_force_quit: LifecycleGuarantee::Unsupported,
4201                background_execution: LifecycleGuarantee::Unsupported,
4202                limitations: vec![],
4203                required_setup: vec![],
4204            },
4205            issues: vec![],
4206            native_running: None,
4207            native_foreground: None,
4208            adopted: None,
4209            degraded: None,
4210            degraded_reason: None,
4211            data_dir: None,
4212        };
4213        let json = serde_json::to_string(&status).unwrap();
4214        let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4215        assert_eq!(de.state, LifecycleState::Idle);
4216        assert!(!de.desired_running);
4217        assert!(!de.recovery_enabled);
4218        assert!(!de.recovery_pending);
4219        assert_eq!(de.recovery_reason, None);
4220        assert_eq!(de.last_start_config, None);
4221        assert_eq!(de.last_platform_state, None);
4222        assert_eq!(de.last_platform_error, None);
4223        assert_eq!(de.last_error, None);
4224        assert_eq!(de.platform, Platform::Unknown);
4225        assert!(de.issues.is_empty());
4226    }
4227
4228    #[test]
4229    fn lifecycle_status_optional_fields_absent_when_none() {
4230        let status = LifecycleStatus {
4231            state: LifecycleState::Idle,
4232            desired_running: false,
4233            recovery_enabled: false,
4234            recovery_pending: false,
4235            recovery_reason: None,
4236            last_start_config: None,
4237            last_platform_state: None,
4238            last_platform_error: None,
4239            last_error: None,
4240            platform: Platform::Unknown,
4241            capabilities: PlatformCapabilities {
4242                platform: Platform::Unknown,
4243                lifecycle_mode: LifecycleMode::DesktopInProcess,
4244                survives_app_close: LifecycleGuarantee::Unsupported,
4245                survives_reboot: LifecycleGuarantee::Unsupported,
4246                survives_force_quit: LifecycleGuarantee::Unsupported,
4247                background_execution: LifecycleGuarantee::Unsupported,
4248                limitations: vec![],
4249                required_setup: vec![],
4250            },
4251            issues: vec![],
4252            native_running: None,
4253            native_foreground: None,
4254            adopted: None,
4255            degraded: None,
4256            degraded_reason: None,
4257            data_dir: None,
4258        };
4259        let json = serde_json::to_string(&status).unwrap();
4260        assert!(!json.contains("recoveryReason"), "should be absent: {json}");
4261        assert!(
4262            !json.contains("lastStartConfig"),
4263            "should be absent: {json}"
4264        );
4265        assert!(
4266            !json.contains("lastPlatformState"),
4267            "should be absent: {json}"
4268        );
4269        assert!(
4270            !json.contains("lastPlatformError"),
4271            "should be absent: {json}"
4272        );
4273        assert!(!json.contains("lastError"), "should be absent: {json}");
4274        assert!(!json.contains("nativeRunning"), "should be absent: {json}");
4275        assert!(
4276            !json.contains("nativeForeground"),
4277            "should be absent: {json}"
4278        );
4279        assert!(!json.contains("adopted"), "should be absent: {json}");
4280        assert!(!json.contains("degraded"), "should be absent: {json}");
4281        assert!(!json.contains("degradedReason"), "should be absent: {json}");
4282    }
4283
4284    #[test]
4285    fn lifecycle_status_full_roundtrip_with_all_fields() {
4286        let status = LifecycleStatus {
4287            state: LifecycleState::Running,
4288            desired_running: true,
4289            recovery_enabled: true,
4290            recovery_pending: false,
4291            recovery_reason: Some("boot recovery".into()),
4292            last_start_config: Some(StartConfig {
4293                service_label: "Sync".into(),
4294                foreground_service_type: "dataSync".into(),
4295            }),
4296            last_platform_state: Some("running".into()),
4297            last_platform_error: Some("timeout exceeded".into()),
4298            last_error: Some("previous crash".into()),
4299            platform: Platform::Android,
4300            capabilities: PlatformCapabilities {
4301                platform: Platform::Android,
4302                lifecycle_mode: LifecycleMode::AndroidForegroundService,
4303                survives_app_close: LifecycleGuarantee::BestEffort,
4304                survives_reboot: LifecycleGuarantee::BestEffort,
4305                survives_force_quit: LifecycleGuarantee::Unsupported,
4306                background_execution: LifecycleGuarantee::Guaranteed,
4307                limitations: vec!["OEM battery optimization".into()],
4308                required_setup: vec!["FOREGROUND_SERVICE permission".into()],
4309            },
4310            issues: vec![ValidationIssue {
4311                severity: Severity::Warning,
4312                code: "ANDROID_BATTERY_OPTIMIZED".into(),
4313                message: "Battery optimization may kill the service".into(),
4314                fix: Some("Request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS".into()),
4315                platform: Platform::Android,
4316            }],
4317            native_running: Some(true),
4318            native_foreground: Some(true),
4319            adopted: Some(false),
4320            degraded: Some(false),
4321            degraded_reason: None,
4322            data_dir: None,
4323        };
4324        let json = serde_json::to_string(&status).unwrap();
4325        let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4326        assert_eq!(de.state, LifecycleState::Running);
4327        assert!(de.desired_running);
4328        assert!(de.recovery_enabled);
4329        assert!(!de.recovery_pending);
4330        assert_eq!(de.recovery_reason, Some("boot recovery".into()));
4331        assert!(de.last_start_config.is_some());
4332        assert_eq!(de.last_platform_state, Some("running".into()));
4333        assert_eq!(de.last_platform_error, Some("timeout exceeded".into()));
4334        assert_eq!(de.last_error, Some("previous crash".into()));
4335        assert_eq!(de.platform, Platform::Android);
4336        assert_eq!(de.issues.len(), 1);
4337        assert_eq!(de.native_running, Some(true));
4338        assert_eq!(de.native_foreground, Some(true));
4339        assert_eq!(de.adopted, Some(false));
4340        assert_eq!(de.degraded, Some(false));
4341        assert_eq!(de.degraded_reason, None);
4342    }
4343
4344    #[test]
4345    fn lifecycle_status_json_keys_camel_case() {
4346        let status = LifecycleStatus {
4347            state: LifecycleState::RecoveryPending,
4348            desired_running: true,
4349            recovery_enabled: true,
4350            recovery_pending: true,
4351            recovery_reason: Some("platform timeout".into()),
4352            last_start_config: None,
4353            last_platform_state: Some("timeout".into()),
4354            last_platform_error: None,
4355            last_error: None,
4356            platform: Platform::Ios,
4357            capabilities: PlatformCapabilities {
4358                platform: Platform::Ios,
4359                lifecycle_mode: LifecycleMode::IosBgTaskScheduler,
4360                survives_app_close: LifecycleGuarantee::BestEffort,
4361                survives_reboot: LifecycleGuarantee::BestEffort,
4362                survives_force_quit: LifecycleGuarantee::Unsupported,
4363                background_execution: LifecycleGuarantee::BestEffort,
4364                limitations: vec![],
4365                required_setup: vec![],
4366            },
4367            issues: vec![],
4368            native_running: None,
4369            native_foreground: None,
4370            adopted: None,
4371            degraded: None,
4372            degraded_reason: None,
4373            data_dir: None,
4374        };
4375        let json = serde_json::to_string(&status).unwrap();
4376        assert!(json.contains("\"state\":"), "{json}");
4377        assert!(json.contains("\"desiredRunning\":"), "{json}");
4378        assert!(json.contains("\"recoveryEnabled\":"), "{json}");
4379        assert!(json.contains("\"recoveryPending\":"), "{json}");
4380        assert!(json.contains("\"recoveryReason\":"), "{json}");
4381        assert!(json.contains("\"lastPlatformState\":"), "{json}");
4382        assert!(json.contains("\"platform\":"), "{json}");
4383        assert!(json.contains("\"capabilities\":"), "{json}");
4384        assert!(json.contains("\"issues\":"), "{json}");
4385    }
4386
4387    // --- tauri.conf.json camelCase deserialization proof (Step 1) ---
4388
4389    #[test]
4390    fn plugin_config_camel_case_json_deserializes_correctly() {
4391        let json = r#"{
4392            "androidForegroundServiceTypes": ["remoteMessaging", "dataSync"],
4393            "androidOnTimeout": "notifyUser",
4394            "androidNotificationChannelId": "bg_service",
4395            "androidNotificationChannelName": "Background Service",
4396            "androidShowStopAction": true,
4397            "iosSafetyTimeoutSecs": 28.0,
4398            "iosEarliestRefreshBeginMinutes": 15.0,
4399            "iosEarliestProcessingBeginMinutes": 15.0,
4400            "desktopServiceMode": "inProcess"
4401        }"#;
4402        let config: PluginConfig = serde_json::from_str(json).unwrap();
4403        assert_eq!(
4404            config.android_foreground_service_types,
4405            vec!["remoteMessaging", "dataSync"]
4406        );
4407        assert_eq!(config.android_on_timeout, "notifyUser");
4408        assert_eq!(config.android_notification_channel_id, "bg_service");
4409        assert_eq!(
4410            config.android_notification_channel_name,
4411            "Background Service"
4412        );
4413        assert!(config.android_show_stop_action);
4414        assert_eq!(config.ios_safety_timeout_secs, 28.0);
4415        assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
4416        assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
4417        #[cfg(feature = "desktop-service")]
4418        assert_eq!(config.desktop_service_mode, "inProcess");
4419    }
4420
4421    #[test]
4422    fn plugin_config_snake_case_json_falls_back_to_defaults() {
4423        let json = r#"{
4424            "android_foreground_service_types": ["remoteMessaging", "dataSync"],
4425            "desktop_service_mode": "inProcess"
4426        }"#;
4427        let config: PluginConfig = serde_json::from_str(json).unwrap();
4428        // snake_case keys are ignored by #[serde(rename_all = "camelCase")]
4429        assert_eq!(
4430            config.android_foreground_service_types,
4431            vec!["remoteMessaging"],
4432            "snake_case key should fall back to default"
4433        );
4434    }
4435
4436    // --- AndroidServiceState tests ---
4437
4438    #[test]
4439    fn android_service_state_deserialize_full_kotlin_output() {
4440        let json = r#"{
4441            "nativeRunning": true,
4442            "nativeForeground": true,
4443            "desiredRunning": true,
4444            "durableState": "running",
4445            "serviceLabel": "App Service",
4446            "foregroundServiceType": "remoteMessaging",
4447            "notificationId": 9001,
4448            "notificationChannelId": "bg_service",
4449            "recoveryPending": false,
4450            "recoveryReason": null,
4451            "lastPlatformError": null,
4452            "dataDir": "/data/data/com.example.app"
4453        }"#;
4454        let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4455        assert!(state.native_running);
4456        assert!(state.native_foreground);
4457        assert!(state.desired_running);
4458        assert_eq!(state.durable_state, "running");
4459        assert_eq!(state.service_label, Some("App Service".into()));
4460        assert_eq!(
4461            state.foreground_service_type,
4462            Some("remoteMessaging".into())
4463        );
4464        assert_eq!(state.notification_id, Some(9001));
4465        assert_eq!(state.notification_channel_id, Some("bg_service".into()));
4466        assert!(!state.recovery_pending);
4467        assert_eq!(state.recovery_reason, None);
4468        assert_eq!(state.last_platform_error, None);
4469        assert_eq!(state.data_dir, "/data/data/com.example.app");
4470    }
4471
4472    #[test]
4473    fn android_service_state_deserialize_minimal() {
4474        let json = r#"{
4475            "nativeRunning": false,
4476            "nativeForeground": false,
4477            "desiredRunning": false,
4478            "durableState": "stopped",
4479            "recoveryPending": false,
4480            "dataDir": ""
4481        }"#;
4482        let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4483        assert!(!state.native_running);
4484        assert!(!state.native_foreground);
4485        assert!(!state.desired_running);
4486        assert_eq!(state.durable_state, "stopped");
4487        assert_eq!(state.service_label, None);
4488        assert_eq!(state.foreground_service_type, None);
4489        assert_eq!(state.notification_id, None);
4490        assert_eq!(state.notification_channel_id, None);
4491        assert!(!state.recovery_pending);
4492        assert_eq!(state.recovery_reason, None);
4493        assert_eq!(state.last_platform_error, None);
4494        assert_eq!(state.data_dir, "");
4495    }
4496
4497    #[test]
4498    fn android_service_state_serde_roundtrip() {
4499        let state = AndroidServiceState {
4500            native_running: true,
4501            native_foreground: false,
4502            desired_running: true,
4503            durable_state: "starting".into(),
4504            service_label: Some("test".into()),
4505            foreground_service_type: None,
4506            notification_id: None,
4507            notification_channel_id: None,
4508            recovery_pending: true,
4509            recovery_reason: Some("boot".into()),
4510            last_platform_error: Some("crash".into()),
4511            data_dir: "/data/test".into(),
4512        };
4513        let json = serde_json::to_string(&state).unwrap();
4514        let de: AndroidServiceState = serde_json::from_str(&json).unwrap();
4515        assert_eq!(de, state);
4516    }
4517
4518    #[test]
4519    fn android_service_state_optional_fields_absent_when_none() {
4520        let state = AndroidServiceState {
4521            native_running: false,
4522            native_foreground: false,
4523            desired_running: false,
4524            durable_state: "unknown".into(),
4525            service_label: None,
4526            foreground_service_type: None,
4527            notification_id: None,
4528            notification_channel_id: None,
4529            recovery_pending: false,
4530            recovery_reason: None,
4531            last_platform_error: None,
4532            data_dir: "".into(),
4533        };
4534        let json = serde_json::to_string(&state).unwrap();
4535        assert!(!json.contains("serviceLabel"), "absent: {json}");
4536        assert!(!json.contains("foregroundServiceType"), "absent: {json}");
4537        assert!(!json.contains("notificationId"), "absent: {json}");
4538        assert!(!json.contains("notificationChannelId"), "absent: {json}");
4539        assert!(!json.contains("recoveryReason"), "absent: {json}");
4540        assert!(!json.contains("lastPlatformError"), "absent: {json}");
4541    }
4542
4543    // --- LifecycleStatus native state fields tests ---
4544
4545    #[test]
4546    fn lifecycle_status_native_fields_serialize_when_present() {
4547        let status = LifecycleStatus {
4548            state: LifecycleState::Running,
4549            desired_running: true,
4550            recovery_enabled: true,
4551            recovery_pending: false,
4552            recovery_reason: None,
4553            last_start_config: None,
4554            last_platform_state: None,
4555            last_platform_error: None,
4556            last_error: None,
4557            platform: Platform::Android,
4558            capabilities: PlatformCapabilities {
4559                platform: Platform::Android,
4560                lifecycle_mode: LifecycleMode::AndroidForegroundService,
4561                survives_app_close: LifecycleGuarantee::BestEffort,
4562                survives_reboot: LifecycleGuarantee::BestEffort,
4563                survives_force_quit: LifecycleGuarantee::Unsupported,
4564                background_execution: LifecycleGuarantee::Guaranteed,
4565                limitations: vec![],
4566                required_setup: vec![],
4567            },
4568            issues: vec![],
4569            native_running: Some(true),
4570            native_foreground: Some(true),
4571            adopted: Some(false),
4572            degraded: Some(false),
4573            degraded_reason: Some("native running but Rust idle".into()),
4574            data_dir: None,
4575        };
4576        let json = serde_json::to_string(&status).unwrap();
4577        assert!(json.contains("\"nativeRunning\":true"), "{json}");
4578        assert!(json.contains("\"nativeForeground\":true"), "{json}");
4579        assert!(json.contains("\"adopted\":false"), "{json}");
4580        assert!(json.contains("\"degraded\":false"), "{json}");
4581        assert!(
4582            json.contains("\"degradedReason\":\"native running but Rust idle\""),
4583            "{json}"
4584        );
4585    }
4586
4587    #[test]
4588    fn lifecycle_status_native_fields_deserialize_from_json() {
4589        let json = r#"{
4590            "state": "running",
4591            "desiredRunning": true,
4592            "recoveryEnabled": true,
4593            "recoveryPending": false,
4594            "platform": "android",
4595            "capabilities": {
4596                "platform": "android",
4597                "lifecycleMode": "androidForegroundService",
4598                "survivesAppClose": "bestEffort",
4599                "survivesReboot": "bestEffort",
4600                "survivesForceQuit": "unsupported",
4601                "backgroundExecution": "guaranteed",
4602                "limitations": [],
4603                "requiredSetup": []
4604            },
4605            "issues": [],
4606            "nativeRunning": true,
4607            "nativeForeground": false,
4608            "adopted": true,
4609            "degraded": true,
4610            "degradedReason": "split-brain detected"
4611        }"#;
4612        let status: LifecycleStatus = serde_json::from_str(json).unwrap();
4613        assert_eq!(status.native_running, Some(true));
4614        assert_eq!(status.native_foreground, Some(false));
4615        assert_eq!(status.adopted, Some(true));
4616        assert_eq!(status.degraded, Some(true));
4617        assert_eq!(status.degraded_reason, Some("split-brain detected".into()));
4618    }
4619}