1use serde::{Deserialize, Serialize};
8use tauri::Runtime;
9use tokio_util::sync::CancellationToken;
10
11use crate::error::ServiceError;
12use crate::notifier::Notifier;
13
14pub 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
36pub 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
51pub 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
85pub struct ServiceContext<R: Runtime> {
88 pub notifier: Notifier<R>,
90
91 pub app: tauri::AppHandle<R>,
93
94 pub shutdown: CancellationToken,
96
97 #[cfg(mobile)]
100 pub service_label: String,
101
102 #[cfg(mobile)]
105 pub foreground_service_type: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct StartConfig {
112 #[serde(default = "default_label", alias = "label")]
114 pub service_label: String,
115
116 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct PluginConfig {
133 #[serde(default = "default_ios_safety_timeout")]
136 pub ios_safety_timeout_secs: f64,
137
138 #[serde(default = "default_ios_cancel_listener_timeout_secs")]
141 pub ios_cancel_listener_timeout_secs: u64,
142
143 #[serde(default = "default_ios_processing_safety_timeout_secs")]
148 pub ios_processing_safety_timeout_secs: f64,
149
150 #[serde(default = "default_ios_earliest_refresh_begin_minutes")]
153 pub ios_earliest_refresh_begin_minutes: f64,
154
155 #[serde(default = "default_ios_earliest_processing_begin_minutes")]
158 pub ios_earliest_processing_begin_minutes: f64,
159
160 #[serde(default)]
164 pub ios_requires_external_power: bool,
165
166 #[serde(default)]
170 pub ios_requires_network_connectivity: bool,
171
172 #[serde(default = "default_ios_processing_ceiling_multiplier")]
177 pub ios_processing_ceiling_multiplier: f64,
178
179 #[serde(default = "default_channel_capacity")]
183 pub channel_capacity: usize,
184
185 #[serde(default = "default_android_foreground_service_types")]
189 pub android_foreground_service_types: Vec<String>,
190
191 #[serde(default = "default_true")]
195 pub android_validate_foreground_service_type: bool,
196
197 #[serde(default = "default_android_on_timeout")]
203 pub android_on_timeout: String,
204
205 #[serde(default = "default_android_notification_channel_id")]
208 pub android_notification_channel_id: String,
209
210 #[serde(default = "default_android_notification_channel_name")]
213 pub android_notification_channel_name: String,
214
215 #[serde(default = "default_android_notification_id")]
218 pub android_notification_id: u32,
219
220 #[serde(default)]
223 #[serde(skip_serializing_if = "Option::is_none")]
224 pub android_notification_small_icon: Option<String>,
225
226 #[serde(default = "default_true")]
229 pub android_show_stop_action: bool,
230
231 #[serde(default)]
238 pub android_request_notification_permission_on_load: bool,
239
240 #[serde(default)]
248 pub notify_on_timeout: bool,
249
250 #[serde(default)]
257 pub notify_on_recovery: bool,
258
259 #[cfg(feature = "desktop-service")]
263 #[serde(default = "default_desktop_service_mode")]
264 pub desktop_service_mode: String,
265
266 #[cfg(feature = "desktop-service")]
269 #[serde(default)]
270 pub desktop_service_label: Option<String>,
271
272 #[cfg(feature = "desktop-service")]
276 #[serde(default)]
277 pub desktop_service_autostart: bool,
278
279 #[cfg(feature = "desktop-service")]
284 #[serde(default)]
285 pub desktop_start_service_if_missing: bool,
286
287 #[cfg(feature = "desktop-service")]
292 #[serde(default = "default_desktop_service_start_timeout_ms")]
293 pub desktop_service_start_timeout_ms: u64,
294}
295
296fn default_ios_safety_timeout() -> f64 {
297 28.0
298}
299
300fn default_ios_cancel_listener_timeout_secs() -> u64 {
301 14400
302}
303
304fn default_ios_processing_safety_timeout_secs() -> f64 {
305 0.0
306}
307
308fn default_ios_earliest_refresh_begin_minutes() -> f64 {
309 15.0
310}
311
312fn default_ios_earliest_processing_begin_minutes() -> f64 {
313 15.0
314}
315
316fn default_ios_processing_ceiling_multiplier() -> f64 {
317 4.0
318}
319
320fn default_android_foreground_service_types() -> Vec<String> {
321 vec!["remoteMessaging".into()]
322}
323
324fn default_android_on_timeout() -> String {
325 "notifyUser".into()
326}
327
328fn default_android_notification_channel_id() -> String {
329 "bg_service".into()
330}
331
332fn default_android_notification_channel_name() -> String {
333 "Background Service".into()
334}
335
336fn default_android_notification_id() -> u32 {
337 9001
338}
339
340fn default_true() -> bool {
341 true
342}
343
344fn default_channel_capacity() -> usize {
345 16
346}
347
348#[cfg(feature = "desktop-service")]
349fn default_desktop_service_mode() -> String {
350 "inProcess".into()
351}
352
353#[cfg(feature = "desktop-service")]
354fn default_desktop_service_start_timeout_ms() -> u64 {
355 5000
356}
357
358impl Default for StartConfig {
359 fn default() -> Self {
360 Self {
361 service_label: default_label(),
362 foreground_service_type: default_foreground_service_type(),
363 }
364 }
365}
366
367#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
372#[serde(rename_all = "camelCase")]
373#[non_exhaustive]
374pub enum ServiceState {
375 Idle,
377 Initializing,
379 Running,
381 Stopped,
383}
384
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "camelCase")]
391#[non_exhaustive]
392pub enum LifecycleState {
393 Idle,
395 Starting,
397 Running,
399 Stopping,
401 Stopped,
403 Recovering,
405 RecoveryPending,
407 Expired,
409 Blocked,
411 Error,
413 SetupIdle,
415 LockedIdle,
417}
418
419impl From<ServiceState> for LifecycleState {
420 fn from(state: ServiceState) -> Self {
421 match state {
422 ServiceState::Idle => LifecycleState::Idle,
423 ServiceState::Initializing => LifecycleState::Starting,
424 ServiceState::Running => LifecycleState::Running,
425 ServiceState::Stopped => LifecycleState::Stopped,
426 }
427 }
428}
429
430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
436#[serde(rename_all = "camelCase")]
437#[non_exhaustive]
438pub enum NativeState {
439 Idle,
440 Starting,
441 Running,
442 Stopping,
443 Timeout,
444 Expired,
445 Recovering,
446 Error,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
453#[serde(rename_all = "camelCase")]
454pub struct ServiceStatus {
455 pub state: ServiceState,
457 pub last_error: Option<String>,
459
460 #[serde(skip_serializing_if = "Option::is_none")]
463 pub desired_running: Option<bool>,
464 #[serde(skip_serializing_if = "Option::is_none")]
466 pub native_state: Option<NativeState>,
467 #[serde(skip_serializing_if = "Option::is_none")]
469 pub platform_mode: Option<LifecycleMode>,
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub last_start_config: Option<StartConfig>,
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub last_heartbeat_at: Option<u64>,
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub restart_attempt: Option<u32>,
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub recovery_reason: Option<String>,
482 #[serde(skip_serializing_if = "Option::is_none")]
484 pub platform_error: Option<String>,
485}
486
487impl Default for ServiceStatus {
488 fn default() -> Self {
489 Self {
490 state: ServiceState::Idle,
491 last_error: None,
492 desired_running: None,
493 native_state: None,
494 platform_mode: None,
495 last_start_config: None,
496 last_heartbeat_at: None,
497 restart_attempt: None,
498 recovery_reason: None,
499 platform_error: None,
500 }
501 }
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
508#[serde(rename_all = "camelCase")]
509#[non_exhaustive]
510pub enum Platform {
511 Android,
512 Ios,
513 Windows,
514 Macos,
515 Linux,
516 Unknown,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "camelCase")]
522#[non_exhaustive]
523pub enum Severity {
524 Error,
525 Warning,
526 Info,
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(rename_all = "camelCase")]
532#[non_exhaustive]
533pub enum LifecycleMode {
534 AndroidForegroundService,
535 IosBgTaskScheduler,
536 DesktopInProcess,
537 DesktopOsService,
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "camelCase")]
547#[non_exhaustive]
548pub enum LifecycleGuarantee {
549 Guaranteed,
550 BestEffort,
551 Unsupported,
552}
553
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
559#[serde(rename_all = "camelCase")]
560#[non_exhaustive]
561pub struct PlatformCapabilities {
562 pub platform: Platform,
563 pub lifecycle_mode: LifecycleMode,
564 pub survives_app_close: LifecycleGuarantee,
565 pub survives_reboot: LifecycleGuarantee,
566 pub survives_force_quit: LifecycleGuarantee,
567 pub background_execution: LifecycleGuarantee,
568 pub limitations: Vec<String>,
569 pub required_setup: Vec<String>,
570}
571
572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
577#[serde(rename_all = "camelCase")]
578#[non_exhaustive]
579pub enum OsServiceInstallState {
580 NotInstalled,
582 Installed,
584 Running,
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
593#[serde(rename_all = "camelCase")]
594#[non_exhaustive]
595pub struct OsServiceStatus {
596 pub label: String,
598 pub mode: String,
600 pub installed: OsServiceInstallState,
602 pub ipc_connected: bool,
604 #[serde(skip_serializing_if = "Option::is_none")]
606 pub socket_path: Option<String>,
607 #[serde(skip_serializing_if = "Option::is_none")]
609 pub last_error: Option<String>,
610}
611
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
617#[serde(rename_all = "camelCase")]
618#[non_exhaustive]
619pub enum StopReason {
620 UserStop,
622 AppStop,
624 PlatformTimeout,
626 PlatformExpiration,
628 NativeNotificationStop,
630 OsRestart,
632 BootRecovery,
634 TaskCompleted,
636 Error,
638 ProcessExit,
642}
643
644impl<'de> serde::Deserialize<'de> for StopReason {
645 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
646 let s = String::deserialize(deserializer)?;
647 match s.as_str() {
648 "userStop" => Ok(Self::UserStop),
649 "appStop" => Ok(Self::AppStop),
650 "platformTimeout" => Ok(Self::PlatformTimeout),
651 "platformExpiration" => Ok(Self::PlatformExpiration),
652 "nativeNotificationStop" => Ok(Self::NativeNotificationStop),
653 "osRestart" => Ok(Self::OsRestart),
654 "bootRecovery" => Ok(Self::BootRecovery),
655 "taskCompleted" => Ok(Self::TaskCompleted),
656 "error" => Ok(Self::Error),
657 "processExit" => Ok(Self::ProcessExit),
658 "completed" => Ok(Self::TaskCompleted),
660 "cancelled" | "user" => Ok(Self::UserStop),
661 _ => Err(serde::de::Error::unknown_variant(
662 &s,
663 &[
664 "userStop",
665 "appStop",
666 "platformTimeout",
667 "platformExpiration",
668 "nativeNotificationStop",
669 "osRestart",
670 "bootRecovery",
671 "taskCompleted",
672 "error",
673 "processExit",
674 ],
675 )),
676 }
677 }
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(rename_all = "camelCase", tag = "type")]
687#[non_exhaustive]
688pub enum NativeLifecycleEvent {
689 AndroidNotificationStop,
691 AndroidTimeout {
693 #[serde(skip_serializing_if = "Option::is_none")]
695 fgs_type: Option<String>,
696 },
697 AndroidOsRestartAccepted,
700 AndroidBootRecoveryAccepted,
703 IosBgTaskExpired,
708}
709
710impl NativeLifecycleEvent {
711 pub fn to_stop_reason(&self) -> StopReason {
713 match self {
714 Self::AndroidNotificationStop => StopReason::NativeNotificationStop,
715 Self::AndroidTimeout { .. } => StopReason::PlatformTimeout,
716 Self::AndroidOsRestartAccepted => StopReason::OsRestart,
717 Self::AndroidBootRecoveryAccepted => StopReason::BootRecovery,
718 Self::IosBgTaskExpired => StopReason::PlatformExpiration,
719 }
720 }
721
722 pub fn is_recovery_acceptance(&self) -> bool {
726 matches!(
727 self,
728 Self::AndroidOsRestartAccepted | Self::AndroidBootRecoveryAccepted
729 )
730 }
731}
732
733#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
735#[serde(rename_all = "camelCase", tag = "type")]
736#[non_exhaustive]
737pub enum PluginEvent {
738 Started,
740 Stopped { reason: StopReason },
742 Error { message: String },
744}
745
746impl Default for PluginConfig {
747 fn default() -> Self {
748 Self {
749 ios_safety_timeout_secs: default_ios_safety_timeout(),
750 ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
751 ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
752 ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
753 ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
754 ios_requires_external_power: false,
755 ios_requires_network_connectivity: false,
756 ios_processing_ceiling_multiplier: default_ios_processing_ceiling_multiplier(),
757 channel_capacity: default_channel_capacity(),
758 android_foreground_service_types: default_android_foreground_service_types(),
759 android_validate_foreground_service_type: default_true(),
760 android_on_timeout: default_android_on_timeout(),
761 android_notification_channel_id: default_android_notification_channel_id(),
762 android_notification_channel_name: default_android_notification_channel_name(),
763 android_notification_id: default_android_notification_id(),
764 android_notification_small_icon: None,
765 android_show_stop_action: default_true(),
766 android_request_notification_permission_on_load: false,
767 notify_on_timeout: false,
768 notify_on_recovery: false,
769 #[cfg(feature = "desktop-service")]
770 desktop_service_mode: default_desktop_service_mode(),
771 #[cfg(feature = "desktop-service")]
772 desktop_service_label: None,
773 #[cfg(feature = "desktop-service")]
774 desktop_service_autostart: false,
775 #[cfg(feature = "desktop-service")]
776 desktop_start_service_if_missing: false,
777 #[cfg(feature = "desktop-service")]
778 desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
779 }
780 }
781}
782
783impl PluginConfig {
792 pub fn validate(&self) -> Result<(), crate::error::ServiceError> {
796 use crate::error::ServiceError;
797 use ServiceError::Platform;
798
799 if !self.ios_safety_timeout_secs.is_finite() || self.ios_safety_timeout_secs <= 0.0 {
802 return Err(Platform(
803 "iosSafetyTimeoutSecs must be a finite positive number".into(),
804 ));
805 }
806 if self.ios_cancel_listener_timeout_secs == 0 {
808 return Err(Platform(
809 "iosCancelListenerTimeoutSecs must be greater than 0".into(),
810 ));
811 }
812 if !self.ios_processing_safety_timeout_secs.is_finite()
815 || self.ios_processing_safety_timeout_secs < 0.0
816 {
817 return Err(Platform(
818 "iosProcessingSafetyTimeoutSecs must be finite and non-negative (0 = uncapped)"
819 .into(),
820 ));
821 }
822 for (name, value) in [
826 (
827 "iosEarliestRefreshBeginMinutes",
828 self.ios_earliest_refresh_begin_minutes,
829 ),
830 (
831 "iosEarliestProcessingBeginMinutes",
832 self.ios_earliest_processing_begin_minutes,
833 ),
834 ] {
835 if !value.is_finite() || value < 0.0 {
836 return Err(Platform(format!("{name} must be finite and non-negative")));
837 }
838 }
839 if !self.ios_processing_ceiling_multiplier.is_finite()
841 || self.ios_processing_ceiling_multiplier < 1.0
842 {
843 return Err(Platform(
844 "iosProcessingCeilingMultiplier must be finite and >= 1".into(),
845 ));
846 }
847
848 if self.channel_capacity == 0 {
851 return Err(Platform(
852 "channelCapacity must be greater than 0 (mpsc::channel panics at 0)".into(),
853 ));
854 }
855
856 if self.android_notification_id == 0 || self.android_notification_id > i32::MAX as u32 {
861 return Err(Platform(format!(
862 "androidNotificationId must be in 1..=i32::MAX (got {})",
863 self.android_notification_id
864 )));
865 }
866 if self.android_notification_channel_id.trim().is_empty() {
867 return Err(Platform(
868 "androidNotificationChannelId must be non-empty".into(),
869 ));
870 }
871 if self.android_notification_channel_name.trim().is_empty() {
872 return Err(Platform(
873 "androidNotificationChannelName must be non-empty".into(),
874 ));
875 }
876 match self.android_on_timeout.as_str() {
878 "stop" | "notifyUser" | "scheduleRecovery" => {}
879 other => {
880 return Err(Platform(format!(
881 "androidOnTimeout must be one of stop|notifyUser|scheduleRecovery (got {other:?})"
882 )));
883 }
884 }
885
886 #[cfg(feature = "desktop-service")]
888 {
889 match self.desktop_service_mode.as_str() {
890 "inProcess" | "osService" => {}
891 other => {
892 return Err(Platform(format!(
893 "desktopServiceMode must be inProcess|osService (got {other:?})"
894 )));
895 }
896 }
897 if self.desktop_service_start_timeout_ms == 0 {
898 return Err(Platform(
899 "desktopServiceStartTimeoutMs must be greater than 0".into(),
900 ));
901 }
902 }
903
904 Ok(())
905 }
906}
907
908#[derive(Debug, Serialize)]
912#[serde(rename_all = "camelCase")]
913#[allow(dead_code)]
914pub(crate) struct StartKeepaliveArgs<'a> {
915 pub label: &'a str,
916 pub foreground_service_type: &'a str,
917 #[serde(skip_serializing_if = "Option::is_none")]
919 pub ios_safety_timeout_secs: Option<f64>,
920 #[serde(skip_serializing_if = "Option::is_none")]
923 pub ios_processing_safety_timeout_secs: Option<f64>,
924 #[serde(skip_serializing_if = "Option::is_none")]
926 pub ios_earliest_refresh_begin_minutes: Option<f64>,
927 #[serde(skip_serializing_if = "Option::is_none")]
929 pub ios_earliest_processing_begin_minutes: Option<f64>,
930 #[serde(skip_serializing_if = "Option::is_none")]
932 pub ios_requires_external_power: Option<bool>,
933 #[serde(skip_serializing_if = "Option::is_none")]
935 pub ios_requires_network_connectivity: Option<bool>,
936 #[serde(skip_serializing_if = "Option::is_none")]
938 pub ios_processing_ceiling_multiplier: Option<f64>,
939}
940
941#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
948#[serde(rename_all = "camelCase")]
949#[non_exhaustive]
950pub struct PendingTaskInfo {
951 pub task_kind: String,
953 pub identifier: String,
955 pub received_at: f64,
957 #[serde(default)]
960 pub consumed_at: Option<f64>,
961}
962
963impl PendingTaskInfo {
964 pub fn from_pending_payload(
974 value: &serde_json::Value,
975 ) -> Result<Option<Self>, serde_json::Error> {
976 if value.get("taskKind").is_none_or(serde_json::Value::is_null) {
977 return Ok(None);
978 }
979 let info: Self = serde_json::from_value(value.clone())?;
980 if info.consumed_at.is_some() {
981 return Ok(None);
982 }
983 Ok(Some(info))
984 }
985}
986
987#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
993#[serde(rename_all = "camelCase")]
994#[non_exhaustive]
995pub struct IOSSchedulingStatus {
996 pub refresh_scheduled: bool,
998 pub processing_scheduled: bool,
1000 #[serde(default)]
1002 #[serde(skip_serializing_if = "Option::is_none")]
1003 pub refresh_error: Option<String>,
1004 #[serde(default)]
1006 #[serde(skip_serializing_if = "Option::is_none")]
1007 pub processing_error: Option<String>,
1008}
1009
1010#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1023#[serde(rename_all = "camelCase")]
1024#[non_exhaustive]
1025pub struct IOSDesiredStateStatus {
1026 pub desired_running: bool,
1028 #[serde(default)]
1030 #[serde(skip_serializing_if = "Option::is_none")]
1031 pub last_start_config: Option<String>,
1032 #[serde(default)]
1034 #[serde(skip_serializing_if = "Option::is_none")]
1035 pub last_task_kind: Option<String>,
1036 #[serde(default)]
1038 #[serde(skip_serializing_if = "Option::is_none")]
1039 pub last_task_started_at: Option<f64>,
1040 #[serde(default)]
1042 #[serde(skip_serializing_if = "Option::is_none")]
1043 pub last_task_completed_at: Option<f64>,
1044 #[serde(default)]
1046 #[serde(skip_serializing_if = "Option::is_none")]
1047 pub last_schedule_error: Option<String>,
1048 #[serde(default)]
1054 #[serde(skip_serializing_if = "Option::is_none")]
1055 pub last_completion_reason: Option<String>,
1056 #[serde(default)]
1062 #[serde(skip_serializing_if = "Option::is_none")]
1063 pub notification_granted: Option<bool>,
1064}
1065
1066#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1073#[non_exhaustive]
1074pub struct NotificationPermissionStatus {
1075 pub status: String,
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1084#[serde(rename_all = "camelCase")]
1085#[non_exhaustive]
1086pub struct SetupIssue {
1087 pub code: String,
1089 pub message: String,
1091 pub platform: Platform,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1095 pub fix: Option<String>,
1096}
1097
1098impl SetupIssue {
1099 pub fn to_validation_issue(&self, severity: Severity) -> ValidationIssue {
1101 ValidationIssue {
1102 severity,
1103 code: self.code.clone(),
1104 message: self.message.clone(),
1105 fix: self.fix.clone(),
1106 platform: self.platform,
1107 }
1108 }
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1117#[serde(rename_all = "camelCase")]
1118#[non_exhaustive]
1119pub struct SetupValidationReport {
1120 pub ok: bool,
1122 pub errors: Vec<SetupIssue>,
1124 pub warnings: Vec<SetupIssue>,
1126 #[serde(default)]
1132 pub issues: Vec<ValidationIssue>,
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1140#[serde(rename_all = "camelCase")]
1141#[non_exhaustive]
1142pub struct ValidationIssue {
1143 pub severity: Severity,
1144 pub code: String,
1145 pub message: String,
1146 #[serde(skip_serializing_if = "Option::is_none")]
1147 pub fix: Option<String>,
1148 pub platform: Platform,
1149}
1150
1151#[derive(Debug, Clone, Serialize, Deserialize)]
1156#[serde(rename_all = "camelCase")]
1157#[non_exhaustive]
1158pub struct LifecycleStatus {
1159 pub state: LifecycleState,
1160 pub desired_running: bool,
1161 pub recovery_enabled: bool,
1162 pub recovery_pending: bool,
1163 #[serde(skip_serializing_if = "Option::is_none")]
1164 pub recovery_reason: Option<String>,
1165 #[serde(skip_serializing_if = "Option::is_none")]
1166 pub last_start_config: Option<StartConfig>,
1167 #[serde(skip_serializing_if = "Option::is_none")]
1168 pub last_platform_state: Option<String>,
1169 #[serde(skip_serializing_if = "Option::is_none")]
1170 pub last_platform_error: Option<String>,
1171 #[serde(skip_serializing_if = "Option::is_none")]
1172 pub last_error: Option<String>,
1173 pub platform: Platform,
1174 pub capabilities: PlatformCapabilities,
1175 pub issues: Vec<ValidationIssue>,
1176 #[serde(skip_serializing_if = "Option::is_none")]
1177 pub native_running: Option<bool>,
1178 #[serde(skip_serializing_if = "Option::is_none")]
1179 pub native_foreground: Option<bool>,
1180 #[serde(skip_serializing_if = "Option::is_none")]
1181 pub adopted: Option<bool>,
1182 #[serde(skip_serializing_if = "Option::is_none")]
1183 pub degraded: Option<bool>,
1184 #[serde(skip_serializing_if = "Option::is_none")]
1185 pub degraded_reason: Option<String>,
1186 #[serde(skip_serializing_if = "Option::is_none")]
1187 pub data_dir: Option<String>,
1188}
1189
1190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1195#[serde(rename_all = "camelCase")]
1196#[non_exhaustive]
1197pub struct AndroidServiceState {
1198 pub native_running: bool,
1199 pub native_foreground: bool,
1200 pub desired_running: bool,
1201 pub durable_state: String,
1202 #[serde(skip_serializing_if = "Option::is_none")]
1203 pub service_label: Option<String>,
1204 #[serde(skip_serializing_if = "Option::is_none")]
1205 pub foreground_service_type: Option<String>,
1206 #[serde(skip_serializing_if = "Option::is_none")]
1207 pub notification_id: Option<i64>,
1208 #[serde(skip_serializing_if = "Option::is_none")]
1209 pub notification_channel_id: Option<String>,
1210 pub recovery_pending: bool,
1211 #[serde(skip_serializing_if = "Option::is_none")]
1212 pub recovery_reason: Option<String>,
1213 #[serde(skip_serializing_if = "Option::is_none")]
1214 pub last_platform_error: Option<String>,
1215 pub data_dir: String,
1216}
1217
1218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1230#[serde(rename_all = "camelCase")]
1231#[non_exhaustive]
1232pub struct IosNativeState {
1233 pub desired_running: bool,
1236 #[serde(default)]
1239 pub refresh_scheduled: bool,
1240 #[serde(default)]
1243 pub processing_scheduled: bool,
1244 #[serde(default)]
1248 #[serde(skip_serializing_if = "Option::is_none")]
1249 pub active_task_kind: Option<String>,
1250 #[serde(default)]
1253 #[serde(skip_serializing_if = "Option::is_none")]
1254 pub pending_task: Option<PendingTaskInfo>,
1255 #[serde(default)]
1258 #[serde(skip_serializing_if = "Option::is_none")]
1259 pub last_completed_at: Option<f64>,
1260 #[serde(default)]
1265 #[serde(skip_serializing_if = "Option::is_none")]
1266 pub last_completion_reason: Option<String>,
1267 #[serde(default)]
1271 #[serde(skip_serializing_if = "Option::is_none")]
1272 pub last_refresh_error: Option<String>,
1273 #[serde(default)]
1276 #[serde(skip_serializing_if = "Option::is_none")]
1277 pub last_processing_error: Option<String>,
1278 pub in_budget: bool,
1281}
1282
1283#[cfg(test)]
1284#[allow(clippy::field_reassign_with_default)]
1285mod tests {
1286 use super::*;
1287
1288 #[test]
1291 fn default_foreground_service_type_is_remote_messaging() {
1292 assert_eq!(default_foreground_service_type(), "remoteMessaging");
1293 }
1294
1295 #[test]
1296 fn default_android_foreground_service_types_is_remote_messaging() {
1297 assert_eq!(
1298 default_android_foreground_service_types(),
1299 vec!["remoteMessaging"]
1300 );
1301 }
1302
1303 #[test]
1304 fn start_config_default_uses_remote_messaging() {
1305 let config = StartConfig::default();
1306 assert_eq!(config.foreground_service_type, "remoteMessaging");
1307 }
1308
1309 #[test]
1312 fn start_config_default_label() {
1313 let config = StartConfig::default();
1314 assert_eq!(config.service_label, "Service running");
1315 }
1316
1317 #[test]
1318 fn start_config_custom_label() {
1319 let config = StartConfig {
1320 service_label: "Syncing data".into(),
1321 ..Default::default()
1322 };
1323 assert_eq!(config.service_label, "Syncing data");
1324 }
1325
1326 #[test]
1327 fn start_config_serde_roundtrip_default() {
1328 let config = StartConfig::default();
1329 let json = serde_json::to_string(&config).unwrap();
1330 let de: StartConfig = serde_json::from_str(&json).unwrap();
1331 assert_eq!(de.service_label, config.service_label);
1332 }
1333
1334 #[test]
1335 fn start_config_serde_roundtrip_custom() {
1336 let config = StartConfig {
1337 service_label: "My service".into(),
1338 ..Default::default()
1339 };
1340 let json = serde_json::to_string(&config).unwrap();
1341 let de: StartConfig = serde_json::from_str(&json).unwrap();
1342 assert_eq!(de.service_label, "My service");
1343 }
1344
1345 #[test]
1346 fn start_config_deserialize_missing_field_uses_default() {
1347 let json = "{}";
1349 let de: StartConfig = serde_json::from_str(json).unwrap();
1350 assert_eq!(de.service_label, "Service running");
1351 }
1352
1353 #[test]
1354 fn start_config_json_key_is_camel_case() {
1355 let config = StartConfig {
1356 service_label: "test".into(),
1357 ..Default::default()
1358 };
1359 let json = serde_json::to_string(&config).unwrap();
1360 assert!(
1361 json.contains("serviceLabel"),
1362 "JSON should use camelCase: {json}"
1363 );
1364 }
1365
1366 #[test]
1369 fn start_config_legacy_label_alias_decodes() {
1370 let json = r#"{"label":"Legacy name"}"#;
1371 let de: StartConfig = serde_json::from_str(json).unwrap();
1372 assert_eq!(de.service_label, "Legacy name");
1373 }
1374
1375 #[test]
1376 fn start_config_both_label_and_service_label_rejected() {
1377 let json = r#"{"serviceLabel":"New name","label":"Old name"}"#;
1378 let result = serde_json::from_str::<StartConfig>(json);
1379 assert!(result.is_err(), "should reject duplicate field via alias");
1380 }
1381
1382 #[test]
1383 fn start_config_unknown_fields_ignored() {
1384 let json = r#"{"serviceLabel":"test","unknownField":42,"extra":"data"}"#;
1385 let de: StartConfig = serde_json::from_str(json).unwrap();
1386 assert_eq!(de.service_label, "test");
1387 assert_eq!(de.foreground_service_type, "remoteMessaging");
1388 }
1389
1390 #[test]
1391 fn start_config_camel_case_key_still_works() {
1392 let json = r#"{"serviceLabel":"Modern name"}"#;
1393 let de: StartConfig = serde_json::from_str(json).unwrap();
1394 assert_eq!(de.service_label, "Modern name");
1395 }
1396
1397 #[test]
1400 fn plugin_event_started_serde_roundtrip() {
1401 let event = PluginEvent::Started;
1402 let json = serde_json::to_string(&event).unwrap();
1403 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1404 assert!(matches!(de, PluginEvent::Started));
1405 }
1406
1407 #[test]
1408 fn plugin_event_stopped_serde_roundtrip() {
1409 let event = PluginEvent::Stopped {
1410 reason: StopReason::UserStop,
1411 };
1412 let json = serde_json::to_string(&event).unwrap();
1413 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1414 match de {
1415 PluginEvent::Stopped { reason } => assert_eq!(reason, StopReason::UserStop),
1416 other => panic!("Expected Stopped, got {other:?}"),
1417 }
1418 }
1419
1420 #[test]
1421 fn plugin_event_error_serde_roundtrip() {
1422 let event = PluginEvent::Error {
1423 message: "init failed".into(),
1424 };
1425 let json = serde_json::to_string(&event).unwrap();
1426 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1427 match de {
1428 PluginEvent::Error { message } => assert_eq!(message, "init failed"),
1429 other => panic!("Expected Error, got {other:?}"),
1430 }
1431 }
1432
1433 #[test]
1434 fn plugin_event_tagged_json_format() {
1435 let event = PluginEvent::Started;
1436 let json = serde_json::to_string(&event).unwrap();
1437 assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
1438 }
1439
1440 #[test]
1441 fn plugin_event_stopped_json_keys_camel_case() {
1442 let event = PluginEvent::Stopped {
1443 reason: StopReason::TaskCompleted,
1444 };
1445 let json = serde_json::to_string(&event).unwrap();
1446 assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
1447 assert!(
1448 json.contains("\"reason\":\"taskCompleted\""),
1449 "Reason: {json}"
1450 );
1451 }
1452
1453 #[test]
1454 fn plugin_event_error_json_keys_camel_case() {
1455 let event = PluginEvent::Error {
1456 message: "oops".into(),
1457 };
1458 let json = serde_json::to_string(&event).unwrap();
1459 assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
1460 assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
1461 }
1462
1463 #[test]
1466 fn stop_reason_all_variants_serialize_to_camel_case() {
1467 assert_eq!(
1468 serde_json::to_string(&StopReason::UserStop).unwrap(),
1469 "\"userStop\""
1470 );
1471 assert_eq!(
1472 serde_json::to_string(&StopReason::AppStop).unwrap(),
1473 "\"appStop\""
1474 );
1475 assert_eq!(
1476 serde_json::to_string(&StopReason::PlatformTimeout).unwrap(),
1477 "\"platformTimeout\""
1478 );
1479 assert_eq!(
1480 serde_json::to_string(&StopReason::PlatformExpiration).unwrap(),
1481 "\"platformExpiration\""
1482 );
1483 assert_eq!(
1484 serde_json::to_string(&StopReason::NativeNotificationStop).unwrap(),
1485 "\"nativeNotificationStop\""
1486 );
1487 assert_eq!(
1488 serde_json::to_string(&StopReason::OsRestart).unwrap(),
1489 "\"osRestart\""
1490 );
1491 assert_eq!(
1492 serde_json::to_string(&StopReason::BootRecovery).unwrap(),
1493 "\"bootRecovery\""
1494 );
1495 assert_eq!(
1496 serde_json::to_string(&StopReason::TaskCompleted).unwrap(),
1497 "\"taskCompleted\""
1498 );
1499 assert_eq!(
1500 serde_json::to_string(&StopReason::Error).unwrap(),
1501 "\"error\""
1502 );
1503 assert_eq!(
1504 serde_json::to_string(&StopReason::ProcessExit).unwrap(),
1505 "\"processExit\""
1506 );
1507 }
1508
1509 #[test]
1510 fn stop_reason_roundtrip_all_variants() {
1511 for variant in [
1512 StopReason::UserStop,
1513 StopReason::AppStop,
1514 StopReason::PlatformTimeout,
1515 StopReason::PlatformExpiration,
1516 StopReason::NativeNotificationStop,
1517 StopReason::OsRestart,
1518 StopReason::BootRecovery,
1519 StopReason::TaskCompleted,
1520 StopReason::Error,
1521 StopReason::ProcessExit,
1522 ] {
1523 let json = serde_json::to_string(&variant).unwrap();
1524 let de: StopReason = serde_json::from_str(&json).unwrap();
1525 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
1526 }
1527 }
1528
1529 #[test]
1530 fn stop_reason_process_exit_deserializes_from_camel_case() {
1531 let de: StopReason = serde_json::from_str("\"processExit\"").unwrap();
1533 assert_eq!(de, StopReason::ProcessExit);
1534 }
1535
1536 #[test]
1537 fn stop_reason_legacy_completed_maps_to_task_completed() {
1538 let json = "\"completed\"";
1539 let de: StopReason = serde_json::from_str(json).unwrap();
1540 assert_eq!(de, StopReason::TaskCompleted);
1541 }
1542
1543 #[test]
1544 fn stop_reason_legacy_cancelled_maps_to_user_stop() {
1545 let json = "\"cancelled\"";
1546 let de: StopReason = serde_json::from_str(json).unwrap();
1547 assert_eq!(de, StopReason::UserStop);
1548 }
1549
1550 #[test]
1551 fn stop_reason_legacy_user_maps_to_user_stop() {
1552 let json = "\"user\"";
1553 let de: StopReason = serde_json::from_str(json).unwrap();
1554 assert_eq!(de, StopReason::UserStop);
1555 }
1556
1557 #[test]
1558 fn stop_reason_unknown_variant_returns_error() {
1559 let json = "\"unknownReason\"";
1560 let result = serde_json::from_str::<StopReason>(json);
1561 assert!(
1562 result.is_err(),
1563 "unknown variant should fail to deserialize"
1564 );
1565 }
1566
1567 #[test]
1570 fn native_lifecycle_event_android_notification_stop_roundtrip() {
1571 let event = NativeLifecycleEvent::AndroidNotificationStop;
1572 let json = serde_json::to_string(&event).unwrap();
1573 assert_eq!(json, r#"{"type":"androidNotificationStop"}"#);
1574 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1575 assert_eq!(de, event);
1576 }
1577
1578 #[test]
1579 fn native_lifecycle_event_android_timeout_roundtrip() {
1580 let event = NativeLifecycleEvent::AndroidTimeout {
1581 fgs_type: Some("dataSync".into()),
1582 };
1583 let json = serde_json::to_string(&event).unwrap();
1584 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1585 assert_eq!(de, event);
1586 }
1587
1588 #[test]
1589 fn native_lifecycle_event_android_timeout_without_fgs_type() {
1590 let event = NativeLifecycleEvent::AndroidTimeout { fgs_type: None };
1591 let json = serde_json::to_string(&event).unwrap();
1592 assert!(!json.contains("fgsType"), "{json}");
1594 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1595 assert_eq!(de, event);
1596 }
1597
1598 #[test]
1599 fn native_lifecycle_event_to_stop_reason_mapping() {
1600 assert_eq!(
1601 NativeLifecycleEvent::AndroidNotificationStop.to_stop_reason(),
1602 StopReason::NativeNotificationStop
1603 );
1604 assert_eq!(
1605 NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.to_stop_reason(),
1606 StopReason::PlatformTimeout
1607 );
1608 assert_eq!(
1609 NativeLifecycleEvent::AndroidTimeout {
1610 fgs_type: Some("dataSync".into())
1611 }
1612 .to_stop_reason(),
1613 StopReason::PlatformTimeout
1614 );
1615 }
1616
1617 #[test]
1618 fn native_lifecycle_event_recovery_acceptance_roundtrips() {
1619 let event = NativeLifecycleEvent::AndroidOsRestartAccepted;
1620 let json = serde_json::to_string(&event).unwrap();
1621 assert_eq!(json, r#"{"type":"androidOsRestartAccepted"}"#);
1622 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1623 assert_eq!(de, event);
1624
1625 let event = NativeLifecycleEvent::AndroidBootRecoveryAccepted;
1626 let json = serde_json::to_string(&event).unwrap();
1627 assert_eq!(json, r#"{"type":"androidBootRecoveryAccepted"}"#);
1628 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1629 assert_eq!(de, event);
1630 }
1631
1632 #[test]
1633 fn native_lifecycle_event_recovery_acceptance_stop_reasons() {
1634 assert_eq!(
1635 NativeLifecycleEvent::AndroidOsRestartAccepted.to_stop_reason(),
1636 StopReason::OsRestart
1637 );
1638 assert_eq!(
1639 NativeLifecycleEvent::AndroidBootRecoveryAccepted.to_stop_reason(),
1640 StopReason::BootRecovery
1641 );
1642 }
1643
1644 #[test]
1645 fn native_lifecycle_event_recovery_acceptance_classification() {
1646 assert!(NativeLifecycleEvent::AndroidOsRestartAccepted.is_recovery_acceptance());
1647 assert!(NativeLifecycleEvent::AndroidBootRecoveryAccepted.is_recovery_acceptance());
1648 assert!(!NativeLifecycleEvent::AndroidNotificationStop.is_recovery_acceptance());
1649 assert!(!NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.is_recovery_acceptance());
1650 }
1651
1652 #[test]
1656 fn native_lifecycle_event_ios_bg_task_expired() {
1657 let event = NativeLifecycleEvent::IosBgTaskExpired;
1658 assert_eq!(event.to_stop_reason(), StopReason::PlatformExpiration);
1659 assert!(!event.is_recovery_acceptance());
1660
1661 let json = serde_json::to_string(&event).unwrap();
1662 assert_eq!(json, r#"{"type":"iosBgTaskExpired"}"#);
1663 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1664 assert_eq!(de, event);
1665 }
1666
1667 #[test]
1670 fn ios_native_state_serde_roundtrip() {
1671 let state = IosNativeState {
1672 desired_running: true,
1673 refresh_scheduled: true,
1674 processing_scheduled: false,
1675 active_task_kind: Some("refresh".into()),
1676 pending_task: Some(PendingTaskInfo {
1677 task_kind: "processing".into(),
1678 identifier: "com.example.app.bg-processing".into(),
1679 received_at: 1000.0,
1680 consumed_at: None,
1681 }),
1682 last_completed_at: Some(900.0),
1683 last_completion_reason: Some("completed".into()),
1684 last_refresh_error: Some("BGTaskSchedulerErrorDomain code 1".into()),
1685 last_processing_error: None,
1686 in_budget: false,
1687 };
1688 let json = serde_json::to_string(&state).unwrap();
1689 assert!(json.contains("\"desiredRunning\":true"));
1690 assert!(json.contains("\"refreshScheduled\":true"));
1691 assert!(json.contains("\"activeTaskKind\":\"refresh\""));
1692 assert!(json.contains("\"inBudget\":false"));
1693 let de: IosNativeState = serde_json::from_str(&json).unwrap();
1694 assert_eq!(de, state);
1695 }
1696
1697 #[test]
1699 fn ios_native_state_defaults_optional_fields() {
1700 let json = r#"{"desiredRunning":false,"inBudget":true}"#;
1701 let de: IosNativeState = serde_json::from_str(json).unwrap();
1702 assert!(!de.desired_running);
1703 assert!(de.in_budget);
1704 assert!(!de.refresh_scheduled);
1705 assert!(!de.processing_scheduled);
1706 assert_eq!(de.active_task_kind, None);
1707 assert_eq!(de.pending_task, None);
1708 assert_eq!(de.last_completed_at, None);
1709 assert_eq!(de.last_completion_reason, None);
1710 assert_eq!(de.last_refresh_error, None);
1711 assert_eq!(de.last_processing_error, None);
1712 }
1713
1714 #[test]
1718 fn ios_native_state_answers_seven_questions() {
1719 let state = IosNativeState {
1720 desired_running: true, refresh_scheduled: true, processing_scheduled: true, active_task_kind: Some("refresh".into()), pending_task: Some(PendingTaskInfo {
1725 task_kind: "refresh".into(),
1727 identifier: "com.example.app.bg-refresh".into(),
1728 received_at: 1000.0,
1729 consumed_at: None,
1730 }),
1731 last_completed_at: Some(950.0), last_completion_reason: Some("completed".into()), last_refresh_error: Some("refresh boom".into()), last_processing_error: Some("processing boom".into()), in_budget: false,
1736 };
1737
1738 assert!(state.desired_running);
1740 assert!(state.refresh_scheduled && state.processing_scheduled);
1741 assert!(state.pending_task.is_some());
1742 assert_eq!(state.active_task_kind.as_deref(), Some("refresh"));
1744 assert_eq!(state.last_completed_at, Some(950.0));
1745 assert_eq!(state.last_completion_reason.as_deref(), Some("completed"));
1746 assert_eq!(state.last_refresh_error.as_deref(), Some("refresh boom"));
1748 assert_eq!(
1749 state.last_processing_error.as_deref(),
1750 Some("processing boom")
1751 );
1752 }
1753
1754 #[test]
1758 fn ios_native_state_splits_schedule_errors() {
1759 let state = IosNativeState {
1760 desired_running: true,
1761 refresh_scheduled: false,
1762 processing_scheduled: true,
1763 active_task_kind: None,
1764 pending_task: None,
1765 last_completed_at: None,
1766 last_completion_reason: None,
1767 last_refresh_error: Some("only refresh failed".into()),
1768 last_processing_error: None,
1769 in_budget: true,
1770 };
1771 let json = serde_json::to_string(&state).unwrap();
1772 assert!(
1773 json.contains("\"lastRefreshError\":\"only refresh failed\""),
1774 "{json}"
1775 );
1776 assert!(!json.contains("lastProcessingError"), "{json}");
1779 let de: IosNativeState = serde_json::from_str(&json).unwrap();
1780 assert_eq!(de, state);
1781 assert_eq!(
1782 de.last_refresh_error.as_deref(),
1783 Some("only refresh failed")
1784 );
1785 assert_eq!(de.last_processing_error, None);
1786 }
1787
1788 #[test]
1791 fn ios_native_state_carries_last_completion_reason() {
1792 let state = IosNativeState {
1793 desired_running: true,
1794 refresh_scheduled: true,
1795 processing_scheduled: true,
1796 active_task_kind: None,
1797 pending_task: None,
1798 last_completed_at: Some(900.0),
1799 last_completion_reason: Some("expired".into()),
1800 last_refresh_error: None,
1801 last_processing_error: None,
1802 in_budget: true,
1803 };
1804 let json = serde_json::to_string(&state).unwrap();
1805 assert!(
1806 json.contains("\"lastCompletionReason\":\"expired\""),
1807 "{json}"
1808 );
1809 let de: IosNativeState = serde_json::from_str(&json).unwrap();
1810 assert_eq!(de.last_completion_reason.as_deref(), Some("expired"));
1811 }
1812
1813 #[test]
1814 fn plugin_event_stopped_with_stop_reason_roundtrip() {
1815 let event = PluginEvent::Stopped {
1816 reason: StopReason::TaskCompleted,
1817 };
1818 let json = serde_json::to_string(&event).unwrap();
1819 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1820 assert_eq!(
1821 de,
1822 PluginEvent::Stopped {
1823 reason: StopReason::TaskCompleted
1824 }
1825 );
1826 }
1827
1828 #[test]
1829 fn plugin_event_stopped_legacy_reason_deserializes() {
1830 let json = r#"{"type":"stopped","reason":"completed"}"#;
1832 let de: PluginEvent = serde_json::from_str(json).unwrap();
1833 match de {
1834 PluginEvent::Stopped { reason } => {
1835 assert_eq!(reason, StopReason::TaskCompleted);
1836 }
1837 other => panic!("Expected Stopped, got {other:?}"),
1838 }
1839 }
1840
1841 #[test]
1842 fn plugin_event_stopped_legacy_cancelled_deserializes() {
1843 let json = r#"{"type":"stopped","reason":"cancelled"}"#;
1844 let de: PluginEvent = serde_json::from_str(json).unwrap();
1845 match de {
1846 PluginEvent::Stopped { reason } => {
1847 assert_eq!(reason, StopReason::UserStop);
1848 }
1849 other => panic!("Expected Stopped, got {other:?}"),
1850 }
1851 }
1852
1853 #[test]
1856 fn start_config_default_service_type() {
1857 let config = StartConfig::default();
1858 assert_eq!(config.foreground_service_type, "remoteMessaging");
1859 }
1860
1861 #[test]
1862 fn start_config_custom_service_type() {
1863 let config = StartConfig {
1864 service_label: "test".into(),
1865 foreground_service_type: "specialUse".into(),
1866 };
1867 assert_eq!(config.foreground_service_type, "specialUse");
1868 }
1869
1870 #[test]
1871 fn start_config_serde_roundtrip_service_type() {
1872 let config = StartConfig {
1873 service_label: "test".into(),
1874 foreground_service_type: "specialUse".into(),
1875 };
1876 let json = serde_json::to_string(&config).unwrap();
1877 let de: StartConfig = serde_json::from_str(&json).unwrap();
1878 assert_eq!(de.foreground_service_type, "specialUse");
1879 }
1880
1881 #[test]
1882 fn start_config_deserialize_missing_service_type() {
1883 let json = r#"{"serviceLabel":"test"}"#;
1884 let de: StartConfig = serde_json::from_str(json).unwrap();
1885 assert_eq!(de.foreground_service_type, "remoteMessaging");
1886 }
1887
1888 #[test]
1889 fn start_config_deserialize_special_use() {
1890 let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
1891 let de: StartConfig = serde_json::from_str(json).unwrap();
1892 assert_eq!(de.foreground_service_type, "specialUse");
1893 }
1894
1895 #[test]
1896 fn start_config_unrecognized_type_rejected_by_validation() {
1897 let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
1899 let de: StartConfig = serde_json::from_str(json).unwrap();
1900 assert_eq!(de.foreground_service_type, "customType");
1901 let result = validate_foreground_service_type(&de.foreground_service_type);
1903 assert!(
1904 result.is_err(),
1905 "validation should reject unrecognized type"
1906 );
1907 let err_msg = result.unwrap_err().to_string();
1908 assert!(
1909 err_msg.contains("customType"),
1910 "error should mention the invalid type: {err_msg}"
1911 );
1912 }
1913
1914 #[test]
1915 fn start_config_json_key_is_camel_case_service_type() {
1916 let config = StartConfig {
1917 service_label: "test".into(),
1918 foreground_service_type: "specialUse".into(),
1919 };
1920 let json = serde_json::to_string(&config).unwrap();
1921 assert!(
1922 json.contains("foregroundServiceType"),
1923 "JSON should use camelCase: {json}"
1924 );
1925 }
1926
1927 #[test]
1930 fn plugin_config_default_ios_safety_timeout() {
1931 let json = "{}";
1932 let config: PluginConfig = serde_json::from_str(json).unwrap();
1933 assert_eq!(config.ios_safety_timeout_secs, 28.0);
1934 }
1935
1936 #[test]
1937 fn plugin_config_custom_ios_safety_timeout() {
1938 let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
1939 let config: PluginConfig = serde_json::from_str(json).unwrap();
1940 assert_eq!(config.ios_safety_timeout_secs, 15.0);
1941 }
1942
1943 #[test]
1944 fn plugin_config_serde_roundtrip_preserves_value() {
1945 let config = PluginConfig {
1946 ios_safety_timeout_secs: 30.0,
1947 ios_cancel_listener_timeout_secs: 14400,
1948 ios_processing_safety_timeout_secs: 0.0,
1949 ios_earliest_refresh_begin_minutes: 20.0,
1950 ios_earliest_processing_begin_minutes: 30.0,
1951 ios_requires_external_power: true,
1952 ios_requires_network_connectivity: true,
1953 ..Default::default()
1954 };
1955 let json = serde_json::to_string(&config).unwrap();
1956 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1957 assert_eq!(de.ios_safety_timeout_secs, 30.0);
1958 assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
1959 assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
1960 assert!(de.ios_requires_external_power);
1961 assert!(de.ios_requires_network_connectivity);
1962 }
1963
1964 #[test]
1965 fn plugin_config_default_impl() {
1966 let config = PluginConfig::default();
1967 assert_eq!(config.ios_safety_timeout_secs, 28.0);
1968 assert_eq!(config.channel_capacity, 16);
1969 }
1970
1971 #[test]
1972 fn plugin_config_default_cancel_timeout() {
1973 let json = "{}";
1974 let config: PluginConfig = serde_json::from_str(json).unwrap();
1975 assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
1976 }
1977
1978 #[test]
1979 fn plugin_config_custom_cancel_timeout() {
1980 let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
1981 let config: PluginConfig = serde_json::from_str(json).unwrap();
1982 assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
1983 }
1984
1985 #[test]
1986 fn plugin_config_cancel_timeout_serde_roundtrip() {
1987 let config = PluginConfig {
1988 ios_cancel_listener_timeout_secs: 3600,
1989 ..Default::default()
1990 };
1991 let json = serde_json::to_string(&config).unwrap();
1992 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1993 assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
1994 }
1995
1996 #[test]
1999 fn plugin_config_processing_timeout_default() {
2000 let json = "{}";
2001 let config: PluginConfig = serde_json::from_str(json).unwrap();
2002 assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
2003 }
2004
2005 #[test]
2006 fn plugin_config_processing_timeout_custom() {
2007 let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
2008 let config: PluginConfig = serde_json::from_str(json).unwrap();
2009 assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
2010 }
2011
2012 #[test]
2013 fn plugin_config_processing_timeout_serde_roundtrip() {
2014 let config = PluginConfig {
2015 ios_processing_safety_timeout_secs: 120.0,
2016 ..Default::default()
2017 };
2018 let json = serde_json::to_string(&config).unwrap();
2019 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2020 assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
2021 }
2022
2023 #[test]
2026 fn start_keepalive_args_with_timeout() {
2027 let args = StartKeepaliveArgs {
2028 label: "Test",
2029 foreground_service_type: "dataSync",
2030 ios_safety_timeout_secs: Some(15.0),
2031 ios_processing_safety_timeout_secs: None,
2032 ios_earliest_refresh_begin_minutes: None,
2033 ios_earliest_processing_begin_minutes: None,
2034 ios_requires_external_power: None,
2035 ios_requires_network_connectivity: None,
2036 ios_processing_ceiling_multiplier: None,
2037 };
2038 let json = serde_json::to_string(&args).unwrap();
2039 assert!(
2040 json.contains("\"iosSafetyTimeoutSecs\":15.0"),
2041 "JSON should contain iosSafetyTimeoutSecs: {json}"
2042 );
2043 }
2044
2045 #[test]
2046 fn start_keepalive_args_without_timeout() {
2047 let args = StartKeepaliveArgs {
2048 label: "Test",
2049 foreground_service_type: "dataSync",
2050 ios_safety_timeout_secs: None,
2051 ios_processing_safety_timeout_secs: None,
2052 ios_earliest_refresh_begin_minutes: None,
2053 ios_earliest_processing_begin_minutes: None,
2054 ios_requires_external_power: None,
2055 ios_requires_network_connectivity: None,
2056 ios_processing_ceiling_multiplier: None,
2057 };
2058 let json = serde_json::to_string(&args).unwrap();
2059 assert!(
2060 !json.contains("iosSafetyTimeoutSecs"),
2061 "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
2062 );
2063 }
2064
2065 #[test]
2066 fn start_keepalive_args_processing_timeout() {
2067 let args = StartKeepaliveArgs {
2068 label: "Test",
2069 foreground_service_type: "dataSync",
2070 ios_safety_timeout_secs: None,
2071 ios_processing_safety_timeout_secs: Some(60.0),
2072 ios_earliest_refresh_begin_minutes: None,
2073 ios_earliest_processing_begin_minutes: None,
2074 ios_requires_external_power: None,
2075 ios_requires_network_connectivity: None,
2076 ios_processing_ceiling_multiplier: None,
2077 };
2078 let json = serde_json::to_string(&args).unwrap();
2079 assert!(
2080 json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
2081 "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
2082 );
2083 }
2084
2085 #[test]
2086 fn start_keepalive_args_no_processing_timeout() {
2087 let args = StartKeepaliveArgs {
2088 label: "Test",
2089 foreground_service_type: "dataSync",
2090 ios_safety_timeout_secs: None,
2091 ios_processing_safety_timeout_secs: None,
2092 ios_earliest_refresh_begin_minutes: None,
2093 ios_earliest_processing_begin_minutes: None,
2094 ios_requires_external_power: None,
2095 ios_requires_network_connectivity: None,
2096 ios_processing_ceiling_multiplier: None,
2097 };
2098 let json = serde_json::to_string(&args).unwrap();
2099 assert!(
2100 !json.contains("iosProcessingSafetyTimeoutSecs"),
2101 "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
2102 );
2103 }
2104
2105 #[test]
2106 fn start_keepalive_args_camel_case_keys() {
2107 let args = StartKeepaliveArgs {
2108 label: "Test",
2109 foreground_service_type: "specialUse",
2110 ios_safety_timeout_secs: None,
2111 ios_processing_safety_timeout_secs: None,
2112 ios_earliest_refresh_begin_minutes: None,
2113 ios_earliest_processing_begin_minutes: None,
2114 ios_requires_external_power: None,
2115 ios_requires_network_connectivity: None,
2116 ios_processing_ceiling_multiplier: None,
2117 };
2118 let json = serde_json::to_string(&args).unwrap();
2119 assert!(json.contains("\"label\""), "label: {json}");
2120 assert!(
2121 json.contains("\"foregroundServiceType\""),
2122 "foregroundServiceType: {json}"
2123 );
2124 }
2125
2126 #[test]
2127 fn start_keepalive_args_scheduling_intervals() {
2128 let args = StartKeepaliveArgs {
2129 label: "Test",
2130 foreground_service_type: "dataSync",
2131 ios_safety_timeout_secs: None,
2132 ios_processing_safety_timeout_secs: None,
2133 ios_earliest_refresh_begin_minutes: Some(30.0),
2134 ios_earliest_processing_begin_minutes: Some(60.0),
2135 ios_requires_external_power: None,
2136 ios_requires_network_connectivity: None,
2137 ios_processing_ceiling_multiplier: None,
2138 };
2139 let json = serde_json::to_string(&args).unwrap();
2140 assert!(
2141 json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
2142 "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
2143 );
2144 assert!(
2145 json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
2146 "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
2147 );
2148 }
2149
2150 #[test]
2151 fn start_keepalive_args_processing_options() {
2152 let args = StartKeepaliveArgs {
2153 label: "Test",
2154 foreground_service_type: "dataSync",
2155 ios_safety_timeout_secs: None,
2156 ios_processing_safety_timeout_secs: None,
2157 ios_earliest_refresh_begin_minutes: None,
2158 ios_earliest_processing_begin_minutes: None,
2159 ios_requires_external_power: Some(true),
2160 ios_requires_network_connectivity: Some(true),
2161 ios_processing_ceiling_multiplier: None,
2162 };
2163 let json = serde_json::to_string(&args).unwrap();
2164 assert!(
2165 json.contains("\"iosRequiresExternalPower\":true"),
2166 "JSON should contain iosRequiresExternalPower: {json}"
2167 );
2168 assert!(
2169 json.contains("\"iosRequiresNetworkConnectivity\":true"),
2170 "JSON should contain iosRequiresNetworkConnectivity: {json}"
2171 );
2172 }
2173
2174 #[test]
2175 fn start_keepalive_args_processing_ceiling_multiplier() {
2176 let args = StartKeepaliveArgs {
2177 label: "Test",
2178 foreground_service_type: "dataSync",
2179 ios_safety_timeout_secs: None,
2180 ios_processing_safety_timeout_secs: None,
2181 ios_earliest_refresh_begin_minutes: None,
2182 ios_earliest_processing_begin_minutes: None,
2183 ios_requires_external_power: None,
2184 ios_requires_network_connectivity: None,
2185 ios_processing_ceiling_multiplier: Some(4.0),
2186 };
2187 let json = serde_json::to_string(&args).unwrap();
2188 assert!(
2189 json.contains("\"iosProcessingCeilingMultiplier\":4.0"),
2190 "JSON should contain iosProcessingCeilingMultiplier: {json}"
2191 );
2192 }
2193
2194 #[test]
2197 fn plugin_config_earliest_refresh_default() {
2198 let json = "{}";
2199 let config: PluginConfig = serde_json::from_str(json).unwrap();
2200 assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
2201 }
2202
2203 #[test]
2204 fn plugin_config_earliest_processing_default() {
2205 let json = "{}";
2206 let config: PluginConfig = serde_json::from_str(json).unwrap();
2207 assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
2208 }
2209
2210 #[test]
2211 fn plugin_config_requires_external_power_default() {
2212 let json = "{}";
2213 let config: PluginConfig = serde_json::from_str(json).unwrap();
2214 assert!(!config.ios_requires_external_power);
2215 }
2216
2217 #[test]
2218 fn plugin_config_requires_network_connectivity_default() {
2219 let json = "{}";
2220 let config: PluginConfig = serde_json::from_str(json).unwrap();
2221 assert!(!config.ios_requires_network_connectivity);
2222 }
2223
2224 #[test]
2225 fn plugin_config_custom_scheduling_intervals() {
2226 let json =
2227 r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
2228 let config: PluginConfig = serde_json::from_str(json).unwrap();
2229 assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
2230 assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
2231 }
2232
2233 #[test]
2234 fn plugin_config_custom_processing_options() {
2235 let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
2236 let config: PluginConfig = serde_json::from_str(json).unwrap();
2237 assert!(config.ios_requires_external_power);
2238 assert!(config.ios_requires_network_connectivity);
2239 }
2240
2241 #[test]
2244 fn plugin_config_processing_ceiling_multiplier_default() {
2245 let json = "{}";
2246 let config: PluginConfig = serde_json::from_str(json).unwrap();
2247 assert_eq!(config.ios_processing_ceiling_multiplier, 4.0);
2248 }
2249
2250 #[test]
2251 fn plugin_config_processing_ceiling_multiplier_custom() {
2252 let json = r#"{"iosProcessingCeilingMultiplier":6.0}"#;
2253 let config: PluginConfig = serde_json::from_str(json).unwrap();
2254 assert_eq!(config.ios_processing_ceiling_multiplier, 6.0);
2255 }
2256
2257 #[test]
2258 fn plugin_config_processing_ceiling_multiplier_serde_roundtrip() {
2259 let config = PluginConfig {
2260 ios_processing_ceiling_multiplier: 6.0,
2261 ..Default::default()
2262 };
2263 let json = serde_json::to_string(&config).unwrap();
2264 assert!(
2265 json.contains("\"iosProcessingCeilingMultiplier\":6.0"),
2266 "JSON should contain iosProcessingCeilingMultiplier: {json}"
2267 );
2268 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2269 assert_eq!(de.ios_processing_ceiling_multiplier, 6.0);
2270 }
2271
2272 #[test]
2275 fn plugin_config_channel_capacity_default() {
2276 let json = "{}";
2277 let config: PluginConfig = serde_json::from_str(json).unwrap();
2278 assert_eq!(config.channel_capacity, 16);
2279 }
2280
2281 #[test]
2282 fn plugin_config_channel_capacity_custom() {
2283 let json = r#"{"channelCapacity":32}"#;
2284 let config: PluginConfig = serde_json::from_str(json).unwrap();
2285 assert_eq!(config.channel_capacity, 32);
2286 }
2287
2288 #[test]
2289 fn plugin_config_channel_capacity_serde_roundtrip() {
2290 let config = PluginConfig {
2291 channel_capacity: 64,
2292 ..Default::default()
2293 };
2294 let json = serde_json::to_string(&config).unwrap();
2295 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2296 assert_eq!(de.channel_capacity, 64);
2297 }
2298
2299 #[test]
2300 fn plugin_config_channel_capacity_json_key_camel_case() {
2301 let config = PluginConfig {
2302 channel_capacity: 32,
2303 ..Default::default()
2304 };
2305 let json = serde_json::to_string(&config).unwrap();
2306 assert!(
2307 json.contains("channelCapacity"),
2308 "JSON should use camelCase: {json}"
2309 );
2310 }
2311
2312 #[test]
2315 fn plugin_config_android_fgs_types_default() {
2316 let json = "{}";
2317 let config: PluginConfig = serde_json::from_str(json).unwrap();
2318 assert_eq!(
2319 config.android_foreground_service_types,
2320 vec!["remoteMessaging"]
2321 );
2322 }
2323
2324 #[test]
2325 fn plugin_config_android_fgs_types_custom() {
2326 let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
2327 let config: PluginConfig = serde_json::from_str(json).unwrap();
2328 assert_eq!(
2329 config.android_foreground_service_types,
2330 vec!["dataSync", "specialUse"]
2331 );
2332 }
2333
2334 #[test]
2335 fn plugin_config_android_fgs_types_serde_roundtrip() {
2336 let config = PluginConfig {
2337 android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
2338 ..Default::default()
2339 };
2340 let json = serde_json::to_string(&config).unwrap();
2341 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2342 assert_eq!(
2343 de.android_foreground_service_types,
2344 vec!["location", "connectedDevice"]
2345 );
2346 }
2347
2348 #[test]
2349 fn plugin_config_android_fgs_types_json_key_camel_case() {
2350 let config = PluginConfig {
2351 android_foreground_service_types: vec!["specialUse".into()],
2352 ..Default::default()
2353 };
2354 let json = serde_json::to_string(&config).unwrap();
2355 assert!(
2356 json.contains("androidForegroundServiceTypes"),
2357 "JSON should use camelCase: {json}"
2358 );
2359 }
2360
2361 #[test]
2362 fn plugin_config_android_validate_default() {
2363 let json = "{}";
2364 let config: PluginConfig = serde_json::from_str(json).unwrap();
2365 assert!(config.android_validate_foreground_service_type);
2366 }
2367
2368 #[test]
2369 fn plugin_config_android_validate_false() {
2370 let json = r#"{"androidValidateForegroundServiceType":false}"#;
2371 let config: PluginConfig = serde_json::from_str(json).unwrap();
2372 assert!(!config.android_validate_foreground_service_type);
2373 }
2374
2375 #[test]
2376 fn plugin_config_android_validate_serde_roundtrip() {
2377 let config = PluginConfig {
2378 android_validate_foreground_service_type: false,
2379 ..Default::default()
2380 };
2381 let json = serde_json::to_string(&config).unwrap();
2382 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2383 assert!(!de.android_validate_foreground_service_type);
2384 }
2385
2386 #[test]
2387 fn plugin_config_android_validate_json_key_camel_case() {
2388 let config = PluginConfig {
2389 android_validate_foreground_service_type: false,
2390 ..Default::default()
2391 };
2392 let json = serde_json::to_string(&config).unwrap();
2393 assert!(
2394 json.contains("androidValidateForegroundServiceType"),
2395 "JSON should use camelCase: {json}"
2396 );
2397 }
2398
2399 #[test]
2402 fn plugin_config_android_on_timeout_default() {
2403 let json = "{}";
2404 let config: PluginConfig = serde_json::from_str(json).unwrap();
2405 assert_eq!(config.android_on_timeout, "notifyUser");
2406 }
2407
2408 #[test]
2409 fn plugin_config_android_on_timeout_custom() {
2410 let json = r#"{"androidOnTimeout":"stop"}"#;
2411 let config: PluginConfig = serde_json::from_str(json).unwrap();
2412 assert_eq!(config.android_on_timeout, "stop");
2413 }
2414
2415 #[test]
2416 fn plugin_config_android_on_timeout_schedule_recovery() {
2417 let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
2418 let config: PluginConfig = serde_json::from_str(json).unwrap();
2419 assert_eq!(config.android_on_timeout, "scheduleRecovery");
2420 }
2421
2422 #[test]
2423 fn plugin_config_android_on_timeout_serde_roundtrip() {
2424 let config = PluginConfig {
2425 android_on_timeout: "stop".into(),
2426 ..Default::default()
2427 };
2428 let json = serde_json::to_string(&config).unwrap();
2429 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2430 assert_eq!(de.android_on_timeout, "stop");
2431 }
2432
2433 #[test]
2434 fn plugin_config_android_on_timeout_json_key_camel_case() {
2435 let config = PluginConfig {
2436 android_on_timeout: "notifyUser".into(),
2437 ..Default::default()
2438 };
2439 let json = serde_json::to_string(&config).unwrap();
2440 assert!(
2441 json.contains("androidOnTimeout"),
2442 "JSON should use camelCase: {json}"
2443 );
2444 }
2445
2446 #[test]
2447 fn plugin_config_android_notification_channel_id_default() {
2448 let json = "{}";
2449 let config: PluginConfig = serde_json::from_str(json).unwrap();
2450 assert_eq!(config.android_notification_channel_id, "bg_service");
2451 }
2452
2453 #[test]
2454 fn plugin_config_android_notification_channel_id_custom() {
2455 let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
2456 let config: PluginConfig = serde_json::from_str(json).unwrap();
2457 assert_eq!(config.android_notification_channel_id, "my_channel");
2458 }
2459
2460 #[test]
2461 fn plugin_config_android_notification_channel_id_serde_roundtrip() {
2462 let config = PluginConfig {
2463 android_notification_channel_id: "custom_ch".into(),
2464 ..Default::default()
2465 };
2466 let json = serde_json::to_string(&config).unwrap();
2467 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2468 assert_eq!(de.android_notification_channel_id, "custom_ch");
2469 }
2470
2471 #[test]
2472 fn plugin_config_android_notification_channel_id_json_key_camel_case() {
2473 let config = PluginConfig {
2474 android_notification_channel_id: "test".into(),
2475 ..Default::default()
2476 };
2477 let json = serde_json::to_string(&config).unwrap();
2478 assert!(
2479 json.contains("androidNotificationChannelId"),
2480 "JSON should use camelCase: {json}"
2481 );
2482 }
2483
2484 #[test]
2485 fn plugin_config_android_notification_channel_name_default() {
2486 let json = "{}";
2487 let config: PluginConfig = serde_json::from_str(json).unwrap();
2488 assert_eq!(
2489 config.android_notification_channel_name,
2490 "Background Service"
2491 );
2492 }
2493
2494 #[test]
2495 fn plugin_config_android_notification_channel_name_custom() {
2496 let json = r#"{"androidNotificationChannelName":"My Service"}"#;
2497 let config: PluginConfig = serde_json::from_str(json).unwrap();
2498 assert_eq!(config.android_notification_channel_name, "My Service");
2499 }
2500
2501 #[test]
2502 fn plugin_config_android_notification_channel_name_serde_roundtrip() {
2503 let config = PluginConfig {
2504 android_notification_channel_name: "Sync Service".into(),
2505 ..Default::default()
2506 };
2507 let json = serde_json::to_string(&config).unwrap();
2508 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2509 assert_eq!(de.android_notification_channel_name, "Sync Service");
2510 }
2511
2512 #[test]
2513 fn plugin_config_android_notification_channel_name_json_key_camel_case() {
2514 let config = PluginConfig {
2515 android_notification_channel_name: "Test".into(),
2516 ..Default::default()
2517 };
2518 let json = serde_json::to_string(&config).unwrap();
2519 assert!(
2520 json.contains("androidNotificationChannelName"),
2521 "JSON should use camelCase: {json}"
2522 );
2523 }
2524
2525 #[test]
2526 fn plugin_config_android_notification_id_default() {
2527 let json = "{}";
2528 let config: PluginConfig = serde_json::from_str(json).unwrap();
2529 assert_eq!(config.android_notification_id, 9001);
2530 }
2531
2532 #[test]
2533 fn plugin_config_android_notification_id_custom() {
2534 let json = r#"{"androidNotificationId":1234}"#;
2535 let config: PluginConfig = serde_json::from_str(json).unwrap();
2536 assert_eq!(config.android_notification_id, 1234);
2537 }
2538
2539 #[test]
2540 fn plugin_config_android_notification_id_serde_roundtrip() {
2541 let config = PluginConfig {
2542 android_notification_id: 42,
2543 ..Default::default()
2544 };
2545 let json = serde_json::to_string(&config).unwrap();
2546 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2547 assert_eq!(de.android_notification_id, 42);
2548 }
2549
2550 #[test]
2551 fn plugin_config_android_notification_id_json_key_camel_case() {
2552 let config = PluginConfig {
2553 android_notification_id: 5555,
2554 ..Default::default()
2555 };
2556 let json = serde_json::to_string(&config).unwrap();
2557 assert!(
2558 json.contains("androidNotificationId"),
2559 "JSON should use camelCase: {json}"
2560 );
2561 }
2562
2563 #[test]
2564 fn plugin_config_android_notification_small_icon_default() {
2565 let json = "{}";
2566 let config: PluginConfig = serde_json::from_str(json).unwrap();
2567 assert_eq!(config.android_notification_small_icon, None);
2568 }
2569
2570 #[test]
2571 fn plugin_config_android_notification_small_icon_custom() {
2572 let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
2573 let config: PluginConfig = serde_json::from_str(json).unwrap();
2574 assert_eq!(
2575 config.android_notification_small_icon,
2576 Some("ic_notification".to_string())
2577 );
2578 }
2579
2580 #[test]
2581 fn plugin_config_android_notification_small_icon_serde_roundtrip() {
2582 let config = PluginConfig {
2583 android_notification_small_icon: Some("my_icon".into()),
2584 ..Default::default()
2585 };
2586 let json = serde_json::to_string(&config).unwrap();
2587 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2588 assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
2589 }
2590
2591 #[test]
2592 fn plugin_config_android_notification_small_icon_absent_when_none() {
2593 let config = PluginConfig {
2594 android_notification_small_icon: None,
2595 ..Default::default()
2596 };
2597 let json = serde_json::to_string(&config).unwrap();
2598 assert!(
2599 !json.contains("androidNotificationSmallIcon"),
2600 "should be absent when None: {json}"
2601 );
2602 }
2603
2604 #[test]
2605 fn plugin_config_android_notification_small_icon_json_key_camel_case() {
2606 let config = PluginConfig {
2607 android_notification_small_icon: Some("icon".into()),
2608 ..Default::default()
2609 };
2610 let json = serde_json::to_string(&config).unwrap();
2611 assert!(
2612 json.contains("androidNotificationSmallIcon"),
2613 "JSON should use camelCase: {json}"
2614 );
2615 }
2616
2617 #[test]
2618 fn plugin_config_android_show_stop_action_default() {
2619 let json = "{}";
2620 let config: PluginConfig = serde_json::from_str(json).unwrap();
2621 assert!(config.android_show_stop_action);
2622 }
2623
2624 #[test]
2625 fn plugin_config_android_show_stop_action_false() {
2626 let json = r#"{"androidShowStopAction":false}"#;
2627 let config: PluginConfig = serde_json::from_str(json).unwrap();
2628 assert!(!config.android_show_stop_action);
2629 }
2630
2631 #[test]
2632 fn plugin_config_android_show_stop_action_serde_roundtrip() {
2633 let config = PluginConfig {
2634 android_show_stop_action: false,
2635 ..Default::default()
2636 };
2637 let json = serde_json::to_string(&config).unwrap();
2638 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2639 assert!(!de.android_show_stop_action);
2640 }
2641
2642 #[test]
2643 fn plugin_config_android_show_stop_action_json_key_camel_case() {
2644 let config = PluginConfig {
2645 android_show_stop_action: false,
2646 ..Default::default()
2647 };
2648 let json = serde_json::to_string(&config).unwrap();
2649 assert!(
2650 json.contains("androidShowStopAction"),
2651 "JSON should use camelCase: {json}"
2652 );
2653 }
2654
2655 #[test]
2658 fn core03_default_config_is_valid() {
2659 assert!(PluginConfig::default().validate().is_ok());
2660 }
2661
2662 #[test]
2663 fn core03_channel_capacity_zero_rejected() {
2664 let mut c = PluginConfig::default();
2665 c.channel_capacity = 0;
2666 let msg = format!("{}", c.validate().unwrap_err());
2667 assert!(
2668 msg.contains("channelCapacity") && msg.contains('0'),
2669 "expected channelCapacity diagnostic, got: {msg}"
2670 );
2671 }
2672
2673 #[test]
2674 fn core03_ios_safety_timeout_nonpositive_or_nonfinite_rejected() {
2675 for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] {
2676 let mut c = PluginConfig::default();
2677 c.ios_safety_timeout_secs = bad;
2678 assert!(
2679 format!("{}", c.validate().unwrap_err()).contains("iosSafetyTimeoutSecs"),
2680 "value {bad:?} should be rejected"
2681 );
2682 }
2683 }
2684
2685 #[test]
2686 fn core03_ios_cancel_listener_timeout_zero_rejected() {
2687 let mut c = PluginConfig::default();
2688 c.ios_cancel_listener_timeout_secs = 0;
2689 assert!(c.validate().is_err());
2690 }
2691
2692 #[test]
2693 fn core03_ios_processing_safety_timeout_negative_or_nonfinite_rejected() {
2694 for bad in [-1.0, f64::NAN, f64::INFINITY] {
2695 let mut c = PluginConfig::default();
2696 c.ios_processing_safety_timeout_secs = bad;
2697 assert!(c.validate().is_err(), "{bad:?} should be rejected");
2698 }
2699 let mut c = PluginConfig::default();
2701 c.ios_processing_safety_timeout_secs = 0.0;
2702 assert!(c.validate().is_ok());
2703 c.ios_processing_safety_timeout_secs = 120.0;
2704 assert!(c.validate().is_ok());
2705 }
2706
2707 #[test]
2708 fn core03_ios_earliest_begin_negative_or_nonfinite_rejected() {
2709 for bad in [-0.1, f64::NAN, f64::INFINITY] {
2710 let mut c = PluginConfig::default();
2711 c.ios_earliest_refresh_begin_minutes = bad;
2712 assert!(c.validate().is_err(), "refresh {bad:?}");
2713 let mut c = PluginConfig::default();
2714 c.ios_earliest_processing_begin_minutes = bad;
2715 assert!(c.validate().is_err(), "processing {bad:?}");
2716 }
2717 let mut c = PluginConfig::default();
2719 c.ios_earliest_refresh_begin_minutes = 0.0;
2720 assert!(c.validate().is_ok());
2721 }
2722
2723 #[test]
2724 fn core03_ios_processing_ceiling_multiplier_below_one_rejected() {
2725 for bad in [0.0, 0.99, -1.0, f64::NAN] {
2726 let mut c = PluginConfig::default();
2727 c.ios_processing_ceiling_multiplier = bad;
2728 assert!(c.validate().is_err(), "{bad:?} should be rejected");
2729 }
2730 let mut c = PluginConfig::default();
2732 c.ios_processing_ceiling_multiplier = 1.0;
2733 assert!(c.validate().is_ok());
2734 }
2735
2736 #[test]
2737 fn core03_android_notification_id_outside_i32_range_rejected() {
2738 for bad in [0u32, (i32::MAX as u32) + 1, u32::MAX] {
2739 let mut c = PluginConfig::default();
2740 c.android_notification_id = bad;
2741 assert!(c.validate().is_err(), "id {bad} should be rejected");
2742 }
2743 for ok in [1u32, i32::MAX as u32] {
2744 let mut c = PluginConfig::default();
2745 c.android_notification_id = ok;
2746 assert!(c.validate().is_ok(), "id {ok} should pass");
2747 }
2748 }
2749
2750 #[test]
2751 fn core03_android_notification_channel_id_empty_rejected() {
2752 for bad in ["", " ", "\t"] {
2753 let mut c = PluginConfig::default();
2754 c.android_notification_channel_id = bad.into();
2755 assert!(c.validate().is_err(), "{bad:?} should be rejected");
2756 }
2757 }
2758
2759 #[test]
2760 fn core03_android_notification_channel_name_empty_rejected() {
2761 let mut c = PluginConfig::default();
2762 c.android_notification_channel_name = " ".into();
2763 assert!(c.validate().is_err());
2764 }
2765
2766 #[test]
2767 fn core03_android_on_timeout_unknown_policy_rejected() {
2768 for bad in ["", "notify", "recover", "STOP", "schedule"] {
2769 let mut c = PluginConfig::default();
2770 c.android_on_timeout = bad.into();
2771 assert!(c.validate().is_err(), "{bad:?} should be rejected");
2772 }
2773 for ok in ["stop", "notifyUser", "scheduleRecovery"] {
2774 let mut c = PluginConfig::default();
2775 c.android_on_timeout = ok.into();
2776 assert!(c.validate().is_ok(), "{ok:?} should pass");
2777 }
2778 }
2779
2780 #[cfg(feature = "desktop-service")]
2781 #[test]
2782 fn core03_desktop_service_mode_invalid_rejected() {
2783 for bad in ["", "os-service", "OSService", "daemon", "in-process"] {
2784 let mut c = PluginConfig::default();
2785 c.desktop_service_mode = bad.into();
2786 assert!(c.validate().is_err(), "{bad:?} should be rejected");
2787 }
2788 for ok in ["inProcess", "osService"] {
2789 let mut c = PluginConfig::default();
2790 c.desktop_service_mode = ok.into();
2791 assert!(c.validate().is_ok(), "{ok:?} should pass");
2792 }
2793 }
2794
2795 #[cfg(feature = "desktop-service")]
2796 #[test]
2797 fn core03_desktop_service_start_timeout_zero_rejected() {
2798 let mut c = PluginConfig::default();
2799 c.desktop_service_start_timeout_ms = 0;
2800 assert!(c.validate().is_err());
2801 }
2802
2803 #[test]
2806 fn plugin_config_android_request_notification_permission_default() {
2807 let json = "{}";
2808 let config: PluginConfig = serde_json::from_str(json).unwrap();
2809 assert!(!config.android_request_notification_permission_on_load);
2812 }
2813
2814 #[test]
2815 fn plugin_config_android_request_notification_permission_false() {
2816 let json = r#"{"androidRequestNotificationPermissionOnLoad":false}"#;
2817 let config: PluginConfig = serde_json::from_str(json).unwrap();
2818 assert!(!config.android_request_notification_permission_on_load);
2819 }
2820
2821 #[test]
2822 fn plugin_config_android_request_notification_permission_serde_roundtrip() {
2823 let config = PluginConfig {
2824 android_request_notification_permission_on_load: false,
2825 ..Default::default()
2826 };
2827 let json = serde_json::to_string(&config).unwrap();
2828 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2829 assert!(!de.android_request_notification_permission_on_load);
2830 }
2831 #[test]
2834 fn core07_notification_permission_status_internal_construct() {
2835 let s = NotificationPermissionStatus {
2840 status: "granted".to_string(),
2841 };
2842 assert_eq!(s.status, "granted");
2843 }
2844
2845 #[test]
2846 fn core07_notification_permission_status_serde_roundtrip() {
2847 for v in ["granted", "denied", "notDetermined"] {
2849 let s = NotificationPermissionStatus {
2850 status: v.to_string(),
2851 };
2852 let json = serde_json::to_string(&s).unwrap();
2853 assert_eq!(json, format!("{{\"status\":\"{v}\"}}"));
2854 let de: NotificationPermissionStatus = serde_json::from_str(&json).unwrap();
2855 assert_eq!(de, s);
2856 }
2857 }
2858
2859 #[test]
2860 fn core07_notification_permission_status_is_non_exhaustive() {
2861 let src = include_str!("models.rs");
2865 let needle = concat!(
2866 "#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]\n",
2867 "#[non_exhaustive]\n",
2868 "pub struct NotificationPermissionStatus"
2869 );
2870 assert!(
2871 src.contains(needle),
2872 "NotificationPermissionStatus must retain #[non_exhaustive]"
2873 );
2874 }
2875
2876 #[test]
2877 fn plugin_config_android_timeout_notification_full_roundtrip() {
2878 let config = PluginConfig {
2879 android_on_timeout: "scheduleRecovery".into(),
2880 android_notification_channel_id: "my_ch".into(),
2881 android_notification_channel_name: "My Channel".into(),
2882 android_notification_id: 42,
2883 android_notification_small_icon: Some("ic_bg".into()),
2884 android_show_stop_action: false,
2885 ..Default::default()
2886 };
2887 let json = serde_json::to_string(&config).unwrap();
2888 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2889 assert_eq!(de.android_on_timeout, "scheduleRecovery");
2890 assert_eq!(de.android_notification_channel_id, "my_ch");
2891 assert_eq!(de.android_notification_channel_name, "My Channel");
2892 assert_eq!(de.android_notification_id, 42);
2893 assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
2894 assert!(!de.android_show_stop_action);
2895 }
2896
2897 #[test]
2900 fn plugin_config_notify_keys_default_false() {
2901 let json = "{}";
2902 let config: PluginConfig = serde_json::from_str(json).unwrap();
2903 assert!(!config.notify_on_timeout);
2904 assert!(!config.notify_on_recovery);
2905 }
2906
2907 #[test]
2908 fn plugin_config_notify_on_timeout_custom() {
2909 let json = r#"{"notifyOnTimeout":true}"#;
2910 let config: PluginConfig = serde_json::from_str(json).unwrap();
2911 assert!(config.notify_on_timeout);
2912 assert!(!config.notify_on_recovery);
2913 }
2914
2915 #[test]
2916 fn plugin_config_notify_on_recovery_custom() {
2917 let json = r#"{"notifyOnRecovery":true}"#;
2918 let config: PluginConfig = serde_json::from_str(json).unwrap();
2919 assert!(!config.notify_on_timeout);
2920 assert!(config.notify_on_recovery);
2921 }
2922
2923 #[test]
2924 fn plugin_config_notify_keys_serde_roundtrip() {
2925 let config = PluginConfig {
2926 notify_on_timeout: true,
2927 notify_on_recovery: true,
2928 ..Default::default()
2929 };
2930 let json = serde_json::to_string(&config).unwrap();
2931 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2932 assert!(de.notify_on_timeout);
2933 assert!(de.notify_on_recovery);
2934 }
2935
2936 #[test]
2937 fn plugin_config_notify_keys_json_camel_case() {
2938 let config = PluginConfig {
2939 notify_on_timeout: true,
2940 notify_on_recovery: true,
2941 ..Default::default()
2942 };
2943 let json = serde_json::to_string(&config).unwrap();
2944 assert!(
2945 json.contains("notifyOnTimeout") && json.contains("notifyOnRecovery"),
2946 "JSON should use camelCase: {json}"
2947 );
2948 }
2949
2950 #[cfg(feature = "desktop-service")]
2953 #[test]
2954 fn plugin_config_desktop_mode_default() {
2955 let json = "{}";
2956 let config: PluginConfig = serde_json::from_str(json).unwrap();
2957 assert_eq!(config.desktop_service_mode, "inProcess");
2958 }
2959
2960 #[cfg(feature = "desktop-service")]
2961 #[test]
2962 fn plugin_config_desktop_mode_custom() {
2963 let json = r#"{"desktopServiceMode":"osService"}"#;
2964 let config: PluginConfig = serde_json::from_str(json).unwrap();
2965 assert_eq!(config.desktop_service_mode, "osService");
2966 }
2967
2968 #[cfg(feature = "desktop-service")]
2969 #[test]
2970 fn plugin_config_desktop_mode_serde_roundtrip() {
2971 let config = PluginConfig {
2972 desktop_service_mode: "osService".into(),
2973 ..Default::default()
2974 };
2975 let json = serde_json::to_string(&config).unwrap();
2976 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2977 assert_eq!(de.desktop_service_mode, "osService");
2978 }
2979
2980 #[cfg(feature = "desktop-service")]
2981 #[test]
2982 fn plugin_config_desktop_label_default() {
2983 let json = "{}";
2984 let config: PluginConfig = serde_json::from_str(json).unwrap();
2985 assert_eq!(config.desktop_service_label, None);
2986 }
2987
2988 #[cfg(feature = "desktop-service")]
2989 #[test]
2990 fn plugin_config_desktop_label_custom() {
2991 let json = r#"{"desktopServiceLabel":"my.svc"}"#;
2992 let config: PluginConfig = serde_json::from_str(json).unwrap();
2993 assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
2994 }
2995
2996 #[cfg(feature = "desktop-service")]
2999 #[test]
3000 fn plugin_config_desktop_autostart_default() {
3001 let json = "{}";
3002 let config: PluginConfig = serde_json::from_str(json).unwrap();
3003 assert!(!config.desktop_service_autostart);
3004 }
3005
3006 #[cfg(feature = "desktop-service")]
3007 #[test]
3008 fn plugin_config_desktop_autostart_true() {
3009 let json = r#"{"desktopServiceAutostart":true}"#;
3010 let config: PluginConfig = serde_json::from_str(json).unwrap();
3011 assert!(config.desktop_service_autostart);
3012 }
3013
3014 #[cfg(feature = "desktop-service")]
3015 #[test]
3016 fn plugin_config_desktop_autostart_serde_roundtrip() {
3017 let config = PluginConfig {
3018 desktop_service_autostart: true,
3019 ..Default::default()
3020 };
3021 let json = serde_json::to_string(&config).unwrap();
3022 let de: PluginConfig = serde_json::from_str(&json).unwrap();
3023 assert!(de.desktop_service_autostart);
3024 }
3025
3026 #[cfg(feature = "desktop-service")]
3027 #[test]
3028 fn plugin_config_desktop_autostart_json_key_camel_case() {
3029 let config = PluginConfig {
3030 desktop_service_autostart: true,
3031 ..Default::default()
3032 };
3033 let json = serde_json::to_string(&config).unwrap();
3034 assert!(
3035 json.contains("desktopServiceAutostart"),
3036 "JSON should use camelCase: {json}"
3037 );
3038 }
3039
3040 #[cfg(feature = "desktop-service")]
3041 #[test]
3042 fn plugin_config_desktop_start_if_missing_default() {
3043 let json = "{}";
3044 let config: PluginConfig = serde_json::from_str(json).unwrap();
3045 assert!(!config.desktop_start_service_if_missing);
3046 }
3047
3048 #[cfg(feature = "desktop-service")]
3049 #[test]
3050 fn plugin_config_desktop_start_if_missing_true() {
3051 let json = r#"{"desktopStartServiceIfMissing":true}"#;
3052 let config: PluginConfig = serde_json::from_str(json).unwrap();
3053 assert!(config.desktop_start_service_if_missing);
3054 }
3055
3056 #[cfg(feature = "desktop-service")]
3057 #[test]
3058 fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
3059 let config = PluginConfig {
3060 desktop_start_service_if_missing: true,
3061 ..Default::default()
3062 };
3063 let json = serde_json::to_string(&config).unwrap();
3064 let de: PluginConfig = serde_json::from_str(&json).unwrap();
3065 assert!(de.desktop_start_service_if_missing);
3066 }
3067
3068 #[cfg(feature = "desktop-service")]
3069 #[test]
3070 fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
3071 let config = PluginConfig {
3072 desktop_start_service_if_missing: true,
3073 ..Default::default()
3074 };
3075 let json = serde_json::to_string(&config).unwrap();
3076 assert!(
3077 json.contains("desktopStartServiceIfMissing"),
3078 "JSON should use camelCase: {json}"
3079 );
3080 }
3081
3082 #[cfg(feature = "desktop-service")]
3085 #[test]
3086 fn desk01_windows_daemon_opt_in_field_is_absent() {
3087 let src = include_str!("models.rs");
3088 let needle = ["desktop_windows", "_daemon_opt_in"].concat();
3091 let without_this_test = src
3094 .split("fn desk01_windows_daemon_opt_in_field_is_absent")
3095 .next()
3096 .unwrap_or("");
3097 assert!(
3098 !without_this_test.contains(&needle[..]),
3099 "DESK-01: the opt-in field must not be re-introduced on PluginConfig"
3100 );
3101 let json = r#"{"desktopWindowsDaemonOptIn":true}"#;
3105 let config: PluginConfig = serde_json::from_str(json).unwrap();
3106 let _ = config;
3107 }
3108
3109 #[cfg(feature = "desktop-service")]
3110 #[test]
3111 fn plugin_config_desktop_start_timeout_default() {
3112 let json = "{}";
3113 let config: PluginConfig = serde_json::from_str(json).unwrap();
3114 assert_eq!(config.desktop_service_start_timeout_ms, 5000);
3115 }
3116
3117 #[cfg(feature = "desktop-service")]
3118 #[test]
3119 fn plugin_config_desktop_start_timeout_custom() {
3120 let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
3121 let config: PluginConfig = serde_json::from_str(json).unwrap();
3122 assert_eq!(config.desktop_service_start_timeout_ms, 10000);
3123 }
3124
3125 #[cfg(feature = "desktop-service")]
3126 #[test]
3127 fn plugin_config_desktop_start_timeout_serde_roundtrip() {
3128 let config = PluginConfig {
3129 desktop_service_start_timeout_ms: 15000,
3130 ..Default::default()
3131 };
3132 let json = serde_json::to_string(&config).unwrap();
3133 let de: PluginConfig = serde_json::from_str(&json).unwrap();
3134 assert_eq!(de.desktop_service_start_timeout_ms, 15000);
3135 }
3136
3137 #[cfg(feature = "desktop-service")]
3138 #[test]
3139 fn plugin_config_desktop_start_timeout_json_key_camel_case() {
3140 let config = PluginConfig {
3141 desktop_service_start_timeout_ms: 3000,
3142 ..Default::default()
3143 };
3144 let json = serde_json::to_string(&config).unwrap();
3145 assert!(
3146 json.contains("desktopServiceStartTimeoutMs"),
3147 "JSON should use camelCase: {json}"
3148 );
3149 }
3150
3151 #[cfg(feature = "desktop-service")]
3152 #[test]
3153 fn plugin_config_desktop_all_new_fields_roundtrip() {
3154 let config = PluginConfig {
3155 desktop_service_autostart: true,
3156 desktop_start_service_if_missing: true,
3157 desktop_service_start_timeout_ms: 8000,
3158 ..Default::default()
3159 };
3160 let json = serde_json::to_string(&config).unwrap();
3161 let de: PluginConfig = serde_json::from_str(&json).unwrap();
3162 assert!(de.desktop_service_autostart);
3163 assert!(de.desktop_start_service_if_missing);
3164 assert_eq!(de.desktop_service_start_timeout_ms, 8000);
3165 }
3166
3167 use tauri::AppHandle;
3168
3169 #[cfg(mobile)]
3173 #[allow(dead_code)]
3174 fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
3175 let ctx = ServiceContext {
3176 notifier: Notifier { app: app.clone() },
3177 app,
3178 shutdown: CancellationToken::new(),
3179 service_label: "Syncing".into(),
3180 foreground_service_type: "dataSync".into(),
3181 };
3182 assert_eq!(ctx.service_label, "Syncing");
3183 assert_eq!(ctx.foreground_service_type, "dataSync");
3184 }
3185
3186 #[cfg(not(mobile))]
3188 #[allow(dead_code)]
3189 fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
3190 let ctx = ServiceContext {
3191 notifier: Notifier { app: app.clone() },
3192 app,
3193 shutdown: CancellationToken::new(),
3194 };
3195 let _ = ctx;
3197 }
3198
3199 #[test]
3202 fn validate_data_sync_passes() {
3203 assert!(
3204 validate_foreground_service_type("dataSync").is_ok(),
3205 "dataSync should be valid"
3206 );
3207 }
3208
3209 #[test]
3210 fn validate_special_use_passes() {
3211 assert!(
3212 validate_foreground_service_type("specialUse").is_ok(),
3213 "specialUse should be valid"
3214 );
3215 }
3216
3217 #[test]
3218 fn validate_invalid_type_returns_platform_error() {
3219 let result = validate_foreground_service_type("invalidType");
3220 assert!(result.is_err(), "invalidType should be rejected");
3221 match result {
3222 Err(crate::error::ServiceError::Platform(msg)) => {
3223 assert!(
3224 msg.contains("invalidType"),
3225 "error should mention the type: {msg}"
3226 );
3227 }
3228 other => panic!("Expected Platform error, got: {other:?}"),
3229 }
3230 }
3231
3232 #[test]
3233 fn validate_all_14_types_pass() {
3234 for &t in VALID_FOREGROUND_SERVICE_TYPES {
3235 assert!(
3236 validate_foreground_service_type(t).is_ok(),
3237 "{t} should be valid"
3238 );
3239 }
3240 }
3241
3242 #[test]
3243 fn valid_types_count_is_14() {
3244 assert_eq!(
3245 VALID_FOREGROUND_SERVICE_TYPES.len(),
3246 14,
3247 "should have exactly 14 valid types"
3248 );
3249 }
3250
3251 #[test]
3252 fn validate_empty_string_returns_error() {
3253 let result = validate_foreground_service_type("");
3254 assert!(result.is_err(), "empty string should be rejected");
3255 }
3256
3257 #[test]
3258 fn validate_case_sensitive() {
3259 let result = validate_foreground_service_type("DataSync");
3261 assert!(
3262 result.is_err(),
3263 "validation should be case-sensitive: DataSync should fail"
3264 );
3265 }
3266
3267 #[test]
3270 fn allowlist_accepted_type_in_list() {
3271 let allowlist = vec!["remoteMessaging".to_string()];
3272 let result = validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true);
3273 assert!(result.is_ok(), "type in allowlist should be accepted");
3274 }
3275
3276 #[test]
3277 fn allowlist_rejected_type_not_in_list() {
3278 let allowlist = vec!["remoteMessaging".to_string()];
3279 let result = validate_fg_type_against_allowlist("specialUse", &allowlist, true);
3280 assert!(result.is_err(), "type not in allowlist should be rejected");
3281 match result {
3282 Err(ServiceError::Platform(msg)) => {
3283 assert!(
3284 msg.contains("specialUse"),
3285 "error should mention the type: {msg}"
3286 );
3287 assert!(
3288 msg.contains("not allowed"),
3289 "error should say not allowed: {msg}"
3290 );
3291 }
3292 other => panic!("Expected Platform error, got: {other:?}"),
3293 }
3294 }
3295
3296 #[test]
3297 fn allowlist_empty_type_rejected() {
3298 let allowlist = vec!["dataSync".to_string()];
3299 let result = validate_fg_type_against_allowlist("", &allowlist, true);
3300 assert!(result.is_err(), "empty type should be rejected");
3301 match result {
3302 Err(ServiceError::Platform(msg)) => {
3303 assert!(
3304 msg.contains("must not be empty"),
3305 "error should mention empty: {msg}"
3306 );
3307 }
3308 other => panic!("Expected Platform error, got: {other:?}"),
3309 }
3310 }
3311
3312 #[test]
3313 fn allowlist_case_insensitive_match() {
3314 let allowlist = vec!["remoteMessaging".to_string()];
3315 let result = validate_fg_type_against_allowlist("RemoteMessaging", &allowlist, true);
3316 assert!(result.is_ok(), "case-insensitive match should be accepted");
3317 }
3318
3319 #[test]
3320 fn allowlist_validation_skipped_when_disabled() {
3321 let allowlist = vec!["dataSync".to_string()];
3322 let result = validate_fg_type_against_allowlist("specialUse", &allowlist, false);
3323 assert!(result.is_ok(), "validation disabled should accept any type");
3324 }
3325
3326 #[test]
3327 fn allowlist_multiple_types() {
3328 let allowlist = vec![
3329 "dataSync".to_string(),
3330 "remoteMessaging".to_string(),
3331 "specialUse".to_string(),
3332 ];
3333 assert!(
3334 validate_fg_type_against_allowlist("dataSync", &allowlist, true).is_ok(),
3335 "dataSync should be in allowlist"
3336 );
3337 assert!(
3338 validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true).is_ok(),
3339 "remoteMessaging should be in allowlist"
3340 );
3341 assert!(
3342 validate_fg_type_against_allowlist("specialUse", &allowlist, true).is_ok(),
3343 "specialUse should be in allowlist"
3344 );
3345 assert!(
3346 validate_fg_type_against_allowlist("camera", &allowlist, true).is_err(),
3347 "camera should NOT be in allowlist"
3348 );
3349 }
3350
3351 #[test]
3354 fn service_state_idle_serde_roundtrip() {
3355 let state = ServiceState::Idle;
3356 let json = serde_json::to_string(&state).unwrap();
3357 let de: ServiceState = serde_json::from_str(&json).unwrap();
3358 assert_eq!(de, ServiceState::Idle);
3359 }
3360
3361 #[test]
3362 fn service_state_initializing_serde_roundtrip() {
3363 let state = ServiceState::Initializing;
3364 let json = serde_json::to_string(&state).unwrap();
3365 let de: ServiceState = serde_json::from_str(&json).unwrap();
3366 assert_eq!(de, ServiceState::Initializing);
3367 }
3368
3369 #[test]
3370 fn service_state_running_serde_roundtrip() {
3371 let state = ServiceState::Running;
3372 let json = serde_json::to_string(&state).unwrap();
3373 let de: ServiceState = serde_json::from_str(&json).unwrap();
3374 assert_eq!(de, ServiceState::Running);
3375 }
3376
3377 #[test]
3378 fn service_state_stopped_serde_roundtrip() {
3379 let state = ServiceState::Stopped;
3380 let json = serde_json::to_string(&state).unwrap();
3381 let de: ServiceState = serde_json::from_str(&json).unwrap();
3382 assert_eq!(de, ServiceState::Stopped);
3383 }
3384
3385 #[test]
3386 fn service_state_json_values_are_camel_case() {
3387 assert_eq!(
3388 serde_json::to_string(&ServiceState::Idle).unwrap(),
3389 "\"idle\""
3390 );
3391 assert_eq!(
3392 serde_json::to_string(&ServiceState::Initializing).unwrap(),
3393 "\"initializing\""
3394 );
3395 assert_eq!(
3396 serde_json::to_string(&ServiceState::Running).unwrap(),
3397 "\"running\""
3398 );
3399 assert_eq!(
3400 serde_json::to_string(&ServiceState::Stopped).unwrap(),
3401 "\"stopped\""
3402 );
3403 }
3404
3405 #[test]
3408 fn service_status_serde_roundtrip_idle() {
3409 let status = ServiceStatus {
3410 state: ServiceState::Idle,
3411 ..Default::default()
3412 };
3413 let json = serde_json::to_string(&status).unwrap();
3414 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3415 assert_eq!(de.state, ServiceState::Idle);
3416 assert_eq!(de.last_error, None);
3417 }
3418
3419 #[test]
3420 fn service_status_serde_roundtrip_with_error() {
3421 let status = ServiceStatus {
3422 state: ServiceState::Stopped,
3423 last_error: Some("init failed".into()),
3424 ..Default::default()
3425 };
3426 let json = serde_json::to_string(&status).unwrap();
3427 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3428 assert_eq!(de.state, ServiceState::Stopped);
3429 assert_eq!(de.last_error, Some("init failed".into()));
3430 }
3431
3432 #[test]
3433 fn service_status_json_keys_camel_case() {
3434 let status = ServiceStatus {
3435 state: ServiceState::Running,
3436 ..Default::default()
3437 };
3438 let json = serde_json::to_string(&status).unwrap();
3439 assert!(json.contains("\"state\":"), "state key: {json}");
3440 assert!(json.contains("\"lastError\":"), "lastError key: {json}");
3441 }
3442
3443 #[test]
3444 fn service_status_json_null_last_error() {
3445 let status = ServiceStatus {
3446 state: ServiceState::Idle,
3447 ..Default::default()
3448 };
3449 let json = serde_json::to_string(&status).unwrap();
3450 assert!(
3451 json.contains("\"lastError\":null"),
3452 "lastError should be null: {json}"
3453 );
3454 }
3455
3456 #[test]
3459 fn platform_serde_roundtrip() {
3460 for variant in [
3461 Platform::Android,
3462 Platform::Ios,
3463 Platform::Windows,
3464 Platform::Macos,
3465 Platform::Linux,
3466 Platform::Unknown,
3467 ] {
3468 let json = serde_json::to_string(&variant).unwrap();
3469 let de: Platform = serde_json::from_str(&json).unwrap();
3470 assert_eq!(de, variant);
3471 }
3472 }
3473
3474 #[test]
3475 fn platform_json_values_are_camel_case() {
3476 assert_eq!(
3477 serde_json::to_string(&Platform::Android).unwrap(),
3478 "\"android\""
3479 );
3480 assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
3481 assert_eq!(
3482 serde_json::to_string(&Platform::Windows).unwrap(),
3483 "\"windows\""
3484 );
3485 assert_eq!(
3486 serde_json::to_string(&Platform::Macos).unwrap(),
3487 "\"macos\""
3488 );
3489 assert_eq!(
3490 serde_json::to_string(&Platform::Linux).unwrap(),
3491 "\"linux\""
3492 );
3493 assert_eq!(
3494 serde_json::to_string(&Platform::Unknown).unwrap(),
3495 "\"unknown\""
3496 );
3497 }
3498
3499 #[test]
3502 fn lifecycle_mode_serde_roundtrip() {
3503 for variant in [
3504 LifecycleMode::AndroidForegroundService,
3505 LifecycleMode::IosBgTaskScheduler,
3506 LifecycleMode::DesktopInProcess,
3507 LifecycleMode::DesktopOsService,
3508 ] {
3509 let json = serde_json::to_string(&variant).unwrap();
3510 let de: LifecycleMode = serde_json::from_str(&json).unwrap();
3511 assert_eq!(de, variant);
3512 }
3513 }
3514
3515 #[test]
3516 fn lifecycle_mode_json_values_are_camel_case() {
3517 assert_eq!(
3518 serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
3519 "\"androidForegroundService\""
3520 );
3521 assert_eq!(
3522 serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
3523 "\"iosBgTaskScheduler\""
3524 );
3525 assert_eq!(
3526 serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
3527 "\"desktopInProcess\""
3528 );
3529 assert_eq!(
3530 serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
3531 "\"desktopOsService\""
3532 );
3533 }
3534
3535 #[test]
3538 fn lifecycle_guarantee_serde_roundtrip() {
3539 for variant in [
3540 LifecycleGuarantee::Guaranteed,
3541 LifecycleGuarantee::BestEffort,
3542 LifecycleGuarantee::Unsupported,
3543 ] {
3544 let json = serde_json::to_string(&variant).unwrap();
3545 let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
3546 assert_eq!(de, variant);
3547 }
3548 }
3549
3550 #[test]
3551 fn lifecycle_guarantee_json_values_are_camel_case() {
3552 assert_eq!(
3553 serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
3554 "\"guaranteed\""
3555 );
3556 assert_eq!(
3557 serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
3558 "\"bestEffort\""
3559 );
3560 assert_eq!(
3561 serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
3562 "\"unsupported\""
3563 );
3564 }
3565
3566 #[test]
3569 fn platform_capabilities_serde_roundtrip() {
3570 let caps = PlatformCapabilities {
3571 platform: Platform::Android,
3572 lifecycle_mode: LifecycleMode::AndroidForegroundService,
3573 survives_app_close: LifecycleGuarantee::BestEffort,
3574 survives_reboot: LifecycleGuarantee::BestEffort,
3575 survives_force_quit: LifecycleGuarantee::Unsupported,
3576 background_execution: LifecycleGuarantee::Guaranteed,
3577 limitations: vec!["OEM battery optimization".into()],
3578 required_setup: vec!["FOREGROUND_SERVICE permission".into()],
3579 };
3580 let json = serde_json::to_string(&caps).unwrap();
3581 let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
3582 assert_eq!(de, caps);
3583 }
3584
3585 #[test]
3586 fn platform_capabilities_json_keys_camel_case() {
3587 let caps = PlatformCapabilities {
3588 platform: Platform::Linux,
3589 lifecycle_mode: LifecycleMode::DesktopInProcess,
3590 survives_app_close: LifecycleGuarantee::Unsupported,
3591 survives_reboot: LifecycleGuarantee::Unsupported,
3592 survives_force_quit: LifecycleGuarantee::Unsupported,
3593 background_execution: LifecycleGuarantee::Guaranteed,
3594 limitations: vec![],
3595 required_setup: vec![],
3596 };
3597 let json = serde_json::to_string(&caps).unwrap();
3598 assert!(json.contains("\"platform\":"), "platform: {json}");
3599 assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
3600 assert!(
3601 json.contains("\"survivesAppClose\":"),
3602 "survivesAppClose: {json}"
3603 );
3604 assert!(
3605 json.contains("\"survivesReboot\":"),
3606 "survivesReboot: {json}"
3607 );
3608 assert!(
3609 json.contains("\"survivesForceQuit\":"),
3610 "survivesForceQuit: {json}"
3611 );
3612 assert!(
3613 json.contains("\"backgroundExecution\":"),
3614 "backgroundExecution: {json}"
3615 );
3616 assert!(json.contains("\"limitations\":"), "limitations: {json}");
3617 assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
3618 }
3619
3620 #[test]
3621 fn platform_capabilities_empty_collections_serialize() {
3622 let caps = PlatformCapabilities {
3623 platform: Platform::Unknown,
3624 lifecycle_mode: LifecycleMode::DesktopInProcess,
3625 survives_app_close: LifecycleGuarantee::Unsupported,
3626 survives_reboot: LifecycleGuarantee::Unsupported,
3627 survives_force_quit: LifecycleGuarantee::Unsupported,
3628 background_execution: LifecycleGuarantee::Unsupported,
3629 limitations: vec![],
3630 required_setup: vec![],
3631 };
3632 let json = serde_json::to_string(&caps).unwrap();
3633 assert!(json.contains("\"limitations\":[]"), "{json}");
3634 assert!(json.contains("\"requiredSetup\":[]"), "{json}");
3635 }
3636
3637 #[test]
3640 fn native_state_serde_roundtrip() {
3641 for variant in [
3642 NativeState::Idle,
3643 NativeState::Starting,
3644 NativeState::Running,
3645 NativeState::Stopping,
3646 NativeState::Timeout,
3647 NativeState::Expired,
3648 NativeState::Recovering,
3649 NativeState::Error,
3650 ] {
3651 let json = serde_json::to_string(&variant).unwrap();
3652 let de: NativeState = serde_json::from_str(&json).unwrap();
3653 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3654 }
3655 }
3656
3657 #[test]
3658 fn native_state_json_values_are_camel_case() {
3659 assert_eq!(
3660 serde_json::to_string(&NativeState::Idle).unwrap(),
3661 "\"idle\""
3662 );
3663 assert_eq!(
3664 serde_json::to_string(&NativeState::Starting).unwrap(),
3665 "\"starting\""
3666 );
3667 assert_eq!(
3668 serde_json::to_string(&NativeState::Running).unwrap(),
3669 "\"running\""
3670 );
3671 assert_eq!(
3672 serde_json::to_string(&NativeState::Stopping).unwrap(),
3673 "\"stopping\""
3674 );
3675 assert_eq!(
3676 serde_json::to_string(&NativeState::Timeout).unwrap(),
3677 "\"timeout\""
3678 );
3679 assert_eq!(
3680 serde_json::to_string(&NativeState::Expired).unwrap(),
3681 "\"expired\""
3682 );
3683 assert_eq!(
3684 serde_json::to_string(&NativeState::Recovering).unwrap(),
3685 "\"recovering\""
3686 );
3687 assert_eq!(
3688 serde_json::to_string(&NativeState::Error).unwrap(),
3689 "\"error\""
3690 );
3691 }
3692
3693 #[test]
3696 fn service_status_backward_compat_deserialize_old_json() {
3697 let old_json = r#"{"state":"running","lastError":null}"#;
3698 let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
3699 assert_eq!(status.state, ServiceState::Running);
3700 assert_eq!(status.last_error, None);
3701 assert_eq!(status.desired_running, None);
3702 assert_eq!(status.native_state, None);
3703 assert_eq!(status.platform_mode, None);
3704 assert_eq!(status.last_start_config, None);
3705 assert_eq!(status.last_heartbeat_at, None);
3706 assert_eq!(status.restart_attempt, None);
3707 assert_eq!(status.recovery_reason, None);
3708 assert_eq!(status.platform_error, None);
3709 }
3710
3711 #[test]
3712 fn service_status_new_fields_serialize_when_present() {
3713 let status = ServiceStatus {
3714 state: ServiceState::Running,
3715 last_error: None,
3716 desired_running: Some(true),
3717 native_state: Some(NativeState::Running),
3718 platform_mode: Some(LifecycleMode::AndroidForegroundService),
3719 last_start_config: Some(StartConfig::default()),
3720 last_heartbeat_at: Some(1234567890),
3721 restart_attempt: Some(2),
3722 recovery_reason: Some("boot recovery".into()),
3723 platform_error: Some("timeout exceeded".into()),
3724 };
3725 let json = serde_json::to_string(&status).unwrap();
3726 assert!(json.contains("\"desiredRunning\":true"), "{json}");
3727 assert!(json.contains("\"nativeState\":\"running\""), "{json}");
3728 assert!(
3729 json.contains("\"platformMode\":\"androidForegroundService\""),
3730 "{json}"
3731 );
3732 assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
3733 assert!(json.contains("\"restartAttempt\":2"), "{json}");
3734 assert!(
3735 json.contains("\"recoveryReason\":\"boot recovery\""),
3736 "{json}"
3737 );
3738 assert!(
3739 json.contains("\"platformError\":\"timeout exceeded\""),
3740 "{json}"
3741 );
3742 }
3743
3744 #[test]
3745 fn service_status_new_fields_absent_when_none() {
3746 let status = ServiceStatus {
3747 state: ServiceState::Idle,
3748 last_error: None,
3749 desired_running: None,
3750 native_state: None,
3751 platform_mode: None,
3752 last_start_config: None,
3753 last_heartbeat_at: None,
3754 restart_attempt: None,
3755 recovery_reason: None,
3756 platform_error: None,
3757 };
3758 let json = serde_json::to_string(&status).unwrap();
3759 assert!(!json.contains("desiredRunning"), "should be absent: {json}");
3760 assert!(!json.contains("nativeState"), "should be absent: {json}");
3761 assert!(!json.contains("platformMode"), "should be absent: {json}");
3762 assert!(
3763 !json.contains("lastStartConfig"),
3764 "should be absent: {json}"
3765 );
3766 assert!(
3767 !json.contains("lastHeartbeatAt"),
3768 "should be absent: {json}"
3769 );
3770 assert!(!json.contains("restartAttempt"), "should be absent: {json}");
3771 assert!(!json.contains("recoveryReason"), "should be absent: {json}");
3772 assert!(!json.contains("platformError"), "should be absent: {json}");
3773 }
3774
3775 #[test]
3776 fn service_status_default_impl() {
3777 let status = ServiceStatus::default();
3778 assert_eq!(status.state, ServiceState::Idle);
3779 assert_eq!(status.last_error, None);
3780 assert_eq!(status.desired_running, None);
3781 assert_eq!(status.native_state, None);
3782 assert_eq!(status.platform_mode, None);
3783 assert_eq!(status.last_start_config, None);
3784 assert_eq!(status.last_heartbeat_at, None);
3785 assert_eq!(status.restart_attempt, None);
3786 assert_eq!(status.recovery_reason, None);
3787 assert_eq!(status.platform_error, None);
3788 }
3789
3790 #[test]
3791 fn service_status_full_roundtrip_with_all_fields() {
3792 let status = ServiceStatus {
3793 state: ServiceState::Running,
3794 last_error: Some("previous crash".into()),
3795 desired_running: Some(true),
3796 native_state: Some(NativeState::Recovering),
3797 platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
3798 last_start_config: Some(StartConfig {
3799 service_label: "Sync".into(),
3800 foreground_service_type: "dataSync".into(),
3801 }),
3802 last_heartbeat_at: Some(999),
3803 restart_attempt: Some(3),
3804 recovery_reason: Some("force stop".into()),
3805 platform_error: Some("scheduler busy".into()),
3806 };
3807 let json = serde_json::to_string(&status).unwrap();
3808 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3809 assert_eq!(de.state, ServiceState::Running);
3810 assert_eq!(de.last_error, Some("previous crash".into()));
3811 assert_eq!(de.desired_running, Some(true));
3812 assert_eq!(de.native_state, Some(NativeState::Recovering));
3813 assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
3814 assert!(de.last_start_config.is_some());
3815 assert_eq!(de.last_heartbeat_at, Some(999));
3816 assert_eq!(de.restart_attempt, Some(3));
3817 assert_eq!(de.recovery_reason, Some("force stop".into()));
3818 assert_eq!(de.platform_error, Some("scheduler busy".into()));
3819 }
3820
3821 #[test]
3822 fn platform_capabilities_deserialize_from_json() {
3823 let json = r#"{
3824 "platform":"ios",
3825 "lifecycleMode":"iosBgTaskScheduler",
3826 "survivesAppClose":"bestEffort",
3827 "survivesReboot":"bestEffort",
3828 "survivesForceQuit":"unsupported",
3829 "backgroundExecution":"bestEffort",
3830 "limitations":["Cannot guarantee continuous execution"],
3831 "requiredSetup":["UIBackgroundModes in Info.plist"]
3832 }"#;
3833 let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
3834 assert_eq!(caps.platform, Platform::Ios);
3835 assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
3836 assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
3837 assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
3838 assert_eq!(caps.limitations.len(), 1);
3839 assert_eq!(caps.required_setup.len(), 1);
3840 }
3841
3842 #[test]
3845 fn ios_scheduling_status_both_scheduled() {
3846 let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3847 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3848 assert!(status.refresh_scheduled);
3849 assert!(status.processing_scheduled);
3850 assert_eq!(status.refresh_error, None);
3851 assert_eq!(status.processing_error, None);
3852 }
3853
3854 #[test]
3855 fn ios_scheduling_status_partial_success() {
3856 let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
3857 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3858 assert!(status.refresh_scheduled);
3859 assert!(!status.processing_scheduled);
3860 assert_eq!(status.refresh_error, None);
3861 assert_eq!(status.processing_error, Some("not permitted".to_string()));
3862 }
3863
3864 #[test]
3865 fn ios_scheduling_status_with_errors() {
3866 let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
3867 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3868 assert!(!status.refresh_scheduled);
3869 assert!(!status.processing_scheduled);
3870 assert_eq!(status.refresh_error, Some("err1".to_string()));
3871 assert_eq!(status.processing_error, Some("err2".to_string()));
3872 }
3873
3874 #[test]
3875 fn ios_scheduling_status_serde_roundtrip() {
3876 let status = IOSSchedulingStatus {
3877 refresh_scheduled: true,
3878 processing_scheduled: false,
3879 refresh_error: None,
3880 processing_error: Some("busy".into()),
3881 };
3882 let json = serde_json::to_string(&status).unwrap();
3883 let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
3884 assert_eq!(de, status);
3885 }
3886
3887 #[test]
3888 fn ios_scheduling_status_json_keys_camel_case() {
3889 let status = IOSSchedulingStatus {
3890 refresh_scheduled: true,
3891 processing_scheduled: true,
3892 refresh_error: Some("err".into()),
3893 processing_error: None,
3894 };
3895 let json = serde_json::to_string(&status).unwrap();
3896 assert!(json.contains("\"refreshScheduled\":"), "{json}");
3897 assert!(json.contains("\"processingScheduled\":"), "{json}");
3898 assert!(json.contains("\"refreshError\":"), "{json}");
3899 assert!(
3900 !json.contains("processingError"),
3901 "None fields should be absent: {json}"
3902 );
3903 }
3904
3905 #[test]
3906 fn ios_scheduling_status_from_value_null_errors() {
3907 let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3909 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3910 assert!(status.refresh_scheduled);
3911 assert!(status.processing_scheduled);
3912 assert_eq!(status.refresh_error, None);
3913 assert_eq!(status.processing_error, None);
3914 }
3915
3916 #[test]
3917 fn ios_scheduling_status_from_value_missing_errors() {
3918 let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3920 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3921 assert!(status.refresh_scheduled);
3922 assert!(status.processing_scheduled);
3923 assert_eq!(status.refresh_error, None);
3924 assert_eq!(status.processing_error, None);
3925 }
3926
3927 const SWIFT_SCHEDULING_STATUS_PAYLOAD: &str = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3943
3944 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}"#;
3949
3950 #[test]
3951 fn ios_scheduling_status_parses_exact_swift_payload() {
3952 let status: IOSSchedulingStatus =
3953 serde_json::from_value(serde_json::from_str(SWIFT_SCHEDULING_STATUS_PAYLOAD).unwrap())
3954 .expect("the exact Swift getSchedulingStatus payload must deserialize");
3955 assert!(status.refresh_scheduled);
3956 assert!(status.processing_scheduled);
3957 assert_eq!(status.refresh_error, None);
3958 assert_eq!(status.processing_error, None);
3959 }
3960
3961 #[test]
3962 fn ios_desired_state_status_parses_exact_swift_payload() {
3963 let status: IOSDesiredStateStatus =
3964 serde_json::from_value(serde_json::from_str(SWIFT_DESIRED_STATE_PAYLOAD).unwrap())
3965 .expect("the exact Swift getDesiredStateStatus payload must deserialize");
3966 assert!(status.desired_running);
3967 assert_eq!(
3968 status.last_start_config.as_deref(),
3969 Some("{\"label\":\"App\"}")
3970 );
3971 assert_eq!(status.last_schedule_error, None);
3972 assert_eq!(status.last_task_kind.as_deref(), Some("refresh"));
3973 assert_eq!(status.last_task_started_at, Some(1719500000.5));
3974 assert_eq!(status.last_task_completed_at, None);
3975 assert_eq!(status.last_completion_reason, None);
3976 assert_eq!(status.notification_granted, None);
3977 }
3978
3979 #[test]
3980 fn ios_desired_state_status_defaults_when_never_started() {
3981 let json = r#"{"desiredRunning":false,"lastStartConfig":null,"lastScheduleError":null,"lastTaskKind":null,"lastTaskStartedAt":null,"lastTaskCompletedAt":null}"#;
3984 let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
3985 assert!(!status.desired_running);
3986 assert_eq!(status.last_start_config, None);
3987 assert_eq!(status.last_task_started_at, None);
3988 }
3989
3990 #[test]
3991 fn ios_desired_state_status_camel_case_roundtrip() {
3992 let status = IOSDesiredStateStatus {
3993 desired_running: true,
3994 last_start_config: Some("{}".into()),
3995 last_task_kind: Some("processing".into()),
3996 last_task_started_at: Some(1.0),
3997 last_task_completed_at: None,
3998 last_schedule_error: Some("boom".into()),
3999 last_completion_reason: Some("expired".into()),
4000 notification_granted: Some(true),
4001 };
4002 let json = serde_json::to_string(&status).unwrap();
4003 assert!(json.contains("\"desiredRunning\":"), "{json}");
4004 assert!(json.contains("\"lastStartConfig\":"), "{json}");
4005 assert!(json.contains("\"lastTaskKind\":"), "{json}");
4006 assert!(json.contains("\"lastScheduleError\":"), "{json}");
4007 assert!(json.contains("\"lastCompletionReason\":"), "{json}");
4008 assert!(json.contains("\"notificationGranted\":"), "{json}");
4009 let de: IOSDesiredStateStatus = serde_json::from_str(&json).unwrap();
4010 assert_eq!(de, status);
4011 }
4012
4013 #[test]
4017 fn ios_desired_state_status_carries_last_completion_reason() {
4018 let json = r#"{"desiredRunning":true,"lastCompletionReason":"completed"}"#;
4019 let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
4020 assert_eq!(status.last_completion_reason.as_deref(), Some("completed"));
4021
4022 let legacy = r#"{"desiredRunning":false}"#;
4024 let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
4025 assert_eq!(de.last_completion_reason, None);
4026 }
4027
4028 #[test]
4033 fn ios_desired_state_status_carries_notification_granted() {
4034 let granted = r#"{"desiredRunning":true,"notificationGranted":true}"#;
4035 let status: IOSDesiredStateStatus = serde_json::from_str(granted).unwrap();
4036 assert_eq!(status.notification_granted, Some(true));
4037
4038 let denied = r#"{"desiredRunning":true,"notificationGranted":false}"#;
4039 let status: IOSDesiredStateStatus = serde_json::from_str(denied).unwrap();
4040 assert_eq!(status.notification_granted, Some(false));
4041
4042 let legacy = r#"{"desiredRunning":false}"#;
4044 let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
4045 assert_eq!(de.notification_granted, None);
4046 }
4047
4048 #[test]
4051 fn os_service_install_state_serde_roundtrip() {
4052 for variant in [
4053 OsServiceInstallState::NotInstalled,
4054 OsServiceInstallState::Installed,
4055 OsServiceInstallState::Running,
4056 ] {
4057 let json = serde_json::to_string(&variant).unwrap();
4058 let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
4059 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4060 }
4061 }
4062
4063 #[test]
4064 fn os_service_install_state_json_values_camel_case() {
4065 assert_eq!(
4066 serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
4067 "\"notInstalled\""
4068 );
4069 assert_eq!(
4070 serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
4071 "\"installed\""
4072 );
4073 assert_eq!(
4074 serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
4075 "\"running\""
4076 );
4077 }
4078
4079 #[test]
4082 fn os_service_status_serde_roundtrip() {
4083 let status = OsServiceStatus {
4084 label: "com.example.bg-service".into(),
4085 mode: "systemd".into(),
4086 installed: OsServiceInstallState::Running,
4087 ipc_connected: true,
4088 socket_path: Some("/tmp/test.sock".into()),
4089 last_error: None,
4090 };
4091 let json = serde_json::to_string(&status).unwrap();
4092 let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
4093 assert_eq!(de.label, "com.example.bg-service");
4094 assert_eq!(de.mode, "systemd");
4095 assert_eq!(de.installed, OsServiceInstallState::Running);
4096 assert!(de.ipc_connected);
4097 assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
4098 assert_eq!(de.last_error, None);
4099 }
4100
4101 #[test]
4102 fn os_service_status_json_keys_camel_case() {
4103 let status = OsServiceStatus {
4104 label: "test".into(),
4105 mode: "launchd".into(),
4106 installed: OsServiceInstallState::Installed,
4107 ipc_connected: false,
4108 socket_path: Some("/run/test.sock".into()),
4109 last_error: Some("timeout".into()),
4110 };
4111 let json = serde_json::to_string(&status).unwrap();
4112 assert!(json.contains("\"label\":"), "{json}");
4113 assert!(json.contains("\"mode\":"), "{json}");
4114 assert!(json.contains("\"installed\":"), "{json}");
4115 assert!(json.contains("\"ipcConnected\":"), "{json}");
4116 assert!(json.contains("\"socketPath\":"), "{json}");
4117 assert!(json.contains("\"lastError\":"), "{json}");
4118 }
4119
4120 #[test]
4121 fn os_service_status_optional_fields_absent_when_none() {
4122 let status = OsServiceStatus {
4123 label: "test".into(),
4124 mode: "systemd".into(),
4125 installed: OsServiceInstallState::NotInstalled,
4126 ipc_connected: false,
4127 socket_path: None,
4128 last_error: None,
4129 };
4130 let json = serde_json::to_string(&status).unwrap();
4131 assert!(!json.contains("socketPath"), "should be absent: {json}");
4132 assert!(!json.contains("lastError"), "should be absent: {json}");
4133 }
4134
4135 #[test]
4136 fn os_service_status_with_all_optional_fields() {
4137 let status = OsServiceStatus {
4138 label: "com.test".into(),
4139 mode: "launchd".into(),
4140 installed: OsServiceInstallState::Running,
4141 ipc_connected: true,
4142 socket_path: Some("/var/run/com.test.sock".into()),
4143 last_error: Some("connection refused".into()),
4144 };
4145 let json = serde_json::to_string(&status).unwrap();
4146 assert!(
4147 json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
4148 "{json}"
4149 );
4150 assert!(
4151 json.contains("\"lastError\":\"connection refused\""),
4152 "{json}"
4153 );
4154 }
4155
4156 #[test]
4157 fn os_service_status_deserialize_from_json() {
4158 let json = r#"{
4159 "label":"com.example.svc",
4160 "mode":"systemd",
4161 "installed":"running",
4162 "ipcConnected":true,
4163 "socketPath":"/tmp/test.sock"
4164 }"#;
4165 let status: OsServiceStatus = serde_json::from_str(json).unwrap();
4166 assert_eq!(status.label, "com.example.svc");
4167 assert_eq!(status.mode, "systemd");
4168 assert_eq!(status.installed, OsServiceInstallState::Running);
4169 assert!(status.ipc_connected);
4170 assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
4171 assert_eq!(status.last_error, None);
4172 }
4173
4174 #[test]
4177 fn pending_task_info_serde_roundtrip() {
4178 let info = PendingTaskInfo {
4179 task_kind: "refresh".into(),
4180 identifier: "com.example.app.bg-refresh".into(),
4181 received_at: 1700000000.123,
4182 consumed_at: None,
4183 };
4184 let json = serde_json::to_string(&info).unwrap();
4185 let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
4186 assert_eq!(de, info);
4187 }
4188
4189 #[test]
4190 fn pending_task_info_json_keys_camel_case() {
4191 let info = PendingTaskInfo {
4192 task_kind: "processing".into(),
4193 identifier: "test-id".into(),
4194 received_at: 123456.0,
4195 consumed_at: Some(123500.0),
4196 };
4197 let json = serde_json::to_string(&info).unwrap();
4198 assert!(json.contains("\"taskKind\":"), "{json}");
4199 assert!(json.contains("\"identifier\":"), "{json}");
4200 assert!(json.contains("\"receivedAt\":"), "{json}");
4201 assert!(json.contains("\"consumedAt\":"), "{json}");
4202 }
4203
4204 #[test]
4205 fn pending_task_info_from_native_response() {
4206 let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
4208 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
4209 assert_eq!(info.task_kind, "refresh");
4210 assert_eq!(info.identifier, "com.example.bg-refresh");
4211 assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
4212 assert_eq!(info.consumed_at, None);
4213 }
4214
4215 #[test]
4216 fn pending_task_info_processing_kind() {
4217 let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
4218 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
4219 assert_eq!(info.task_kind, "processing");
4220 assert_eq!(info.identifier, "com.example.bg-processing");
4221 assert_eq!(info.consumed_at, None);
4222 }
4223
4224 #[test]
4225 fn pending_task_info_consumed_at_roundtrip() {
4226 let info = PendingTaskInfo {
4227 task_kind: "refresh".into(),
4228 identifier: "com.example.bg-refresh".into(),
4229 received_at: 1700000000.0,
4230 consumed_at: Some(1700000060.5),
4231 };
4232 let json = serde_json::to_string(&info).unwrap();
4233 assert!(json.contains("\"consumedAt\":1700000060.5"), "{json}");
4234 let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
4235 assert_eq!(de.consumed_at, Some(1700000060.5));
4236 }
4237
4238 #[test]
4239 fn pending_task_info_consumed_at_null_deserializes_to_none() {
4240 let json = r#"{"taskKind":"refresh","identifier":"id","receivedAt":1.0,"consumedAt":null}"#;
4241 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
4242 assert_eq!(info.consumed_at, None);
4243 }
4244
4245 #[test]
4248 fn from_pending_payload_unconsumed_returns_some() {
4249 let value = serde_json::json!({
4251 "taskKind": "refresh",
4252 "identifier": "com.example.bg-refresh",
4253 "receivedAt": 1700000000.0,
4254 "consumedAt": serde_json::Value::Null,
4255 });
4256 let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4257 assert_eq!(
4258 pending,
4259 Some(PendingTaskInfo {
4260 task_kind: "refresh".into(),
4261 identifier: "com.example.bg-refresh".into(),
4262 received_at: 1700000000.0,
4263 consumed_at: None,
4264 })
4265 );
4266 }
4267
4268 #[test]
4269 fn from_pending_payload_consumed_returns_none() {
4270 let value = serde_json::json!({
4274 "taskKind": "processing",
4275 "identifier": "com.example.bg-processing",
4276 "receivedAt": 1700000000.0,
4277 "consumedAt": 1700000060.5,
4278 });
4279 let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4280 assert_eq!(pending, None);
4281 }
4282
4283 #[test]
4284 fn from_pending_payload_no_record_returns_none() {
4285 let value = serde_json::json!({
4287 "taskKind": serde_json::Value::Null,
4288 "identifier": serde_json::Value::Null,
4289 "receivedAt": serde_json::Value::Null,
4290 "consumedAt": serde_json::Value::Null,
4291 });
4292 let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4293 assert_eq!(pending, None);
4294 }
4295
4296 #[test]
4299 fn lifecycle_state_all_variants_serde_roundtrip() {
4300 for variant in [
4301 LifecycleState::Idle,
4302 LifecycleState::Starting,
4303 LifecycleState::Running,
4304 LifecycleState::Stopping,
4305 LifecycleState::Stopped,
4306 LifecycleState::Recovering,
4307 LifecycleState::RecoveryPending,
4308 LifecycleState::Expired,
4309 LifecycleState::Blocked,
4310 LifecycleState::Error,
4311 ] {
4312 let json = serde_json::to_string(&variant).unwrap();
4313 let de: LifecycleState = serde_json::from_str(&json).unwrap();
4314 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4315 }
4316 }
4317
4318 #[test]
4319 fn lifecycle_state_json_values_are_camel_case() {
4320 assert_eq!(
4321 serde_json::to_string(&LifecycleState::Idle).unwrap(),
4322 "\"idle\""
4323 );
4324 assert_eq!(
4325 serde_json::to_string(&LifecycleState::Starting).unwrap(),
4326 "\"starting\""
4327 );
4328 assert_eq!(
4329 serde_json::to_string(&LifecycleState::Running).unwrap(),
4330 "\"running\""
4331 );
4332 assert_eq!(
4333 serde_json::to_string(&LifecycleState::Stopping).unwrap(),
4334 "\"stopping\""
4335 );
4336 assert_eq!(
4337 serde_json::to_string(&LifecycleState::Stopped).unwrap(),
4338 "\"stopped\""
4339 );
4340 assert_eq!(
4341 serde_json::to_string(&LifecycleState::Recovering).unwrap(),
4342 "\"recovering\""
4343 );
4344 assert_eq!(
4345 serde_json::to_string(&LifecycleState::RecoveryPending).unwrap(),
4346 "\"recoveryPending\""
4347 );
4348 assert_eq!(
4349 serde_json::to_string(&LifecycleState::Expired).unwrap(),
4350 "\"expired\""
4351 );
4352 assert_eq!(
4353 serde_json::to_string(&LifecycleState::Blocked).unwrap(),
4354 "\"blocked\""
4355 );
4356 assert_eq!(
4357 serde_json::to_string(&LifecycleState::Error).unwrap(),
4358 "\"error\""
4359 );
4360 }
4361
4362 #[test]
4365 fn service_state_idle_maps_to_lifecycle_idle() {
4366 assert_eq!(
4367 LifecycleState::from(ServiceState::Idle),
4368 LifecycleState::Idle
4369 );
4370 }
4371
4372 #[test]
4373 fn service_state_initializing_maps_to_lifecycle_starting() {
4374 assert_eq!(
4375 LifecycleState::from(ServiceState::Initializing),
4376 LifecycleState::Starting
4377 );
4378 }
4379
4380 #[test]
4381 fn service_state_running_maps_to_lifecycle_running() {
4382 assert_eq!(
4383 LifecycleState::from(ServiceState::Running),
4384 LifecycleState::Running
4385 );
4386 }
4387
4388 #[test]
4389 fn service_state_stopped_maps_to_lifecycle_stopped() {
4390 assert_eq!(
4391 LifecycleState::from(ServiceState::Stopped),
4392 LifecycleState::Stopped
4393 );
4394 }
4395
4396 #[test]
4399 fn severity_all_variants_serde_roundtrip() {
4400 for variant in [Severity::Error, Severity::Warning, Severity::Info] {
4401 let json = serde_json::to_string(&variant).unwrap();
4402 let de: Severity = serde_json::from_str(&json).unwrap();
4403 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4404 }
4405 }
4406
4407 #[test]
4408 fn severity_json_values_are_camel_case() {
4409 assert_eq!(
4410 serde_json::to_string(&Severity::Error).unwrap(),
4411 "\"error\""
4412 );
4413 assert_eq!(
4414 serde_json::to_string(&Severity::Warning).unwrap(),
4415 "\"warning\""
4416 );
4417 assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
4418 }
4419
4420 #[test]
4423 fn validation_issue_serde_roundtrip() {
4424 let issue = ValidationIssue {
4425 severity: Severity::Error,
4426 code: "ANDROID_MISSING_PERMISSION".into(),
4427 message: "Missing FOREGROUND_SERVICE permission".into(),
4428 fix: Some("Add FOREGROUND_SERVICE permission to AndroidManifest.xml".into()),
4429 platform: Platform::Android,
4430 };
4431 let json = serde_json::to_string(&issue).unwrap();
4432 let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4433 assert_eq!(de, issue);
4434 }
4435
4436 #[test]
4437 fn validation_issue_without_fix() {
4438 let issue = ValidationIssue {
4439 severity: Severity::Warning,
4440 code: "IOS_SCHEDULER_BUSY".into(),
4441 message: "BGTaskScheduler is busy".into(),
4442 fix: None,
4443 platform: Platform::Ios,
4444 };
4445 let json = serde_json::to_string(&issue).unwrap();
4446 assert!(
4447 !json.contains("fix"),
4448 "fix should be absent when None: {json}"
4449 );
4450 let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4451 assert_eq!(de, issue);
4452 }
4453
4454 #[test]
4455 fn validation_issue_json_keys_camel_case() {
4456 let issue = ValidationIssue {
4457 severity: Severity::Info,
4458 code: "TEST".into(),
4459 message: "test".into(),
4460 fix: Some("do something".into()),
4461 platform: Platform::Linux,
4462 };
4463 let json = serde_json::to_string(&issue).unwrap();
4464 assert!(json.contains("\"severity\":"), "{json}");
4465 assert!(json.contains("\"code\":"), "{json}");
4466 assert!(json.contains("\"message\":"), "{json}");
4467 assert!(json.contains("\"fix\":"), "{json}");
4468 assert!(json.contains("\"platform\":"), "{json}");
4469 }
4470
4471 #[test]
4474 fn lifecycle_status_serde_roundtrip_minimal() {
4475 let status = LifecycleStatus {
4476 state: LifecycleState::Idle,
4477 desired_running: false,
4478 recovery_enabled: false,
4479 recovery_pending: false,
4480 recovery_reason: None,
4481 last_start_config: None,
4482 last_platform_state: None,
4483 last_platform_error: None,
4484 last_error: None,
4485 platform: Platform::Unknown,
4486 capabilities: PlatformCapabilities {
4487 platform: Platform::Unknown,
4488 lifecycle_mode: LifecycleMode::DesktopInProcess,
4489 survives_app_close: LifecycleGuarantee::Unsupported,
4490 survives_reboot: LifecycleGuarantee::Unsupported,
4491 survives_force_quit: LifecycleGuarantee::Unsupported,
4492 background_execution: LifecycleGuarantee::Unsupported,
4493 limitations: vec![],
4494 required_setup: vec![],
4495 },
4496 issues: vec![],
4497 native_running: None,
4498 native_foreground: None,
4499 adopted: None,
4500 degraded: None,
4501 degraded_reason: None,
4502 data_dir: None,
4503 };
4504 let json = serde_json::to_string(&status).unwrap();
4505 let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4506 assert_eq!(de.state, LifecycleState::Idle);
4507 assert!(!de.desired_running);
4508 assert!(!de.recovery_enabled);
4509 assert!(!de.recovery_pending);
4510 assert_eq!(de.recovery_reason, None);
4511 assert_eq!(de.last_start_config, None);
4512 assert_eq!(de.last_platform_state, None);
4513 assert_eq!(de.last_platform_error, None);
4514 assert_eq!(de.last_error, None);
4515 assert_eq!(de.platform, Platform::Unknown);
4516 assert!(de.issues.is_empty());
4517 }
4518
4519 #[test]
4520 fn lifecycle_status_optional_fields_absent_when_none() {
4521 let status = LifecycleStatus {
4522 state: LifecycleState::Idle,
4523 desired_running: false,
4524 recovery_enabled: false,
4525 recovery_pending: false,
4526 recovery_reason: None,
4527 last_start_config: None,
4528 last_platform_state: None,
4529 last_platform_error: None,
4530 last_error: None,
4531 platform: Platform::Unknown,
4532 capabilities: PlatformCapabilities {
4533 platform: Platform::Unknown,
4534 lifecycle_mode: LifecycleMode::DesktopInProcess,
4535 survives_app_close: LifecycleGuarantee::Unsupported,
4536 survives_reboot: LifecycleGuarantee::Unsupported,
4537 survives_force_quit: LifecycleGuarantee::Unsupported,
4538 background_execution: LifecycleGuarantee::Unsupported,
4539 limitations: vec![],
4540 required_setup: vec![],
4541 },
4542 issues: vec![],
4543 native_running: None,
4544 native_foreground: None,
4545 adopted: None,
4546 degraded: None,
4547 degraded_reason: None,
4548 data_dir: None,
4549 };
4550 let json = serde_json::to_string(&status).unwrap();
4551 assert!(!json.contains("recoveryReason"), "should be absent: {json}");
4552 assert!(
4553 !json.contains("lastStartConfig"),
4554 "should be absent: {json}"
4555 );
4556 assert!(
4557 !json.contains("lastPlatformState"),
4558 "should be absent: {json}"
4559 );
4560 assert!(
4561 !json.contains("lastPlatformError"),
4562 "should be absent: {json}"
4563 );
4564 assert!(!json.contains("lastError"), "should be absent: {json}");
4565 assert!(!json.contains("nativeRunning"), "should be absent: {json}");
4566 assert!(
4567 !json.contains("nativeForeground"),
4568 "should be absent: {json}"
4569 );
4570 assert!(!json.contains("adopted"), "should be absent: {json}");
4571 assert!(!json.contains("degraded"), "should be absent: {json}");
4572 assert!(!json.contains("degradedReason"), "should be absent: {json}");
4573 }
4574
4575 #[test]
4576 fn lifecycle_status_full_roundtrip_with_all_fields() {
4577 let status = LifecycleStatus {
4578 state: LifecycleState::Running,
4579 desired_running: true,
4580 recovery_enabled: true,
4581 recovery_pending: false,
4582 recovery_reason: Some("boot recovery".into()),
4583 last_start_config: Some(StartConfig {
4584 service_label: "Sync".into(),
4585 foreground_service_type: "dataSync".into(),
4586 }),
4587 last_platform_state: Some("running".into()),
4588 last_platform_error: Some("timeout exceeded".into()),
4589 last_error: Some("previous crash".into()),
4590 platform: Platform::Android,
4591 capabilities: PlatformCapabilities {
4592 platform: Platform::Android,
4593 lifecycle_mode: LifecycleMode::AndroidForegroundService,
4594 survives_app_close: LifecycleGuarantee::BestEffort,
4595 survives_reboot: LifecycleGuarantee::BestEffort,
4596 survives_force_quit: LifecycleGuarantee::Unsupported,
4597 background_execution: LifecycleGuarantee::Guaranteed,
4598 limitations: vec!["OEM battery optimization".into()],
4599 required_setup: vec!["FOREGROUND_SERVICE permission".into()],
4600 },
4601 issues: vec![ValidationIssue {
4602 severity: Severity::Warning,
4603 code: "ANDROID_BATTERY_OPTIMIZED".into(),
4604 message: "Battery optimization may kill the service".into(),
4605 fix: Some("Request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS".into()),
4606 platform: Platform::Android,
4607 }],
4608 native_running: Some(true),
4609 native_foreground: Some(true),
4610 adopted: Some(false),
4611 degraded: Some(false),
4612 degraded_reason: None,
4613 data_dir: None,
4614 };
4615 let json = serde_json::to_string(&status).unwrap();
4616 let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4617 assert_eq!(de.state, LifecycleState::Running);
4618 assert!(de.desired_running);
4619 assert!(de.recovery_enabled);
4620 assert!(!de.recovery_pending);
4621 assert_eq!(de.recovery_reason, Some("boot recovery".into()));
4622 assert!(de.last_start_config.is_some());
4623 assert_eq!(de.last_platform_state, Some("running".into()));
4624 assert_eq!(de.last_platform_error, Some("timeout exceeded".into()));
4625 assert_eq!(de.last_error, Some("previous crash".into()));
4626 assert_eq!(de.platform, Platform::Android);
4627 assert_eq!(de.issues.len(), 1);
4628 assert_eq!(de.native_running, Some(true));
4629 assert_eq!(de.native_foreground, Some(true));
4630 assert_eq!(de.adopted, Some(false));
4631 assert_eq!(de.degraded, Some(false));
4632 assert_eq!(de.degraded_reason, None);
4633 }
4634
4635 #[test]
4636 fn lifecycle_status_json_keys_camel_case() {
4637 let status = LifecycleStatus {
4638 state: LifecycleState::RecoveryPending,
4639 desired_running: true,
4640 recovery_enabled: true,
4641 recovery_pending: true,
4642 recovery_reason: Some("platform timeout".into()),
4643 last_start_config: None,
4644 last_platform_state: Some("timeout".into()),
4645 last_platform_error: None,
4646 last_error: None,
4647 platform: Platform::Ios,
4648 capabilities: PlatformCapabilities {
4649 platform: Platform::Ios,
4650 lifecycle_mode: LifecycleMode::IosBgTaskScheduler,
4651 survives_app_close: LifecycleGuarantee::BestEffort,
4652 survives_reboot: LifecycleGuarantee::BestEffort,
4653 survives_force_quit: LifecycleGuarantee::Unsupported,
4654 background_execution: LifecycleGuarantee::BestEffort,
4655 limitations: vec![],
4656 required_setup: vec![],
4657 },
4658 issues: vec![],
4659 native_running: None,
4660 native_foreground: None,
4661 adopted: None,
4662 degraded: None,
4663 degraded_reason: None,
4664 data_dir: None,
4665 };
4666 let json = serde_json::to_string(&status).unwrap();
4667 assert!(json.contains("\"state\":"), "{json}");
4668 assert!(json.contains("\"desiredRunning\":"), "{json}");
4669 assert!(json.contains("\"recoveryEnabled\":"), "{json}");
4670 assert!(json.contains("\"recoveryPending\":"), "{json}");
4671 assert!(json.contains("\"recoveryReason\":"), "{json}");
4672 assert!(json.contains("\"lastPlatformState\":"), "{json}");
4673 assert!(json.contains("\"platform\":"), "{json}");
4674 assert!(json.contains("\"capabilities\":"), "{json}");
4675 assert!(json.contains("\"issues\":"), "{json}");
4676 }
4677
4678 #[test]
4681 fn plugin_config_camel_case_json_deserializes_correctly() {
4682 let json = r#"{
4683 "androidForegroundServiceTypes": ["remoteMessaging", "dataSync"],
4684 "androidOnTimeout": "notifyUser",
4685 "androidNotificationChannelId": "bg_service",
4686 "androidNotificationChannelName": "Background Service",
4687 "androidShowStopAction": true,
4688 "iosSafetyTimeoutSecs": 28.0,
4689 "iosEarliestRefreshBeginMinutes": 15.0,
4690 "iosEarliestProcessingBeginMinutes": 15.0,
4691 "desktopServiceMode": "inProcess"
4692 }"#;
4693 let config: PluginConfig = serde_json::from_str(json).unwrap();
4694 assert_eq!(
4695 config.android_foreground_service_types,
4696 vec!["remoteMessaging", "dataSync"]
4697 );
4698 assert_eq!(config.android_on_timeout, "notifyUser");
4699 assert_eq!(config.android_notification_channel_id, "bg_service");
4700 assert_eq!(
4701 config.android_notification_channel_name,
4702 "Background Service"
4703 );
4704 assert!(config.android_show_stop_action);
4705 assert_eq!(config.ios_safety_timeout_secs, 28.0);
4706 assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
4707 assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
4708 #[cfg(feature = "desktop-service")]
4709 assert_eq!(config.desktop_service_mode, "inProcess");
4710 }
4711
4712 #[test]
4713 fn plugin_config_snake_case_json_falls_back_to_defaults() {
4714 let json = r#"{
4715 "android_foreground_service_types": ["remoteMessaging", "dataSync"],
4716 "desktop_service_mode": "inProcess"
4717 }"#;
4718 let config: PluginConfig = serde_json::from_str(json).unwrap();
4719 assert_eq!(
4721 config.android_foreground_service_types,
4722 vec!["remoteMessaging"],
4723 "snake_case key should fall back to default"
4724 );
4725 }
4726
4727 #[test]
4730 fn android_service_state_deserialize_full_kotlin_output() {
4731 let json = r#"{
4732 "nativeRunning": true,
4733 "nativeForeground": true,
4734 "desiredRunning": true,
4735 "durableState": "running",
4736 "serviceLabel": "App Service",
4737 "foregroundServiceType": "remoteMessaging",
4738 "notificationId": 9001,
4739 "notificationChannelId": "bg_service",
4740 "recoveryPending": false,
4741 "recoveryReason": null,
4742 "lastPlatformError": null,
4743 "dataDir": "/data/data/com.example.app"
4744 }"#;
4745 let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4746 assert!(state.native_running);
4747 assert!(state.native_foreground);
4748 assert!(state.desired_running);
4749 assert_eq!(state.durable_state, "running");
4750 assert_eq!(state.service_label, Some("App Service".into()));
4751 assert_eq!(
4752 state.foreground_service_type,
4753 Some("remoteMessaging".into())
4754 );
4755 assert_eq!(state.notification_id, Some(9001));
4756 assert_eq!(state.notification_channel_id, Some("bg_service".into()));
4757 assert!(!state.recovery_pending);
4758 assert_eq!(state.recovery_reason, None);
4759 assert_eq!(state.last_platform_error, None);
4760 assert_eq!(state.data_dir, "/data/data/com.example.app");
4761 }
4762
4763 #[test]
4764 fn android_service_state_deserialize_minimal() {
4765 let json = r#"{
4766 "nativeRunning": false,
4767 "nativeForeground": false,
4768 "desiredRunning": false,
4769 "durableState": "stopped",
4770 "recoveryPending": false,
4771 "dataDir": ""
4772 }"#;
4773 let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4774 assert!(!state.native_running);
4775 assert!(!state.native_foreground);
4776 assert!(!state.desired_running);
4777 assert_eq!(state.durable_state, "stopped");
4778 assert_eq!(state.service_label, None);
4779 assert_eq!(state.foreground_service_type, None);
4780 assert_eq!(state.notification_id, None);
4781 assert_eq!(state.notification_channel_id, None);
4782 assert!(!state.recovery_pending);
4783 assert_eq!(state.recovery_reason, None);
4784 assert_eq!(state.last_platform_error, None);
4785 assert_eq!(state.data_dir, "");
4786 }
4787
4788 #[test]
4789 fn android_service_state_serde_roundtrip() {
4790 let state = AndroidServiceState {
4791 native_running: true,
4792 native_foreground: false,
4793 desired_running: true,
4794 durable_state: "starting".into(),
4795 service_label: Some("test".into()),
4796 foreground_service_type: None,
4797 notification_id: None,
4798 notification_channel_id: None,
4799 recovery_pending: true,
4800 recovery_reason: Some("boot".into()),
4801 last_platform_error: Some("crash".into()),
4802 data_dir: "/data/test".into(),
4803 };
4804 let json = serde_json::to_string(&state).unwrap();
4805 let de: AndroidServiceState = serde_json::from_str(&json).unwrap();
4806 assert_eq!(de, state);
4807 }
4808
4809 #[test]
4810 fn android_service_state_optional_fields_absent_when_none() {
4811 let state = AndroidServiceState {
4812 native_running: false,
4813 native_foreground: false,
4814 desired_running: false,
4815 durable_state: "unknown".into(),
4816 service_label: None,
4817 foreground_service_type: None,
4818 notification_id: None,
4819 notification_channel_id: None,
4820 recovery_pending: false,
4821 recovery_reason: None,
4822 last_platform_error: None,
4823 data_dir: "".into(),
4824 };
4825 let json = serde_json::to_string(&state).unwrap();
4826 assert!(!json.contains("serviceLabel"), "absent: {json}");
4827 assert!(!json.contains("foregroundServiceType"), "absent: {json}");
4828 assert!(!json.contains("notificationId"), "absent: {json}");
4829 assert!(!json.contains("notificationChannelId"), "absent: {json}");
4830 assert!(!json.contains("recoveryReason"), "absent: {json}");
4831 assert!(!json.contains("lastPlatformError"), "absent: {json}");
4832 }
4833
4834 #[test]
4837 fn lifecycle_status_native_fields_serialize_when_present() {
4838 let status = LifecycleStatus {
4839 state: LifecycleState::Running,
4840 desired_running: true,
4841 recovery_enabled: true,
4842 recovery_pending: false,
4843 recovery_reason: None,
4844 last_start_config: None,
4845 last_platform_state: None,
4846 last_platform_error: None,
4847 last_error: None,
4848 platform: Platform::Android,
4849 capabilities: PlatformCapabilities {
4850 platform: Platform::Android,
4851 lifecycle_mode: LifecycleMode::AndroidForegroundService,
4852 survives_app_close: LifecycleGuarantee::BestEffort,
4853 survives_reboot: LifecycleGuarantee::BestEffort,
4854 survives_force_quit: LifecycleGuarantee::Unsupported,
4855 background_execution: LifecycleGuarantee::Guaranteed,
4856 limitations: vec![],
4857 required_setup: vec![],
4858 },
4859 issues: vec![],
4860 native_running: Some(true),
4861 native_foreground: Some(true),
4862 adopted: Some(false),
4863 degraded: Some(false),
4864 degraded_reason: Some("native running but Rust idle".into()),
4865 data_dir: None,
4866 };
4867 let json = serde_json::to_string(&status).unwrap();
4868 assert!(json.contains("\"nativeRunning\":true"), "{json}");
4869 assert!(json.contains("\"nativeForeground\":true"), "{json}");
4870 assert!(json.contains("\"adopted\":false"), "{json}");
4871 assert!(json.contains("\"degraded\":false"), "{json}");
4872 assert!(
4873 json.contains("\"degradedReason\":\"native running but Rust idle\""),
4874 "{json}"
4875 );
4876 }
4877
4878 #[test]
4879 fn lifecycle_status_native_fields_deserialize_from_json() {
4880 let json = r#"{
4881 "state": "running",
4882 "desiredRunning": true,
4883 "recoveryEnabled": true,
4884 "recoveryPending": false,
4885 "platform": "android",
4886 "capabilities": {
4887 "platform": "android",
4888 "lifecycleMode": "androidForegroundService",
4889 "survivesAppClose": "bestEffort",
4890 "survivesReboot": "bestEffort",
4891 "survivesForceQuit": "unsupported",
4892 "backgroundExecution": "guaranteed",
4893 "limitations": [],
4894 "requiredSetup": []
4895 },
4896 "issues": [],
4897 "nativeRunning": true,
4898 "nativeForeground": false,
4899 "adopted": true,
4900 "degraded": true,
4901 "degradedReason": "split-brain detected"
4902 }"#;
4903 let status: LifecycleStatus = serde_json::from_str(json).unwrap();
4904 assert_eq!(status.native_running, Some(true));
4905 assert_eq!(status.native_foreground, Some(false));
4906 assert_eq!(status.adopted, Some(true));
4907 assert_eq!(status.degraded, Some(true));
4908 assert_eq!(status.degraded_reason, Some("split-brain detected".into()));
4909 }
4910}