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 #[cfg(feature = "desktop-service")]
300 #[serde(default)]
301 pub desktop_windows_daemon_opt_in: bool,
302}
303
304fn default_ios_safety_timeout() -> f64 {
305 28.0
306}
307
308fn default_ios_cancel_listener_timeout_secs() -> u64 {
309 14400
310}
311
312fn default_ios_processing_safety_timeout_secs() -> f64 {
313 0.0
314}
315
316fn default_ios_earliest_refresh_begin_minutes() -> f64 {
317 15.0
318}
319
320fn default_ios_earliest_processing_begin_minutes() -> f64 {
321 15.0
322}
323
324fn default_ios_processing_ceiling_multiplier() -> f64 {
325 4.0
326}
327
328fn default_android_foreground_service_types() -> Vec<String> {
329 vec!["remoteMessaging".into()]
330}
331
332fn default_android_on_timeout() -> String {
333 "notifyUser".into()
334}
335
336fn default_android_notification_channel_id() -> String {
337 "bg_service".into()
338}
339
340fn default_android_notification_channel_name() -> String {
341 "Background Service".into()
342}
343
344fn default_android_notification_id() -> u32 {
345 9001
346}
347
348fn default_true() -> bool {
349 true
350}
351
352fn default_channel_capacity() -> usize {
353 16
354}
355
356#[cfg(feature = "desktop-service")]
357fn default_desktop_service_mode() -> String {
358 "inProcess".into()
359}
360
361#[cfg(feature = "desktop-service")]
362fn default_desktop_service_start_timeout_ms() -> u64 {
363 5000
364}
365
366impl Default for StartConfig {
367 fn default() -> Self {
368 Self {
369 service_label: default_label(),
370 foreground_service_type: default_foreground_service_type(),
371 }
372 }
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(rename_all = "camelCase")]
381#[non_exhaustive]
382pub enum ServiceState {
383 Idle,
385 Initializing,
387 Running,
389 Stopped,
391}
392
393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398#[serde(rename_all = "camelCase")]
399#[non_exhaustive]
400pub enum LifecycleState {
401 Idle,
403 Starting,
405 Running,
407 Stopping,
409 Stopped,
411 Recovering,
413 RecoveryPending,
415 Expired,
417 Blocked,
419 Error,
421 SetupIdle,
423 LockedIdle,
425}
426
427impl From<ServiceState> for LifecycleState {
428 fn from(state: ServiceState) -> Self {
429 match state {
430 ServiceState::Idle => LifecycleState::Idle,
431 ServiceState::Initializing => LifecycleState::Starting,
432 ServiceState::Running => LifecycleState::Running,
433 ServiceState::Stopped => LifecycleState::Stopped,
434 }
435 }
436}
437
438#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445#[non_exhaustive]
446pub enum NativeState {
447 Idle,
448 Starting,
449 Running,
450 Stopping,
451 Timeout,
452 Expired,
453 Recovering,
454 Error,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
461#[serde(rename_all = "camelCase")]
462pub struct ServiceStatus {
463 pub state: ServiceState,
465 pub last_error: Option<String>,
467
468 #[serde(skip_serializing_if = "Option::is_none")]
471 pub desired_running: Option<bool>,
472 #[serde(skip_serializing_if = "Option::is_none")]
474 pub native_state: Option<NativeState>,
475 #[serde(skip_serializing_if = "Option::is_none")]
477 pub platform_mode: Option<LifecycleMode>,
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub last_start_config: Option<StartConfig>,
481 #[serde(skip_serializing_if = "Option::is_none")]
483 pub last_heartbeat_at: Option<u64>,
484 #[serde(skip_serializing_if = "Option::is_none")]
486 pub restart_attempt: Option<u32>,
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub recovery_reason: Option<String>,
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub platform_error: Option<String>,
493}
494
495impl Default for ServiceStatus {
496 fn default() -> Self {
497 Self {
498 state: ServiceState::Idle,
499 last_error: None,
500 desired_running: None,
501 native_state: None,
502 platform_mode: None,
503 last_start_config: None,
504 last_heartbeat_at: None,
505 restart_attempt: None,
506 recovery_reason: None,
507 platform_error: None,
508 }
509 }
510}
511
512#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "camelCase")]
517#[non_exhaustive]
518pub enum Platform {
519 Android,
520 Ios,
521 Windows,
522 Macos,
523 Linux,
524 Unknown,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
529#[serde(rename_all = "camelCase")]
530#[non_exhaustive]
531pub enum Severity {
532 Error,
533 Warning,
534 Info,
535}
536
537#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
539#[serde(rename_all = "camelCase")]
540#[non_exhaustive]
541pub enum LifecycleMode {
542 AndroidForegroundService,
543 IosBgTaskScheduler,
544 DesktopInProcess,
545 DesktopOsService,
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554#[serde(rename_all = "camelCase")]
555#[non_exhaustive]
556pub enum LifecycleGuarantee {
557 Guaranteed,
558 BestEffort,
559 Unsupported,
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
567#[serde(rename_all = "camelCase")]
568#[non_exhaustive]
569pub struct PlatformCapabilities {
570 pub platform: Platform,
571 pub lifecycle_mode: LifecycleMode,
572 pub survives_app_close: LifecycleGuarantee,
573 pub survives_reboot: LifecycleGuarantee,
574 pub survives_force_quit: LifecycleGuarantee,
575 pub background_execution: LifecycleGuarantee,
576 pub limitations: Vec<String>,
577 pub required_setup: Vec<String>,
578}
579
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
585#[serde(rename_all = "camelCase")]
586#[non_exhaustive]
587pub enum OsServiceInstallState {
588 NotInstalled,
590 Installed,
592 Running,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
601#[serde(rename_all = "camelCase")]
602#[non_exhaustive]
603pub struct OsServiceStatus {
604 pub label: String,
606 pub mode: String,
608 pub installed: OsServiceInstallState,
610 pub ipc_connected: bool,
612 #[serde(skip_serializing_if = "Option::is_none")]
614 pub socket_path: Option<String>,
615 #[serde(skip_serializing_if = "Option::is_none")]
617 pub last_error: Option<String>,
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
625#[serde(rename_all = "camelCase")]
626#[non_exhaustive]
627pub enum StopReason {
628 UserStop,
630 AppStop,
632 PlatformTimeout,
634 PlatformExpiration,
636 NativeNotificationStop,
638 OsRestart,
640 BootRecovery,
642 TaskCompleted,
644 Error,
646 ProcessExit,
650}
651
652impl<'de> serde::Deserialize<'de> for StopReason {
653 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
654 let s = String::deserialize(deserializer)?;
655 match s.as_str() {
656 "userStop" => Ok(Self::UserStop),
657 "appStop" => Ok(Self::AppStop),
658 "platformTimeout" => Ok(Self::PlatformTimeout),
659 "platformExpiration" => Ok(Self::PlatformExpiration),
660 "nativeNotificationStop" => Ok(Self::NativeNotificationStop),
661 "osRestart" => Ok(Self::OsRestart),
662 "bootRecovery" => Ok(Self::BootRecovery),
663 "taskCompleted" => Ok(Self::TaskCompleted),
664 "error" => Ok(Self::Error),
665 "processExit" => Ok(Self::ProcessExit),
666 "completed" => Ok(Self::TaskCompleted),
668 "cancelled" | "user" => Ok(Self::UserStop),
669 _ => Err(serde::de::Error::unknown_variant(
670 &s,
671 &[
672 "userStop",
673 "appStop",
674 "platformTimeout",
675 "platformExpiration",
676 "nativeNotificationStop",
677 "osRestart",
678 "bootRecovery",
679 "taskCompleted",
680 "error",
681 "processExit",
682 ],
683 )),
684 }
685 }
686}
687
688#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
694#[serde(rename_all = "camelCase", tag = "type")]
695#[non_exhaustive]
696pub enum NativeLifecycleEvent {
697 AndroidNotificationStop,
699 AndroidTimeout {
701 #[serde(skip_serializing_if = "Option::is_none")]
703 fgs_type: Option<String>,
704 },
705 AndroidOsRestartAccepted,
708 AndroidBootRecoveryAccepted,
711 IosBgTaskExpired,
716}
717
718impl NativeLifecycleEvent {
719 pub fn to_stop_reason(&self) -> StopReason {
721 match self {
722 Self::AndroidNotificationStop => StopReason::NativeNotificationStop,
723 Self::AndroidTimeout { .. } => StopReason::PlatformTimeout,
724 Self::AndroidOsRestartAccepted => StopReason::OsRestart,
725 Self::AndroidBootRecoveryAccepted => StopReason::BootRecovery,
726 Self::IosBgTaskExpired => StopReason::PlatformExpiration,
727 }
728 }
729
730 pub fn is_recovery_acceptance(&self) -> bool {
734 matches!(
735 self,
736 Self::AndroidOsRestartAccepted | Self::AndroidBootRecoveryAccepted
737 )
738 }
739}
740
741#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase", tag = "type")]
744#[non_exhaustive]
745pub enum PluginEvent {
746 Started,
748 Stopped { reason: StopReason },
750 Error { message: String },
752}
753
754impl Default for PluginConfig {
755 fn default() -> Self {
756 Self {
757 ios_safety_timeout_secs: default_ios_safety_timeout(),
758 ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
759 ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
760 ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
761 ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
762 ios_requires_external_power: false,
763 ios_requires_network_connectivity: false,
764 ios_processing_ceiling_multiplier: default_ios_processing_ceiling_multiplier(),
765 channel_capacity: default_channel_capacity(),
766 android_foreground_service_types: default_android_foreground_service_types(),
767 android_validate_foreground_service_type: default_true(),
768 android_on_timeout: default_android_on_timeout(),
769 android_notification_channel_id: default_android_notification_channel_id(),
770 android_notification_channel_name: default_android_notification_channel_name(),
771 android_notification_id: default_android_notification_id(),
772 android_notification_small_icon: None,
773 android_show_stop_action: default_true(),
774 android_request_notification_permission_on_load: false,
775 notify_on_timeout: false,
776 notify_on_recovery: false,
777 #[cfg(feature = "desktop-service")]
778 desktop_service_mode: default_desktop_service_mode(),
779 #[cfg(feature = "desktop-service")]
780 desktop_service_label: None,
781 #[cfg(feature = "desktop-service")]
782 desktop_service_autostart: false,
783 #[cfg(feature = "desktop-service")]
784 desktop_start_service_if_missing: false,
785 #[cfg(feature = "desktop-service")]
786 desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
787 #[cfg(feature = "desktop-service")]
788 desktop_windows_daemon_opt_in: false,
789 }
790 }
791}
792
793#[derive(Debug, Serialize)]
797#[serde(rename_all = "camelCase")]
798#[allow(dead_code)]
799pub(crate) struct StartKeepaliveArgs<'a> {
800 pub label: &'a str,
801 pub foreground_service_type: &'a str,
802 #[serde(skip_serializing_if = "Option::is_none")]
804 pub ios_safety_timeout_secs: Option<f64>,
805 #[serde(skip_serializing_if = "Option::is_none")]
808 pub ios_processing_safety_timeout_secs: Option<f64>,
809 #[serde(skip_serializing_if = "Option::is_none")]
811 pub ios_earliest_refresh_begin_minutes: Option<f64>,
812 #[serde(skip_serializing_if = "Option::is_none")]
814 pub ios_earliest_processing_begin_minutes: Option<f64>,
815 #[serde(skip_serializing_if = "Option::is_none")]
817 pub ios_requires_external_power: Option<bool>,
818 #[serde(skip_serializing_if = "Option::is_none")]
820 pub ios_requires_network_connectivity: Option<bool>,
821 #[serde(skip_serializing_if = "Option::is_none")]
823 pub ios_processing_ceiling_multiplier: Option<f64>,
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
833#[serde(rename_all = "camelCase")]
834#[non_exhaustive]
835pub struct PendingTaskInfo {
836 pub task_kind: String,
838 pub identifier: String,
840 pub received_at: f64,
842 #[serde(default)]
845 pub consumed_at: Option<f64>,
846}
847
848impl PendingTaskInfo {
849 pub fn from_pending_payload(
859 value: &serde_json::Value,
860 ) -> Result<Option<Self>, serde_json::Error> {
861 if value
862 .get("taskKind")
863 .map_or(true, serde_json::Value::is_null)
864 {
865 return Ok(None);
866 }
867 let info: Self = serde_json::from_value(value.clone())?;
868 if info.consumed_at.is_some() {
869 return Ok(None);
870 }
871 Ok(Some(info))
872 }
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
881#[serde(rename_all = "camelCase")]
882#[non_exhaustive]
883pub struct IOSSchedulingStatus {
884 pub refresh_scheduled: bool,
886 pub processing_scheduled: bool,
888 #[serde(default)]
890 #[serde(skip_serializing_if = "Option::is_none")]
891 pub refresh_error: Option<String>,
892 #[serde(default)]
894 #[serde(skip_serializing_if = "Option::is_none")]
895 pub processing_error: Option<String>,
896}
897
898#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
911#[serde(rename_all = "camelCase")]
912#[non_exhaustive]
913pub struct IOSDesiredStateStatus {
914 pub desired_running: bool,
916 #[serde(default)]
918 #[serde(skip_serializing_if = "Option::is_none")]
919 pub last_start_config: Option<String>,
920 #[serde(default)]
922 #[serde(skip_serializing_if = "Option::is_none")]
923 pub last_task_kind: Option<String>,
924 #[serde(default)]
926 #[serde(skip_serializing_if = "Option::is_none")]
927 pub last_task_started_at: Option<f64>,
928 #[serde(default)]
930 #[serde(skip_serializing_if = "Option::is_none")]
931 pub last_task_completed_at: Option<f64>,
932 #[serde(default)]
934 #[serde(skip_serializing_if = "Option::is_none")]
935 pub last_schedule_error: Option<String>,
936 #[serde(default)]
942 #[serde(skip_serializing_if = "Option::is_none")]
943 pub last_completion_reason: Option<String>,
944 #[serde(default)]
950 #[serde(skip_serializing_if = "Option::is_none")]
951 pub notification_granted: Option<bool>,
952}
953
954#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
961pub struct NotificationPermissionStatus {
962 pub status: String,
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
971#[serde(rename_all = "camelCase")]
972#[non_exhaustive]
973pub struct SetupIssue {
974 pub code: String,
976 pub message: String,
978 pub platform: Platform,
980 #[serde(skip_serializing_if = "Option::is_none")]
982 pub fix: Option<String>,
983}
984
985impl SetupIssue {
986 pub fn to_validation_issue(&self, severity: Severity) -> ValidationIssue {
988 ValidationIssue {
989 severity,
990 code: self.code.clone(),
991 message: self.message.clone(),
992 fix: self.fix.clone(),
993 platform: self.platform,
994 }
995 }
996}
997
998#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1004#[serde(rename_all = "camelCase")]
1005#[non_exhaustive]
1006pub struct SetupValidationReport {
1007 pub ok: bool,
1009 pub errors: Vec<SetupIssue>,
1011 pub warnings: Vec<SetupIssue>,
1013 #[serde(default)]
1019 pub issues: Vec<ValidationIssue>,
1020}
1021
1022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1027#[serde(rename_all = "camelCase")]
1028#[non_exhaustive]
1029pub struct ValidationIssue {
1030 pub severity: Severity,
1031 pub code: String,
1032 pub message: String,
1033 #[serde(skip_serializing_if = "Option::is_none")]
1034 pub fix: Option<String>,
1035 pub platform: Platform,
1036}
1037
1038#[derive(Debug, Clone, Serialize, Deserialize)]
1043#[serde(rename_all = "camelCase")]
1044#[non_exhaustive]
1045pub struct LifecycleStatus {
1046 pub state: LifecycleState,
1047 pub desired_running: bool,
1048 pub recovery_enabled: bool,
1049 pub recovery_pending: bool,
1050 #[serde(skip_serializing_if = "Option::is_none")]
1051 pub recovery_reason: Option<String>,
1052 #[serde(skip_serializing_if = "Option::is_none")]
1053 pub last_start_config: Option<StartConfig>,
1054 #[serde(skip_serializing_if = "Option::is_none")]
1055 pub last_platform_state: Option<String>,
1056 #[serde(skip_serializing_if = "Option::is_none")]
1057 pub last_platform_error: Option<String>,
1058 #[serde(skip_serializing_if = "Option::is_none")]
1059 pub last_error: Option<String>,
1060 pub platform: Platform,
1061 pub capabilities: PlatformCapabilities,
1062 pub issues: Vec<ValidationIssue>,
1063 #[serde(skip_serializing_if = "Option::is_none")]
1064 pub native_running: Option<bool>,
1065 #[serde(skip_serializing_if = "Option::is_none")]
1066 pub native_foreground: Option<bool>,
1067 #[serde(skip_serializing_if = "Option::is_none")]
1068 pub adopted: Option<bool>,
1069 #[serde(skip_serializing_if = "Option::is_none")]
1070 pub degraded: Option<bool>,
1071 #[serde(skip_serializing_if = "Option::is_none")]
1072 pub degraded_reason: Option<String>,
1073 #[serde(skip_serializing_if = "Option::is_none")]
1074 pub data_dir: Option<String>,
1075}
1076
1077#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1082#[serde(rename_all = "camelCase")]
1083#[non_exhaustive]
1084pub struct AndroidServiceState {
1085 pub native_running: bool,
1086 pub native_foreground: bool,
1087 pub desired_running: bool,
1088 pub durable_state: String,
1089 #[serde(skip_serializing_if = "Option::is_none")]
1090 pub service_label: Option<String>,
1091 #[serde(skip_serializing_if = "Option::is_none")]
1092 pub foreground_service_type: Option<String>,
1093 #[serde(skip_serializing_if = "Option::is_none")]
1094 pub notification_id: Option<i64>,
1095 #[serde(skip_serializing_if = "Option::is_none")]
1096 pub notification_channel_id: Option<String>,
1097 pub recovery_pending: bool,
1098 #[serde(skip_serializing_if = "Option::is_none")]
1099 pub recovery_reason: Option<String>,
1100 #[serde(skip_serializing_if = "Option::is_none")]
1101 pub last_platform_error: Option<String>,
1102 pub data_dir: String,
1103}
1104
1105#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1117#[serde(rename_all = "camelCase")]
1118#[non_exhaustive]
1119pub struct IosNativeState {
1120 pub desired_running: bool,
1123 #[serde(default)]
1126 pub refresh_scheduled: bool,
1127 #[serde(default)]
1130 pub processing_scheduled: bool,
1131 #[serde(default)]
1135 #[serde(skip_serializing_if = "Option::is_none")]
1136 pub active_task_kind: Option<String>,
1137 #[serde(default)]
1140 #[serde(skip_serializing_if = "Option::is_none")]
1141 pub pending_task: Option<PendingTaskInfo>,
1142 #[serde(default)]
1145 #[serde(skip_serializing_if = "Option::is_none")]
1146 pub last_completed_at: Option<f64>,
1147 #[serde(default)]
1152 #[serde(skip_serializing_if = "Option::is_none")]
1153 pub last_completion_reason: Option<String>,
1154 #[serde(default)]
1158 #[serde(skip_serializing_if = "Option::is_none")]
1159 pub last_refresh_error: Option<String>,
1160 #[serde(default)]
1163 #[serde(skip_serializing_if = "Option::is_none")]
1164 pub last_processing_error: Option<String>,
1165 pub in_budget: bool,
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172 use super::*;
1173
1174 #[test]
1177 fn default_foreground_service_type_is_remote_messaging() {
1178 assert_eq!(default_foreground_service_type(), "remoteMessaging");
1179 }
1180
1181 #[test]
1182 fn default_android_foreground_service_types_is_remote_messaging() {
1183 assert_eq!(
1184 default_android_foreground_service_types(),
1185 vec!["remoteMessaging"]
1186 );
1187 }
1188
1189 #[test]
1190 fn start_config_default_uses_remote_messaging() {
1191 let config = StartConfig::default();
1192 assert_eq!(config.foreground_service_type, "remoteMessaging");
1193 }
1194
1195 #[test]
1198 fn start_config_default_label() {
1199 let config = StartConfig::default();
1200 assert_eq!(config.service_label, "Service running");
1201 }
1202
1203 #[test]
1204 fn start_config_custom_label() {
1205 let config = StartConfig {
1206 service_label: "Syncing data".into(),
1207 ..Default::default()
1208 };
1209 assert_eq!(config.service_label, "Syncing data");
1210 }
1211
1212 #[test]
1213 fn start_config_serde_roundtrip_default() {
1214 let config = StartConfig::default();
1215 let json = serde_json::to_string(&config).unwrap();
1216 let de: StartConfig = serde_json::from_str(&json).unwrap();
1217 assert_eq!(de.service_label, config.service_label);
1218 }
1219
1220 #[test]
1221 fn start_config_serde_roundtrip_custom() {
1222 let config = StartConfig {
1223 service_label: "My service".into(),
1224 ..Default::default()
1225 };
1226 let json = serde_json::to_string(&config).unwrap();
1227 let de: StartConfig = serde_json::from_str(&json).unwrap();
1228 assert_eq!(de.service_label, "My service");
1229 }
1230
1231 #[test]
1232 fn start_config_deserialize_missing_field_uses_default() {
1233 let json = "{}";
1235 let de: StartConfig = serde_json::from_str(json).unwrap();
1236 assert_eq!(de.service_label, "Service running");
1237 }
1238
1239 #[test]
1240 fn start_config_json_key_is_camel_case() {
1241 let config = StartConfig {
1242 service_label: "test".into(),
1243 ..Default::default()
1244 };
1245 let json = serde_json::to_string(&config).unwrap();
1246 assert!(
1247 json.contains("serviceLabel"),
1248 "JSON should use camelCase: {json}"
1249 );
1250 }
1251
1252 #[test]
1255 fn start_config_legacy_label_alias_decodes() {
1256 let json = r#"{"label":"Legacy name"}"#;
1257 let de: StartConfig = serde_json::from_str(json).unwrap();
1258 assert_eq!(de.service_label, "Legacy name");
1259 }
1260
1261 #[test]
1262 fn start_config_both_label_and_service_label_rejected() {
1263 let json = r#"{"serviceLabel":"New name","label":"Old name"}"#;
1264 let result = serde_json::from_str::<StartConfig>(json);
1265 assert!(result.is_err(), "should reject duplicate field via alias");
1266 }
1267
1268 #[test]
1269 fn start_config_unknown_fields_ignored() {
1270 let json = r#"{"serviceLabel":"test","unknownField":42,"extra":"data"}"#;
1271 let de: StartConfig = serde_json::from_str(json).unwrap();
1272 assert_eq!(de.service_label, "test");
1273 assert_eq!(de.foreground_service_type, "remoteMessaging");
1274 }
1275
1276 #[test]
1277 fn start_config_camel_case_key_still_works() {
1278 let json = r#"{"serviceLabel":"Modern name"}"#;
1279 let de: StartConfig = serde_json::from_str(json).unwrap();
1280 assert_eq!(de.service_label, "Modern name");
1281 }
1282
1283 #[test]
1286 fn plugin_event_started_serde_roundtrip() {
1287 let event = PluginEvent::Started;
1288 let json = serde_json::to_string(&event).unwrap();
1289 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1290 assert!(matches!(de, PluginEvent::Started));
1291 }
1292
1293 #[test]
1294 fn plugin_event_stopped_serde_roundtrip() {
1295 let event = PluginEvent::Stopped {
1296 reason: StopReason::UserStop,
1297 };
1298 let json = serde_json::to_string(&event).unwrap();
1299 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1300 match de {
1301 PluginEvent::Stopped { reason } => assert_eq!(reason, StopReason::UserStop),
1302 other => panic!("Expected Stopped, got {other:?}"),
1303 }
1304 }
1305
1306 #[test]
1307 fn plugin_event_error_serde_roundtrip() {
1308 let event = PluginEvent::Error {
1309 message: "init failed".into(),
1310 };
1311 let json = serde_json::to_string(&event).unwrap();
1312 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1313 match de {
1314 PluginEvent::Error { message } => assert_eq!(message, "init failed"),
1315 other => panic!("Expected Error, got {other:?}"),
1316 }
1317 }
1318
1319 #[test]
1320 fn plugin_event_tagged_json_format() {
1321 let event = PluginEvent::Started;
1322 let json = serde_json::to_string(&event).unwrap();
1323 assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
1324 }
1325
1326 #[test]
1327 fn plugin_event_stopped_json_keys_camel_case() {
1328 let event = PluginEvent::Stopped {
1329 reason: StopReason::TaskCompleted,
1330 };
1331 let json = serde_json::to_string(&event).unwrap();
1332 assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
1333 assert!(
1334 json.contains("\"reason\":\"taskCompleted\""),
1335 "Reason: {json}"
1336 );
1337 }
1338
1339 #[test]
1340 fn plugin_event_error_json_keys_camel_case() {
1341 let event = PluginEvent::Error {
1342 message: "oops".into(),
1343 };
1344 let json = serde_json::to_string(&event).unwrap();
1345 assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
1346 assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
1347 }
1348
1349 #[test]
1352 fn stop_reason_all_variants_serialize_to_camel_case() {
1353 assert_eq!(
1354 serde_json::to_string(&StopReason::UserStop).unwrap(),
1355 "\"userStop\""
1356 );
1357 assert_eq!(
1358 serde_json::to_string(&StopReason::AppStop).unwrap(),
1359 "\"appStop\""
1360 );
1361 assert_eq!(
1362 serde_json::to_string(&StopReason::PlatformTimeout).unwrap(),
1363 "\"platformTimeout\""
1364 );
1365 assert_eq!(
1366 serde_json::to_string(&StopReason::PlatformExpiration).unwrap(),
1367 "\"platformExpiration\""
1368 );
1369 assert_eq!(
1370 serde_json::to_string(&StopReason::NativeNotificationStop).unwrap(),
1371 "\"nativeNotificationStop\""
1372 );
1373 assert_eq!(
1374 serde_json::to_string(&StopReason::OsRestart).unwrap(),
1375 "\"osRestart\""
1376 );
1377 assert_eq!(
1378 serde_json::to_string(&StopReason::BootRecovery).unwrap(),
1379 "\"bootRecovery\""
1380 );
1381 assert_eq!(
1382 serde_json::to_string(&StopReason::TaskCompleted).unwrap(),
1383 "\"taskCompleted\""
1384 );
1385 assert_eq!(
1386 serde_json::to_string(&StopReason::Error).unwrap(),
1387 "\"error\""
1388 );
1389 assert_eq!(
1390 serde_json::to_string(&StopReason::ProcessExit).unwrap(),
1391 "\"processExit\""
1392 );
1393 }
1394
1395 #[test]
1396 fn stop_reason_roundtrip_all_variants() {
1397 for variant in [
1398 StopReason::UserStop,
1399 StopReason::AppStop,
1400 StopReason::PlatformTimeout,
1401 StopReason::PlatformExpiration,
1402 StopReason::NativeNotificationStop,
1403 StopReason::OsRestart,
1404 StopReason::BootRecovery,
1405 StopReason::TaskCompleted,
1406 StopReason::Error,
1407 StopReason::ProcessExit,
1408 ] {
1409 let json = serde_json::to_string(&variant).unwrap();
1410 let de: StopReason = serde_json::from_str(&json).unwrap();
1411 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
1412 }
1413 }
1414
1415 #[test]
1416 fn stop_reason_process_exit_deserializes_from_camel_case() {
1417 let de: StopReason = serde_json::from_str("\"processExit\"").unwrap();
1419 assert_eq!(de, StopReason::ProcessExit);
1420 }
1421
1422 #[test]
1423 fn stop_reason_legacy_completed_maps_to_task_completed() {
1424 let json = "\"completed\"";
1425 let de: StopReason = serde_json::from_str(json).unwrap();
1426 assert_eq!(de, StopReason::TaskCompleted);
1427 }
1428
1429 #[test]
1430 fn stop_reason_legacy_cancelled_maps_to_user_stop() {
1431 let json = "\"cancelled\"";
1432 let de: StopReason = serde_json::from_str(json).unwrap();
1433 assert_eq!(de, StopReason::UserStop);
1434 }
1435
1436 #[test]
1437 fn stop_reason_legacy_user_maps_to_user_stop() {
1438 let json = "\"user\"";
1439 let de: StopReason = serde_json::from_str(json).unwrap();
1440 assert_eq!(de, StopReason::UserStop);
1441 }
1442
1443 #[test]
1444 fn stop_reason_unknown_variant_returns_error() {
1445 let json = "\"unknownReason\"";
1446 let result = serde_json::from_str::<StopReason>(json);
1447 assert!(
1448 result.is_err(),
1449 "unknown variant should fail to deserialize"
1450 );
1451 }
1452
1453 #[test]
1456 fn native_lifecycle_event_android_notification_stop_roundtrip() {
1457 let event = NativeLifecycleEvent::AndroidNotificationStop;
1458 let json = serde_json::to_string(&event).unwrap();
1459 assert_eq!(json, r#"{"type":"androidNotificationStop"}"#);
1460 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1461 assert_eq!(de, event);
1462 }
1463
1464 #[test]
1465 fn native_lifecycle_event_android_timeout_roundtrip() {
1466 let event = NativeLifecycleEvent::AndroidTimeout {
1467 fgs_type: Some("dataSync".into()),
1468 };
1469 let json = serde_json::to_string(&event).unwrap();
1470 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1471 assert_eq!(de, event);
1472 }
1473
1474 #[test]
1475 fn native_lifecycle_event_android_timeout_without_fgs_type() {
1476 let event = NativeLifecycleEvent::AndroidTimeout { fgs_type: None };
1477 let json = serde_json::to_string(&event).unwrap();
1478 assert!(!json.contains("fgsType"), "{json}");
1480 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1481 assert_eq!(de, event);
1482 }
1483
1484 #[test]
1485 fn native_lifecycle_event_to_stop_reason_mapping() {
1486 assert_eq!(
1487 NativeLifecycleEvent::AndroidNotificationStop.to_stop_reason(),
1488 StopReason::NativeNotificationStop
1489 );
1490 assert_eq!(
1491 NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.to_stop_reason(),
1492 StopReason::PlatformTimeout
1493 );
1494 assert_eq!(
1495 NativeLifecycleEvent::AndroidTimeout {
1496 fgs_type: Some("dataSync".into())
1497 }
1498 .to_stop_reason(),
1499 StopReason::PlatformTimeout
1500 );
1501 }
1502
1503 #[test]
1504 fn native_lifecycle_event_recovery_acceptance_roundtrips() {
1505 let event = NativeLifecycleEvent::AndroidOsRestartAccepted;
1506 let json = serde_json::to_string(&event).unwrap();
1507 assert_eq!(json, r#"{"type":"androidOsRestartAccepted"}"#);
1508 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1509 assert_eq!(de, event);
1510
1511 let event = NativeLifecycleEvent::AndroidBootRecoveryAccepted;
1512 let json = serde_json::to_string(&event).unwrap();
1513 assert_eq!(json, r#"{"type":"androidBootRecoveryAccepted"}"#);
1514 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1515 assert_eq!(de, event);
1516 }
1517
1518 #[test]
1519 fn native_lifecycle_event_recovery_acceptance_stop_reasons() {
1520 assert_eq!(
1521 NativeLifecycleEvent::AndroidOsRestartAccepted.to_stop_reason(),
1522 StopReason::OsRestart
1523 );
1524 assert_eq!(
1525 NativeLifecycleEvent::AndroidBootRecoveryAccepted.to_stop_reason(),
1526 StopReason::BootRecovery
1527 );
1528 }
1529
1530 #[test]
1531 fn native_lifecycle_event_recovery_acceptance_classification() {
1532 assert!(NativeLifecycleEvent::AndroidOsRestartAccepted.is_recovery_acceptance());
1533 assert!(NativeLifecycleEvent::AndroidBootRecoveryAccepted.is_recovery_acceptance());
1534 assert!(!NativeLifecycleEvent::AndroidNotificationStop.is_recovery_acceptance());
1535 assert!(!NativeLifecycleEvent::AndroidTimeout { fgs_type: None }.is_recovery_acceptance());
1536 }
1537
1538 #[test]
1542 fn native_lifecycle_event_ios_bg_task_expired() {
1543 let event = NativeLifecycleEvent::IosBgTaskExpired;
1544 assert_eq!(event.to_stop_reason(), StopReason::PlatformExpiration);
1545 assert!(!event.is_recovery_acceptance());
1546
1547 let json = serde_json::to_string(&event).unwrap();
1548 assert_eq!(json, r#"{"type":"iosBgTaskExpired"}"#);
1549 let de: NativeLifecycleEvent = serde_json::from_str(&json).unwrap();
1550 assert_eq!(de, event);
1551 }
1552
1553 #[test]
1556 fn ios_native_state_serde_roundtrip() {
1557 let state = IosNativeState {
1558 desired_running: true,
1559 refresh_scheduled: true,
1560 processing_scheduled: false,
1561 active_task_kind: Some("refresh".into()),
1562 pending_task: Some(PendingTaskInfo {
1563 task_kind: "processing".into(),
1564 identifier: "com.example.app.bg-processing".into(),
1565 received_at: 1000.0,
1566 consumed_at: None,
1567 }),
1568 last_completed_at: Some(900.0),
1569 last_completion_reason: Some("completed".into()),
1570 last_refresh_error: Some("BGTaskSchedulerErrorDomain code 1".into()),
1571 last_processing_error: None,
1572 in_budget: false,
1573 };
1574 let json = serde_json::to_string(&state).unwrap();
1575 assert!(json.contains("\"desiredRunning\":true"));
1576 assert!(json.contains("\"refreshScheduled\":true"));
1577 assert!(json.contains("\"activeTaskKind\":\"refresh\""));
1578 assert!(json.contains("\"inBudget\":false"));
1579 let de: IosNativeState = serde_json::from_str(&json).unwrap();
1580 assert_eq!(de, state);
1581 }
1582
1583 #[test]
1585 fn ios_native_state_defaults_optional_fields() {
1586 let json = r#"{"desiredRunning":false,"inBudget":true}"#;
1587 let de: IosNativeState = serde_json::from_str(json).unwrap();
1588 assert!(!de.desired_running);
1589 assert!(de.in_budget);
1590 assert!(!de.refresh_scheduled);
1591 assert!(!de.processing_scheduled);
1592 assert_eq!(de.active_task_kind, None);
1593 assert_eq!(de.pending_task, None);
1594 assert_eq!(de.last_completed_at, None);
1595 assert_eq!(de.last_completion_reason, None);
1596 assert_eq!(de.last_refresh_error, None);
1597 assert_eq!(de.last_processing_error, None);
1598 }
1599
1600 #[test]
1604 fn ios_native_state_answers_seven_questions() {
1605 let state = IosNativeState {
1606 desired_running: true, refresh_scheduled: true, processing_scheduled: true, active_task_kind: Some("refresh".into()), pending_task: Some(PendingTaskInfo {
1611 task_kind: "refresh".into(),
1613 identifier: "com.example.app.bg-refresh".into(),
1614 received_at: 1000.0,
1615 consumed_at: None,
1616 }),
1617 last_completed_at: Some(950.0), last_completion_reason: Some("completed".into()), last_refresh_error: Some("refresh boom".into()), last_processing_error: Some("processing boom".into()), in_budget: false,
1622 };
1623
1624 assert!(state.desired_running);
1626 assert!(state.refresh_scheduled && state.processing_scheduled);
1627 assert!(state.pending_task.is_some());
1628 assert_eq!(state.active_task_kind.as_deref(), Some("refresh"));
1630 assert_eq!(state.last_completed_at, Some(950.0));
1631 assert_eq!(state.last_completion_reason.as_deref(), Some("completed"));
1632 assert_eq!(state.last_refresh_error.as_deref(), Some("refresh boom"));
1634 assert_eq!(
1635 state.last_processing_error.as_deref(),
1636 Some("processing boom")
1637 );
1638 }
1639
1640 #[test]
1644 fn ios_native_state_splits_schedule_errors() {
1645 let state = IosNativeState {
1646 desired_running: true,
1647 refresh_scheduled: false,
1648 processing_scheduled: true,
1649 active_task_kind: None,
1650 pending_task: None,
1651 last_completed_at: None,
1652 last_completion_reason: None,
1653 last_refresh_error: Some("only refresh failed".into()),
1654 last_processing_error: None,
1655 in_budget: true,
1656 };
1657 let json = serde_json::to_string(&state).unwrap();
1658 assert!(
1659 json.contains("\"lastRefreshError\":\"only refresh failed\""),
1660 "{json}"
1661 );
1662 assert!(!json.contains("lastProcessingError"), "{json}");
1665 let de: IosNativeState = serde_json::from_str(&json).unwrap();
1666 assert_eq!(de, state);
1667 assert_eq!(
1668 de.last_refresh_error.as_deref(),
1669 Some("only refresh failed")
1670 );
1671 assert_eq!(de.last_processing_error, None);
1672 }
1673
1674 #[test]
1677 fn ios_native_state_carries_last_completion_reason() {
1678 let state = IosNativeState {
1679 desired_running: true,
1680 refresh_scheduled: true,
1681 processing_scheduled: true,
1682 active_task_kind: None,
1683 pending_task: None,
1684 last_completed_at: Some(900.0),
1685 last_completion_reason: Some("expired".into()),
1686 last_refresh_error: None,
1687 last_processing_error: None,
1688 in_budget: true,
1689 };
1690 let json = serde_json::to_string(&state).unwrap();
1691 assert!(
1692 json.contains("\"lastCompletionReason\":\"expired\""),
1693 "{json}"
1694 );
1695 let de: IosNativeState = serde_json::from_str(&json).unwrap();
1696 assert_eq!(de.last_completion_reason.as_deref(), Some("expired"));
1697 }
1698
1699 #[test]
1700 fn plugin_event_stopped_with_stop_reason_roundtrip() {
1701 let event = PluginEvent::Stopped {
1702 reason: StopReason::TaskCompleted,
1703 };
1704 let json = serde_json::to_string(&event).unwrap();
1705 let de: PluginEvent = serde_json::from_str(&json).unwrap();
1706 assert_eq!(
1707 de,
1708 PluginEvent::Stopped {
1709 reason: StopReason::TaskCompleted
1710 }
1711 );
1712 }
1713
1714 #[test]
1715 fn plugin_event_stopped_legacy_reason_deserializes() {
1716 let json = r#"{"type":"stopped","reason":"completed"}"#;
1718 let de: PluginEvent = serde_json::from_str(json).unwrap();
1719 match de {
1720 PluginEvent::Stopped { reason } => {
1721 assert_eq!(reason, StopReason::TaskCompleted);
1722 }
1723 other => panic!("Expected Stopped, got {other:?}"),
1724 }
1725 }
1726
1727 #[test]
1728 fn plugin_event_stopped_legacy_cancelled_deserializes() {
1729 let json = r#"{"type":"stopped","reason":"cancelled"}"#;
1730 let de: PluginEvent = serde_json::from_str(json).unwrap();
1731 match de {
1732 PluginEvent::Stopped { reason } => {
1733 assert_eq!(reason, StopReason::UserStop);
1734 }
1735 other => panic!("Expected Stopped, got {other:?}"),
1736 }
1737 }
1738
1739 #[test]
1742 fn start_config_default_service_type() {
1743 let config = StartConfig::default();
1744 assert_eq!(config.foreground_service_type, "remoteMessaging");
1745 }
1746
1747 #[test]
1748 fn start_config_custom_service_type() {
1749 let config = StartConfig {
1750 service_label: "test".into(),
1751 foreground_service_type: "specialUse".into(),
1752 };
1753 assert_eq!(config.foreground_service_type, "specialUse");
1754 }
1755
1756 #[test]
1757 fn start_config_serde_roundtrip_service_type() {
1758 let config = StartConfig {
1759 service_label: "test".into(),
1760 foreground_service_type: "specialUse".into(),
1761 };
1762 let json = serde_json::to_string(&config).unwrap();
1763 let de: StartConfig = serde_json::from_str(&json).unwrap();
1764 assert_eq!(de.foreground_service_type, "specialUse");
1765 }
1766
1767 #[test]
1768 fn start_config_deserialize_missing_service_type() {
1769 let json = r#"{"serviceLabel":"test"}"#;
1770 let de: StartConfig = serde_json::from_str(json).unwrap();
1771 assert_eq!(de.foreground_service_type, "remoteMessaging");
1772 }
1773
1774 #[test]
1775 fn start_config_deserialize_special_use() {
1776 let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
1777 let de: StartConfig = serde_json::from_str(json).unwrap();
1778 assert_eq!(de.foreground_service_type, "specialUse");
1779 }
1780
1781 #[test]
1782 fn start_config_unrecognized_type_rejected_by_validation() {
1783 let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
1785 let de: StartConfig = serde_json::from_str(json).unwrap();
1786 assert_eq!(de.foreground_service_type, "customType");
1787 let result = validate_foreground_service_type(&de.foreground_service_type);
1789 assert!(
1790 result.is_err(),
1791 "validation should reject unrecognized type"
1792 );
1793 let err_msg = result.unwrap_err().to_string();
1794 assert!(
1795 err_msg.contains("customType"),
1796 "error should mention the invalid type: {err_msg}"
1797 );
1798 }
1799
1800 #[test]
1801 fn start_config_json_key_is_camel_case_service_type() {
1802 let config = StartConfig {
1803 service_label: "test".into(),
1804 foreground_service_type: "specialUse".into(),
1805 };
1806 let json = serde_json::to_string(&config).unwrap();
1807 assert!(
1808 json.contains("foregroundServiceType"),
1809 "JSON should use camelCase: {json}"
1810 );
1811 }
1812
1813 #[test]
1816 fn plugin_config_default_ios_safety_timeout() {
1817 let json = "{}";
1818 let config: PluginConfig = serde_json::from_str(json).unwrap();
1819 assert_eq!(config.ios_safety_timeout_secs, 28.0);
1820 }
1821
1822 #[test]
1823 fn plugin_config_custom_ios_safety_timeout() {
1824 let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
1825 let config: PluginConfig = serde_json::from_str(json).unwrap();
1826 assert_eq!(config.ios_safety_timeout_secs, 15.0);
1827 }
1828
1829 #[test]
1830 fn plugin_config_serde_roundtrip_preserves_value() {
1831 let config = PluginConfig {
1832 ios_safety_timeout_secs: 30.0,
1833 ios_cancel_listener_timeout_secs: 14400,
1834 ios_processing_safety_timeout_secs: 0.0,
1835 ios_earliest_refresh_begin_minutes: 20.0,
1836 ios_earliest_processing_begin_minutes: 30.0,
1837 ios_requires_external_power: true,
1838 ios_requires_network_connectivity: true,
1839 ..Default::default()
1840 };
1841 let json = serde_json::to_string(&config).unwrap();
1842 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1843 assert_eq!(de.ios_safety_timeout_secs, 30.0);
1844 assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
1845 assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
1846 assert!(de.ios_requires_external_power);
1847 assert!(de.ios_requires_network_connectivity);
1848 }
1849
1850 #[test]
1851 fn plugin_config_default_impl() {
1852 let config = PluginConfig::default();
1853 assert_eq!(config.ios_safety_timeout_secs, 28.0);
1854 assert_eq!(config.channel_capacity, 16);
1855 }
1856
1857 #[test]
1858 fn plugin_config_default_cancel_timeout() {
1859 let json = "{}";
1860 let config: PluginConfig = serde_json::from_str(json).unwrap();
1861 assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
1862 }
1863
1864 #[test]
1865 fn plugin_config_custom_cancel_timeout() {
1866 let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
1867 let config: PluginConfig = serde_json::from_str(json).unwrap();
1868 assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
1869 }
1870
1871 #[test]
1872 fn plugin_config_cancel_timeout_serde_roundtrip() {
1873 let config = PluginConfig {
1874 ios_cancel_listener_timeout_secs: 3600,
1875 ..Default::default()
1876 };
1877 let json = serde_json::to_string(&config).unwrap();
1878 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1879 assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
1880 }
1881
1882 #[test]
1885 fn plugin_config_processing_timeout_default() {
1886 let json = "{}";
1887 let config: PluginConfig = serde_json::from_str(json).unwrap();
1888 assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
1889 }
1890
1891 #[test]
1892 fn plugin_config_processing_timeout_custom() {
1893 let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
1894 let config: PluginConfig = serde_json::from_str(json).unwrap();
1895 assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
1896 }
1897
1898 #[test]
1899 fn plugin_config_processing_timeout_serde_roundtrip() {
1900 let config = PluginConfig {
1901 ios_processing_safety_timeout_secs: 120.0,
1902 ..Default::default()
1903 };
1904 let json = serde_json::to_string(&config).unwrap();
1905 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1906 assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
1907 }
1908
1909 #[test]
1912 fn start_keepalive_args_with_timeout() {
1913 let args = StartKeepaliveArgs {
1914 label: "Test",
1915 foreground_service_type: "dataSync",
1916 ios_safety_timeout_secs: Some(15.0),
1917 ios_processing_safety_timeout_secs: None,
1918 ios_earliest_refresh_begin_minutes: None,
1919 ios_earliest_processing_begin_minutes: None,
1920 ios_requires_external_power: None,
1921 ios_requires_network_connectivity: None,
1922 ios_processing_ceiling_multiplier: None,
1923 };
1924 let json = serde_json::to_string(&args).unwrap();
1925 assert!(
1926 json.contains("\"iosSafetyTimeoutSecs\":15.0"),
1927 "JSON should contain iosSafetyTimeoutSecs: {json}"
1928 );
1929 }
1930
1931 #[test]
1932 fn start_keepalive_args_without_timeout() {
1933 let args = StartKeepaliveArgs {
1934 label: "Test",
1935 foreground_service_type: "dataSync",
1936 ios_safety_timeout_secs: None,
1937 ios_processing_safety_timeout_secs: None,
1938 ios_earliest_refresh_begin_minutes: None,
1939 ios_earliest_processing_begin_minutes: None,
1940 ios_requires_external_power: None,
1941 ios_requires_network_connectivity: None,
1942 ios_processing_ceiling_multiplier: None,
1943 };
1944 let json = serde_json::to_string(&args).unwrap();
1945 assert!(
1946 !json.contains("iosSafetyTimeoutSecs"),
1947 "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
1948 );
1949 }
1950
1951 #[test]
1952 fn start_keepalive_args_processing_timeout() {
1953 let args = StartKeepaliveArgs {
1954 label: "Test",
1955 foreground_service_type: "dataSync",
1956 ios_safety_timeout_secs: None,
1957 ios_processing_safety_timeout_secs: Some(60.0),
1958 ios_earliest_refresh_begin_minutes: None,
1959 ios_earliest_processing_begin_minutes: None,
1960 ios_requires_external_power: None,
1961 ios_requires_network_connectivity: None,
1962 ios_processing_ceiling_multiplier: None,
1963 };
1964 let json = serde_json::to_string(&args).unwrap();
1965 assert!(
1966 json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
1967 "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
1968 );
1969 }
1970
1971 #[test]
1972 fn start_keepalive_args_no_processing_timeout() {
1973 let args = StartKeepaliveArgs {
1974 label: "Test",
1975 foreground_service_type: "dataSync",
1976 ios_safety_timeout_secs: None,
1977 ios_processing_safety_timeout_secs: None,
1978 ios_earliest_refresh_begin_minutes: None,
1979 ios_earliest_processing_begin_minutes: None,
1980 ios_requires_external_power: None,
1981 ios_requires_network_connectivity: None,
1982 ios_processing_ceiling_multiplier: None,
1983 };
1984 let json = serde_json::to_string(&args).unwrap();
1985 assert!(
1986 !json.contains("iosProcessingSafetyTimeoutSecs"),
1987 "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
1988 );
1989 }
1990
1991 #[test]
1992 fn start_keepalive_args_camel_case_keys() {
1993 let args = StartKeepaliveArgs {
1994 label: "Test",
1995 foreground_service_type: "specialUse",
1996 ios_safety_timeout_secs: None,
1997 ios_processing_safety_timeout_secs: None,
1998 ios_earliest_refresh_begin_minutes: None,
1999 ios_earliest_processing_begin_minutes: None,
2000 ios_requires_external_power: None,
2001 ios_requires_network_connectivity: None,
2002 ios_processing_ceiling_multiplier: None,
2003 };
2004 let json = serde_json::to_string(&args).unwrap();
2005 assert!(json.contains("\"label\""), "label: {json}");
2006 assert!(
2007 json.contains("\"foregroundServiceType\""),
2008 "foregroundServiceType: {json}"
2009 );
2010 }
2011
2012 #[test]
2013 fn start_keepalive_args_scheduling_intervals() {
2014 let args = StartKeepaliveArgs {
2015 label: "Test",
2016 foreground_service_type: "dataSync",
2017 ios_safety_timeout_secs: None,
2018 ios_processing_safety_timeout_secs: None,
2019 ios_earliest_refresh_begin_minutes: Some(30.0),
2020 ios_earliest_processing_begin_minutes: Some(60.0),
2021 ios_requires_external_power: None,
2022 ios_requires_network_connectivity: None,
2023 ios_processing_ceiling_multiplier: None,
2024 };
2025 let json = serde_json::to_string(&args).unwrap();
2026 assert!(
2027 json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
2028 "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
2029 );
2030 assert!(
2031 json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
2032 "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
2033 );
2034 }
2035
2036 #[test]
2037 fn start_keepalive_args_processing_options() {
2038 let args = StartKeepaliveArgs {
2039 label: "Test",
2040 foreground_service_type: "dataSync",
2041 ios_safety_timeout_secs: None,
2042 ios_processing_safety_timeout_secs: None,
2043 ios_earliest_refresh_begin_minutes: None,
2044 ios_earliest_processing_begin_minutes: None,
2045 ios_requires_external_power: Some(true),
2046 ios_requires_network_connectivity: Some(true),
2047 ios_processing_ceiling_multiplier: None,
2048 };
2049 let json = serde_json::to_string(&args).unwrap();
2050 assert!(
2051 json.contains("\"iosRequiresExternalPower\":true"),
2052 "JSON should contain iosRequiresExternalPower: {json}"
2053 );
2054 assert!(
2055 json.contains("\"iosRequiresNetworkConnectivity\":true"),
2056 "JSON should contain iosRequiresNetworkConnectivity: {json}"
2057 );
2058 }
2059
2060 #[test]
2061 fn start_keepalive_args_processing_ceiling_multiplier() {
2062 let args = StartKeepaliveArgs {
2063 label: "Test",
2064 foreground_service_type: "dataSync",
2065 ios_safety_timeout_secs: None,
2066 ios_processing_safety_timeout_secs: None,
2067 ios_earliest_refresh_begin_minutes: None,
2068 ios_earliest_processing_begin_minutes: None,
2069 ios_requires_external_power: None,
2070 ios_requires_network_connectivity: None,
2071 ios_processing_ceiling_multiplier: Some(4.0),
2072 };
2073 let json = serde_json::to_string(&args).unwrap();
2074 assert!(
2075 json.contains("\"iosProcessingCeilingMultiplier\":4.0"),
2076 "JSON should contain iosProcessingCeilingMultiplier: {json}"
2077 );
2078 }
2079
2080 #[test]
2083 fn plugin_config_earliest_refresh_default() {
2084 let json = "{}";
2085 let config: PluginConfig = serde_json::from_str(json).unwrap();
2086 assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
2087 }
2088
2089 #[test]
2090 fn plugin_config_earliest_processing_default() {
2091 let json = "{}";
2092 let config: PluginConfig = serde_json::from_str(json).unwrap();
2093 assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
2094 }
2095
2096 #[test]
2097 fn plugin_config_requires_external_power_default() {
2098 let json = "{}";
2099 let config: PluginConfig = serde_json::from_str(json).unwrap();
2100 assert!(!config.ios_requires_external_power);
2101 }
2102
2103 #[test]
2104 fn plugin_config_requires_network_connectivity_default() {
2105 let json = "{}";
2106 let config: PluginConfig = serde_json::from_str(json).unwrap();
2107 assert!(!config.ios_requires_network_connectivity);
2108 }
2109
2110 #[test]
2111 fn plugin_config_custom_scheduling_intervals() {
2112 let json =
2113 r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
2114 let config: PluginConfig = serde_json::from_str(json).unwrap();
2115 assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
2116 assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
2117 }
2118
2119 #[test]
2120 fn plugin_config_custom_processing_options() {
2121 let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
2122 let config: PluginConfig = serde_json::from_str(json).unwrap();
2123 assert!(config.ios_requires_external_power);
2124 assert!(config.ios_requires_network_connectivity);
2125 }
2126
2127 #[test]
2130 fn plugin_config_processing_ceiling_multiplier_default() {
2131 let json = "{}";
2132 let config: PluginConfig = serde_json::from_str(json).unwrap();
2133 assert_eq!(config.ios_processing_ceiling_multiplier, 4.0);
2134 }
2135
2136 #[test]
2137 fn plugin_config_processing_ceiling_multiplier_custom() {
2138 let json = r#"{"iosProcessingCeilingMultiplier":6.0}"#;
2139 let config: PluginConfig = serde_json::from_str(json).unwrap();
2140 assert_eq!(config.ios_processing_ceiling_multiplier, 6.0);
2141 }
2142
2143 #[test]
2144 fn plugin_config_processing_ceiling_multiplier_serde_roundtrip() {
2145 let config = PluginConfig {
2146 ios_processing_ceiling_multiplier: 6.0,
2147 ..Default::default()
2148 };
2149 let json = serde_json::to_string(&config).unwrap();
2150 assert!(
2151 json.contains("\"iosProcessingCeilingMultiplier\":6.0"),
2152 "JSON should contain iosProcessingCeilingMultiplier: {json}"
2153 );
2154 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2155 assert_eq!(de.ios_processing_ceiling_multiplier, 6.0);
2156 }
2157
2158 #[test]
2161 fn plugin_config_channel_capacity_default() {
2162 let json = "{}";
2163 let config: PluginConfig = serde_json::from_str(json).unwrap();
2164 assert_eq!(config.channel_capacity, 16);
2165 }
2166
2167 #[test]
2168 fn plugin_config_channel_capacity_custom() {
2169 let json = r#"{"channelCapacity":32}"#;
2170 let config: PluginConfig = serde_json::from_str(json).unwrap();
2171 assert_eq!(config.channel_capacity, 32);
2172 }
2173
2174 #[test]
2175 fn plugin_config_channel_capacity_serde_roundtrip() {
2176 let config = PluginConfig {
2177 channel_capacity: 64,
2178 ..Default::default()
2179 };
2180 let json = serde_json::to_string(&config).unwrap();
2181 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2182 assert_eq!(de.channel_capacity, 64);
2183 }
2184
2185 #[test]
2186 fn plugin_config_channel_capacity_json_key_camel_case() {
2187 let config = PluginConfig {
2188 channel_capacity: 32,
2189 ..Default::default()
2190 };
2191 let json = serde_json::to_string(&config).unwrap();
2192 assert!(
2193 json.contains("channelCapacity"),
2194 "JSON should use camelCase: {json}"
2195 );
2196 }
2197
2198 #[test]
2201 fn plugin_config_android_fgs_types_default() {
2202 let json = "{}";
2203 let config: PluginConfig = serde_json::from_str(json).unwrap();
2204 assert_eq!(
2205 config.android_foreground_service_types,
2206 vec!["remoteMessaging"]
2207 );
2208 }
2209
2210 #[test]
2211 fn plugin_config_android_fgs_types_custom() {
2212 let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
2213 let config: PluginConfig = serde_json::from_str(json).unwrap();
2214 assert_eq!(
2215 config.android_foreground_service_types,
2216 vec!["dataSync", "specialUse"]
2217 );
2218 }
2219
2220 #[test]
2221 fn plugin_config_android_fgs_types_serde_roundtrip() {
2222 let config = PluginConfig {
2223 android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
2224 ..Default::default()
2225 };
2226 let json = serde_json::to_string(&config).unwrap();
2227 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2228 assert_eq!(
2229 de.android_foreground_service_types,
2230 vec!["location", "connectedDevice"]
2231 );
2232 }
2233
2234 #[test]
2235 fn plugin_config_android_fgs_types_json_key_camel_case() {
2236 let config = PluginConfig {
2237 android_foreground_service_types: vec!["specialUse".into()],
2238 ..Default::default()
2239 };
2240 let json = serde_json::to_string(&config).unwrap();
2241 assert!(
2242 json.contains("androidForegroundServiceTypes"),
2243 "JSON should use camelCase: {json}"
2244 );
2245 }
2246
2247 #[test]
2248 fn plugin_config_android_validate_default() {
2249 let json = "{}";
2250 let config: PluginConfig = serde_json::from_str(json).unwrap();
2251 assert!(config.android_validate_foreground_service_type);
2252 }
2253
2254 #[test]
2255 fn plugin_config_android_validate_false() {
2256 let json = r#"{"androidValidateForegroundServiceType":false}"#;
2257 let config: PluginConfig = serde_json::from_str(json).unwrap();
2258 assert!(!config.android_validate_foreground_service_type);
2259 }
2260
2261 #[test]
2262 fn plugin_config_android_validate_serde_roundtrip() {
2263 let config = PluginConfig {
2264 android_validate_foreground_service_type: false,
2265 ..Default::default()
2266 };
2267 let json = serde_json::to_string(&config).unwrap();
2268 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2269 assert!(!de.android_validate_foreground_service_type);
2270 }
2271
2272 #[test]
2273 fn plugin_config_android_validate_json_key_camel_case() {
2274 let config = PluginConfig {
2275 android_validate_foreground_service_type: false,
2276 ..Default::default()
2277 };
2278 let json = serde_json::to_string(&config).unwrap();
2279 assert!(
2280 json.contains("androidValidateForegroundServiceType"),
2281 "JSON should use camelCase: {json}"
2282 );
2283 }
2284
2285 #[test]
2288 fn plugin_config_android_on_timeout_default() {
2289 let json = "{}";
2290 let config: PluginConfig = serde_json::from_str(json).unwrap();
2291 assert_eq!(config.android_on_timeout, "notifyUser");
2292 }
2293
2294 #[test]
2295 fn plugin_config_android_on_timeout_custom() {
2296 let json = r#"{"androidOnTimeout":"stop"}"#;
2297 let config: PluginConfig = serde_json::from_str(json).unwrap();
2298 assert_eq!(config.android_on_timeout, "stop");
2299 }
2300
2301 #[test]
2302 fn plugin_config_android_on_timeout_schedule_recovery() {
2303 let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
2304 let config: PluginConfig = serde_json::from_str(json).unwrap();
2305 assert_eq!(config.android_on_timeout, "scheduleRecovery");
2306 }
2307
2308 #[test]
2309 fn plugin_config_android_on_timeout_serde_roundtrip() {
2310 let config = PluginConfig {
2311 android_on_timeout: "stop".into(),
2312 ..Default::default()
2313 };
2314 let json = serde_json::to_string(&config).unwrap();
2315 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2316 assert_eq!(de.android_on_timeout, "stop");
2317 }
2318
2319 #[test]
2320 fn plugin_config_android_on_timeout_json_key_camel_case() {
2321 let config = PluginConfig {
2322 android_on_timeout: "notifyUser".into(),
2323 ..Default::default()
2324 };
2325 let json = serde_json::to_string(&config).unwrap();
2326 assert!(
2327 json.contains("androidOnTimeout"),
2328 "JSON should use camelCase: {json}"
2329 );
2330 }
2331
2332 #[test]
2333 fn plugin_config_android_notification_channel_id_default() {
2334 let json = "{}";
2335 let config: PluginConfig = serde_json::from_str(json).unwrap();
2336 assert_eq!(config.android_notification_channel_id, "bg_service");
2337 }
2338
2339 #[test]
2340 fn plugin_config_android_notification_channel_id_custom() {
2341 let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
2342 let config: PluginConfig = serde_json::from_str(json).unwrap();
2343 assert_eq!(config.android_notification_channel_id, "my_channel");
2344 }
2345
2346 #[test]
2347 fn plugin_config_android_notification_channel_id_serde_roundtrip() {
2348 let config = PluginConfig {
2349 android_notification_channel_id: "custom_ch".into(),
2350 ..Default::default()
2351 };
2352 let json = serde_json::to_string(&config).unwrap();
2353 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2354 assert_eq!(de.android_notification_channel_id, "custom_ch");
2355 }
2356
2357 #[test]
2358 fn plugin_config_android_notification_channel_id_json_key_camel_case() {
2359 let config = PluginConfig {
2360 android_notification_channel_id: "test".into(),
2361 ..Default::default()
2362 };
2363 let json = serde_json::to_string(&config).unwrap();
2364 assert!(
2365 json.contains("androidNotificationChannelId"),
2366 "JSON should use camelCase: {json}"
2367 );
2368 }
2369
2370 #[test]
2371 fn plugin_config_android_notification_channel_name_default() {
2372 let json = "{}";
2373 let config: PluginConfig = serde_json::from_str(json).unwrap();
2374 assert_eq!(
2375 config.android_notification_channel_name,
2376 "Background Service"
2377 );
2378 }
2379
2380 #[test]
2381 fn plugin_config_android_notification_channel_name_custom() {
2382 let json = r#"{"androidNotificationChannelName":"My Service"}"#;
2383 let config: PluginConfig = serde_json::from_str(json).unwrap();
2384 assert_eq!(config.android_notification_channel_name, "My Service");
2385 }
2386
2387 #[test]
2388 fn plugin_config_android_notification_channel_name_serde_roundtrip() {
2389 let config = PluginConfig {
2390 android_notification_channel_name: "Sync Service".into(),
2391 ..Default::default()
2392 };
2393 let json = serde_json::to_string(&config).unwrap();
2394 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2395 assert_eq!(de.android_notification_channel_name, "Sync Service");
2396 }
2397
2398 #[test]
2399 fn plugin_config_android_notification_channel_name_json_key_camel_case() {
2400 let config = PluginConfig {
2401 android_notification_channel_name: "Test".into(),
2402 ..Default::default()
2403 };
2404 let json = serde_json::to_string(&config).unwrap();
2405 assert!(
2406 json.contains("androidNotificationChannelName"),
2407 "JSON should use camelCase: {json}"
2408 );
2409 }
2410
2411 #[test]
2412 fn plugin_config_android_notification_id_default() {
2413 let json = "{}";
2414 let config: PluginConfig = serde_json::from_str(json).unwrap();
2415 assert_eq!(config.android_notification_id, 9001);
2416 }
2417
2418 #[test]
2419 fn plugin_config_android_notification_id_custom() {
2420 let json = r#"{"androidNotificationId":1234}"#;
2421 let config: PluginConfig = serde_json::from_str(json).unwrap();
2422 assert_eq!(config.android_notification_id, 1234);
2423 }
2424
2425 #[test]
2426 fn plugin_config_android_notification_id_serde_roundtrip() {
2427 let config = PluginConfig {
2428 android_notification_id: 42,
2429 ..Default::default()
2430 };
2431 let json = serde_json::to_string(&config).unwrap();
2432 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2433 assert_eq!(de.android_notification_id, 42);
2434 }
2435
2436 #[test]
2437 fn plugin_config_android_notification_id_json_key_camel_case() {
2438 let config = PluginConfig {
2439 android_notification_id: 5555,
2440 ..Default::default()
2441 };
2442 let json = serde_json::to_string(&config).unwrap();
2443 assert!(
2444 json.contains("androidNotificationId"),
2445 "JSON should use camelCase: {json}"
2446 );
2447 }
2448
2449 #[test]
2450 fn plugin_config_android_notification_small_icon_default() {
2451 let json = "{}";
2452 let config: PluginConfig = serde_json::from_str(json).unwrap();
2453 assert_eq!(config.android_notification_small_icon, None);
2454 }
2455
2456 #[test]
2457 fn plugin_config_android_notification_small_icon_custom() {
2458 let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
2459 let config: PluginConfig = serde_json::from_str(json).unwrap();
2460 assert_eq!(
2461 config.android_notification_small_icon,
2462 Some("ic_notification".to_string())
2463 );
2464 }
2465
2466 #[test]
2467 fn plugin_config_android_notification_small_icon_serde_roundtrip() {
2468 let config = PluginConfig {
2469 android_notification_small_icon: Some("my_icon".into()),
2470 ..Default::default()
2471 };
2472 let json = serde_json::to_string(&config).unwrap();
2473 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2474 assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
2475 }
2476
2477 #[test]
2478 fn plugin_config_android_notification_small_icon_absent_when_none() {
2479 let config = PluginConfig {
2480 android_notification_small_icon: None,
2481 ..Default::default()
2482 };
2483 let json = serde_json::to_string(&config).unwrap();
2484 assert!(
2485 !json.contains("androidNotificationSmallIcon"),
2486 "should be absent when None: {json}"
2487 );
2488 }
2489
2490 #[test]
2491 fn plugin_config_android_notification_small_icon_json_key_camel_case() {
2492 let config = PluginConfig {
2493 android_notification_small_icon: Some("icon".into()),
2494 ..Default::default()
2495 };
2496 let json = serde_json::to_string(&config).unwrap();
2497 assert!(
2498 json.contains("androidNotificationSmallIcon"),
2499 "JSON should use camelCase: {json}"
2500 );
2501 }
2502
2503 #[test]
2504 fn plugin_config_android_show_stop_action_default() {
2505 let json = "{}";
2506 let config: PluginConfig = serde_json::from_str(json).unwrap();
2507 assert!(config.android_show_stop_action);
2508 }
2509
2510 #[test]
2511 fn plugin_config_android_show_stop_action_false() {
2512 let json = r#"{"androidShowStopAction":false}"#;
2513 let config: PluginConfig = serde_json::from_str(json).unwrap();
2514 assert!(!config.android_show_stop_action);
2515 }
2516
2517 #[test]
2518 fn plugin_config_android_show_stop_action_serde_roundtrip() {
2519 let config = PluginConfig {
2520 android_show_stop_action: false,
2521 ..Default::default()
2522 };
2523 let json = serde_json::to_string(&config).unwrap();
2524 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2525 assert!(!de.android_show_stop_action);
2526 }
2527
2528 #[test]
2529 fn plugin_config_android_show_stop_action_json_key_camel_case() {
2530 let config = PluginConfig {
2531 android_show_stop_action: false,
2532 ..Default::default()
2533 };
2534 let json = serde_json::to_string(&config).unwrap();
2535 assert!(
2536 json.contains("androidShowStopAction"),
2537 "JSON should use camelCase: {json}"
2538 );
2539 }
2540
2541 #[test]
2544 fn plugin_config_android_request_notification_permission_default() {
2545 let json = "{}";
2546 let config: PluginConfig = serde_json::from_str(json).unwrap();
2547 assert!(!config.android_request_notification_permission_on_load);
2550 }
2551
2552 #[test]
2553 fn plugin_config_android_request_notification_permission_false() {
2554 let json = r#"{"androidRequestNotificationPermissionOnLoad":false}"#;
2555 let config: PluginConfig = serde_json::from_str(json).unwrap();
2556 assert!(!config.android_request_notification_permission_on_load);
2557 }
2558
2559 #[test]
2560 fn plugin_config_android_request_notification_permission_serde_roundtrip() {
2561 let config = PluginConfig {
2562 android_request_notification_permission_on_load: false,
2563 ..Default::default()
2564 };
2565 let json = serde_json::to_string(&config).unwrap();
2566 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2567 assert!(!de.android_request_notification_permission_on_load);
2568 }
2569
2570 #[test]
2571 fn plugin_config_android_timeout_notification_full_roundtrip() {
2572 let config = PluginConfig {
2573 android_on_timeout: "scheduleRecovery".into(),
2574 android_notification_channel_id: "my_ch".into(),
2575 android_notification_channel_name: "My Channel".into(),
2576 android_notification_id: 42,
2577 android_notification_small_icon: Some("ic_bg".into()),
2578 android_show_stop_action: false,
2579 ..Default::default()
2580 };
2581 let json = serde_json::to_string(&config).unwrap();
2582 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2583 assert_eq!(de.android_on_timeout, "scheduleRecovery");
2584 assert_eq!(de.android_notification_channel_id, "my_ch");
2585 assert_eq!(de.android_notification_channel_name, "My Channel");
2586 assert_eq!(de.android_notification_id, 42);
2587 assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
2588 assert!(!de.android_show_stop_action);
2589 }
2590
2591 #[test]
2594 fn plugin_config_notify_keys_default_false() {
2595 let json = "{}";
2596 let config: PluginConfig = serde_json::from_str(json).unwrap();
2597 assert!(!config.notify_on_timeout);
2598 assert!(!config.notify_on_recovery);
2599 }
2600
2601 #[test]
2602 fn plugin_config_notify_on_timeout_custom() {
2603 let json = r#"{"notifyOnTimeout":true}"#;
2604 let config: PluginConfig = serde_json::from_str(json).unwrap();
2605 assert!(config.notify_on_timeout);
2606 assert!(!config.notify_on_recovery);
2607 }
2608
2609 #[test]
2610 fn plugin_config_notify_on_recovery_custom() {
2611 let json = r#"{"notifyOnRecovery":true}"#;
2612 let config: PluginConfig = serde_json::from_str(json).unwrap();
2613 assert!(!config.notify_on_timeout);
2614 assert!(config.notify_on_recovery);
2615 }
2616
2617 #[test]
2618 fn plugin_config_notify_keys_serde_roundtrip() {
2619 let config = PluginConfig {
2620 notify_on_timeout: true,
2621 notify_on_recovery: true,
2622 ..Default::default()
2623 };
2624 let json = serde_json::to_string(&config).unwrap();
2625 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2626 assert!(de.notify_on_timeout);
2627 assert!(de.notify_on_recovery);
2628 }
2629
2630 #[test]
2631 fn plugin_config_notify_keys_json_camel_case() {
2632 let config = PluginConfig {
2633 notify_on_timeout: true,
2634 notify_on_recovery: true,
2635 ..Default::default()
2636 };
2637 let json = serde_json::to_string(&config).unwrap();
2638 assert!(
2639 json.contains("notifyOnTimeout") && json.contains("notifyOnRecovery"),
2640 "JSON should use camelCase: {json}"
2641 );
2642 }
2643
2644 #[cfg(feature = "desktop-service")]
2647 #[test]
2648 fn plugin_config_desktop_mode_default() {
2649 let json = "{}";
2650 let config: PluginConfig = serde_json::from_str(json).unwrap();
2651 assert_eq!(config.desktop_service_mode, "inProcess");
2652 }
2653
2654 #[cfg(feature = "desktop-service")]
2655 #[test]
2656 fn plugin_config_desktop_mode_custom() {
2657 let json = r#"{"desktopServiceMode":"osService"}"#;
2658 let config: PluginConfig = serde_json::from_str(json).unwrap();
2659 assert_eq!(config.desktop_service_mode, "osService");
2660 }
2661
2662 #[cfg(feature = "desktop-service")]
2663 #[test]
2664 fn plugin_config_desktop_mode_serde_roundtrip() {
2665 let config = PluginConfig {
2666 desktop_service_mode: "osService".into(),
2667 ..Default::default()
2668 };
2669 let json = serde_json::to_string(&config).unwrap();
2670 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2671 assert_eq!(de.desktop_service_mode, "osService");
2672 }
2673
2674 #[cfg(feature = "desktop-service")]
2675 #[test]
2676 fn plugin_config_desktop_label_default() {
2677 let json = "{}";
2678 let config: PluginConfig = serde_json::from_str(json).unwrap();
2679 assert_eq!(config.desktop_service_label, None);
2680 }
2681
2682 #[cfg(feature = "desktop-service")]
2683 #[test]
2684 fn plugin_config_desktop_label_custom() {
2685 let json = r#"{"desktopServiceLabel":"my.svc"}"#;
2686 let config: PluginConfig = serde_json::from_str(json).unwrap();
2687 assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
2688 }
2689
2690 #[cfg(feature = "desktop-service")]
2693 #[test]
2694 fn plugin_config_desktop_autostart_default() {
2695 let json = "{}";
2696 let config: PluginConfig = serde_json::from_str(json).unwrap();
2697 assert!(!config.desktop_service_autostart);
2698 }
2699
2700 #[cfg(feature = "desktop-service")]
2701 #[test]
2702 fn plugin_config_desktop_autostart_true() {
2703 let json = r#"{"desktopServiceAutostart":true}"#;
2704 let config: PluginConfig = serde_json::from_str(json).unwrap();
2705 assert!(config.desktop_service_autostart);
2706 }
2707
2708 #[cfg(feature = "desktop-service")]
2709 #[test]
2710 fn plugin_config_desktop_autostart_serde_roundtrip() {
2711 let config = PluginConfig {
2712 desktop_service_autostart: true,
2713 ..Default::default()
2714 };
2715 let json = serde_json::to_string(&config).unwrap();
2716 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2717 assert!(de.desktop_service_autostart);
2718 }
2719
2720 #[cfg(feature = "desktop-service")]
2721 #[test]
2722 fn plugin_config_desktop_autostart_json_key_camel_case() {
2723 let config = PluginConfig {
2724 desktop_service_autostart: true,
2725 ..Default::default()
2726 };
2727 let json = serde_json::to_string(&config).unwrap();
2728 assert!(
2729 json.contains("desktopServiceAutostart"),
2730 "JSON should use camelCase: {json}"
2731 );
2732 }
2733
2734 #[cfg(feature = "desktop-service")]
2735 #[test]
2736 fn plugin_config_desktop_start_if_missing_default() {
2737 let json = "{}";
2738 let config: PluginConfig = serde_json::from_str(json).unwrap();
2739 assert!(!config.desktop_start_service_if_missing);
2740 }
2741
2742 #[cfg(feature = "desktop-service")]
2743 #[test]
2744 fn plugin_config_desktop_start_if_missing_true() {
2745 let json = r#"{"desktopStartServiceIfMissing":true}"#;
2746 let config: PluginConfig = serde_json::from_str(json).unwrap();
2747 assert!(config.desktop_start_service_if_missing);
2748 }
2749
2750 #[cfg(feature = "desktop-service")]
2751 #[test]
2752 fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
2753 let config = PluginConfig {
2754 desktop_start_service_if_missing: true,
2755 ..Default::default()
2756 };
2757 let json = serde_json::to_string(&config).unwrap();
2758 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2759 assert!(de.desktop_start_service_if_missing);
2760 }
2761
2762 #[cfg(feature = "desktop-service")]
2763 #[test]
2764 fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
2765 let config = PluginConfig {
2766 desktop_start_service_if_missing: true,
2767 ..Default::default()
2768 };
2769 let json = serde_json::to_string(&config).unwrap();
2770 assert!(
2771 json.contains("desktopStartServiceIfMissing"),
2772 "JSON should use camelCase: {json}"
2773 );
2774 }
2775
2776 #[cfg(feature = "desktop-service")]
2777 #[test]
2778 fn plugin_config_windows_daemon_opt_in_default_false() {
2779 let json = "{}";
2780 let config: PluginConfig = serde_json::from_str(json).unwrap();
2781 assert!(!config.desktop_windows_daemon_opt_in);
2782 }
2783
2784 #[cfg(feature = "desktop-service")]
2785 #[test]
2786 fn plugin_config_windows_daemon_opt_in_true() {
2787 let json = r#"{"desktopWindowsDaemonOptIn":true}"#;
2788 let config: PluginConfig = serde_json::from_str(json).unwrap();
2789 assert!(config.desktop_windows_daemon_opt_in);
2790 }
2791
2792 #[cfg(feature = "desktop-service")]
2793 #[test]
2794 fn plugin_config_windows_daemon_opt_in_serde_roundtrip() {
2795 let config = PluginConfig {
2796 desktop_windows_daemon_opt_in: true,
2797 ..Default::default()
2798 };
2799 let json = serde_json::to_string(&config).unwrap();
2800 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2801 assert!(de.desktop_windows_daemon_opt_in);
2802 }
2803
2804 #[cfg(feature = "desktop-service")]
2805 #[test]
2806 fn plugin_config_windows_daemon_opt_in_json_key_camel_case() {
2807 let config = PluginConfig {
2808 desktop_windows_daemon_opt_in: true,
2809 ..Default::default()
2810 };
2811 let json = serde_json::to_string(&config).unwrap();
2812 assert!(
2813 json.contains("desktopWindowsDaemonOptIn"),
2814 "JSON should use camelCase: {json}"
2815 );
2816 }
2817
2818 #[cfg(feature = "desktop-service")]
2819 #[test]
2820 fn plugin_config_desktop_start_timeout_default() {
2821 let json = "{}";
2822 let config: PluginConfig = serde_json::from_str(json).unwrap();
2823 assert_eq!(config.desktop_service_start_timeout_ms, 5000);
2824 }
2825
2826 #[cfg(feature = "desktop-service")]
2827 #[test]
2828 fn plugin_config_desktop_start_timeout_custom() {
2829 let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
2830 let config: PluginConfig = serde_json::from_str(json).unwrap();
2831 assert_eq!(config.desktop_service_start_timeout_ms, 10000);
2832 }
2833
2834 #[cfg(feature = "desktop-service")]
2835 #[test]
2836 fn plugin_config_desktop_start_timeout_serde_roundtrip() {
2837 let config = PluginConfig {
2838 desktop_service_start_timeout_ms: 15000,
2839 ..Default::default()
2840 };
2841 let json = serde_json::to_string(&config).unwrap();
2842 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2843 assert_eq!(de.desktop_service_start_timeout_ms, 15000);
2844 }
2845
2846 #[cfg(feature = "desktop-service")]
2847 #[test]
2848 fn plugin_config_desktop_start_timeout_json_key_camel_case() {
2849 let config = PluginConfig {
2850 desktop_service_start_timeout_ms: 3000,
2851 ..Default::default()
2852 };
2853 let json = serde_json::to_string(&config).unwrap();
2854 assert!(
2855 json.contains("desktopServiceStartTimeoutMs"),
2856 "JSON should use camelCase: {json}"
2857 );
2858 }
2859
2860 #[cfg(feature = "desktop-service")]
2861 #[test]
2862 fn plugin_config_desktop_all_new_fields_roundtrip() {
2863 let config = PluginConfig {
2864 desktop_service_autostart: true,
2865 desktop_start_service_if_missing: true,
2866 desktop_service_start_timeout_ms: 8000,
2867 ..Default::default()
2868 };
2869 let json = serde_json::to_string(&config).unwrap();
2870 let de: PluginConfig = serde_json::from_str(&json).unwrap();
2871 assert!(de.desktop_service_autostart);
2872 assert!(de.desktop_start_service_if_missing);
2873 assert_eq!(de.desktop_service_start_timeout_ms, 8000);
2874 }
2875
2876 use tauri::AppHandle;
2877
2878 #[cfg(mobile)]
2882 #[allow(dead_code)]
2883 fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
2884 let ctx = ServiceContext {
2885 notifier: Notifier { app: app.clone() },
2886 app,
2887 shutdown: CancellationToken::new(),
2888 service_label: "Syncing".into(),
2889 foreground_service_type: "dataSync".into(),
2890 };
2891 assert_eq!(ctx.service_label, "Syncing");
2892 assert_eq!(ctx.foreground_service_type, "dataSync");
2893 }
2894
2895 #[cfg(not(mobile))]
2897 #[allow(dead_code)]
2898 fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
2899 let ctx = ServiceContext {
2900 notifier: Notifier { app: app.clone() },
2901 app,
2902 shutdown: CancellationToken::new(),
2903 };
2904 let _ = ctx;
2906 }
2907
2908 #[test]
2911 fn validate_data_sync_passes() {
2912 assert!(
2913 validate_foreground_service_type("dataSync").is_ok(),
2914 "dataSync should be valid"
2915 );
2916 }
2917
2918 #[test]
2919 fn validate_special_use_passes() {
2920 assert!(
2921 validate_foreground_service_type("specialUse").is_ok(),
2922 "specialUse should be valid"
2923 );
2924 }
2925
2926 #[test]
2927 fn validate_invalid_type_returns_platform_error() {
2928 let result = validate_foreground_service_type("invalidType");
2929 assert!(result.is_err(), "invalidType should be rejected");
2930 match result {
2931 Err(crate::error::ServiceError::Platform(msg)) => {
2932 assert!(
2933 msg.contains("invalidType"),
2934 "error should mention the type: {msg}"
2935 );
2936 }
2937 other => panic!("Expected Platform error, got: {other:?}"),
2938 }
2939 }
2940
2941 #[test]
2942 fn validate_all_14_types_pass() {
2943 for &t in VALID_FOREGROUND_SERVICE_TYPES {
2944 assert!(
2945 validate_foreground_service_type(t).is_ok(),
2946 "{t} should be valid"
2947 );
2948 }
2949 }
2950
2951 #[test]
2952 fn valid_types_count_is_14() {
2953 assert_eq!(
2954 VALID_FOREGROUND_SERVICE_TYPES.len(),
2955 14,
2956 "should have exactly 14 valid types"
2957 );
2958 }
2959
2960 #[test]
2961 fn validate_empty_string_returns_error() {
2962 let result = validate_foreground_service_type("");
2963 assert!(result.is_err(), "empty string should be rejected");
2964 }
2965
2966 #[test]
2967 fn validate_case_sensitive() {
2968 let result = validate_foreground_service_type("DataSync");
2970 assert!(
2971 result.is_err(),
2972 "validation should be case-sensitive: DataSync should fail"
2973 );
2974 }
2975
2976 #[test]
2979 fn allowlist_accepted_type_in_list() {
2980 let allowlist = vec!["remoteMessaging".to_string()];
2981 let result = validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true);
2982 assert!(result.is_ok(), "type in allowlist should be accepted");
2983 }
2984
2985 #[test]
2986 fn allowlist_rejected_type_not_in_list() {
2987 let allowlist = vec!["remoteMessaging".to_string()];
2988 let result = validate_fg_type_against_allowlist("specialUse", &allowlist, true);
2989 assert!(result.is_err(), "type not in allowlist should be rejected");
2990 match result {
2991 Err(ServiceError::Platform(msg)) => {
2992 assert!(
2993 msg.contains("specialUse"),
2994 "error should mention the type: {msg}"
2995 );
2996 assert!(
2997 msg.contains("not allowed"),
2998 "error should say not allowed: {msg}"
2999 );
3000 }
3001 other => panic!("Expected Platform error, got: {other:?}"),
3002 }
3003 }
3004
3005 #[test]
3006 fn allowlist_empty_type_rejected() {
3007 let allowlist = vec!["dataSync".to_string()];
3008 let result = validate_fg_type_against_allowlist("", &allowlist, true);
3009 assert!(result.is_err(), "empty type should be rejected");
3010 match result {
3011 Err(ServiceError::Platform(msg)) => {
3012 assert!(
3013 msg.contains("must not be empty"),
3014 "error should mention empty: {msg}"
3015 );
3016 }
3017 other => panic!("Expected Platform error, got: {other:?}"),
3018 }
3019 }
3020
3021 #[test]
3022 fn allowlist_case_insensitive_match() {
3023 let allowlist = vec!["remoteMessaging".to_string()];
3024 let result = validate_fg_type_against_allowlist("RemoteMessaging", &allowlist, true);
3025 assert!(result.is_ok(), "case-insensitive match should be accepted");
3026 }
3027
3028 #[test]
3029 fn allowlist_validation_skipped_when_disabled() {
3030 let allowlist = vec!["dataSync".to_string()];
3031 let result = validate_fg_type_against_allowlist("specialUse", &allowlist, false);
3032 assert!(result.is_ok(), "validation disabled should accept any type");
3033 }
3034
3035 #[test]
3036 fn allowlist_multiple_types() {
3037 let allowlist = vec![
3038 "dataSync".to_string(),
3039 "remoteMessaging".to_string(),
3040 "specialUse".to_string(),
3041 ];
3042 assert!(
3043 validate_fg_type_against_allowlist("dataSync", &allowlist, true).is_ok(),
3044 "dataSync should be in allowlist"
3045 );
3046 assert!(
3047 validate_fg_type_against_allowlist("remoteMessaging", &allowlist, true).is_ok(),
3048 "remoteMessaging should be in allowlist"
3049 );
3050 assert!(
3051 validate_fg_type_against_allowlist("specialUse", &allowlist, true).is_ok(),
3052 "specialUse should be in allowlist"
3053 );
3054 assert!(
3055 validate_fg_type_against_allowlist("camera", &allowlist, true).is_err(),
3056 "camera should NOT be in allowlist"
3057 );
3058 }
3059
3060 #[test]
3063 fn service_state_idle_serde_roundtrip() {
3064 let state = ServiceState::Idle;
3065 let json = serde_json::to_string(&state).unwrap();
3066 let de: ServiceState = serde_json::from_str(&json).unwrap();
3067 assert_eq!(de, ServiceState::Idle);
3068 }
3069
3070 #[test]
3071 fn service_state_initializing_serde_roundtrip() {
3072 let state = ServiceState::Initializing;
3073 let json = serde_json::to_string(&state).unwrap();
3074 let de: ServiceState = serde_json::from_str(&json).unwrap();
3075 assert_eq!(de, ServiceState::Initializing);
3076 }
3077
3078 #[test]
3079 fn service_state_running_serde_roundtrip() {
3080 let state = ServiceState::Running;
3081 let json = serde_json::to_string(&state).unwrap();
3082 let de: ServiceState = serde_json::from_str(&json).unwrap();
3083 assert_eq!(de, ServiceState::Running);
3084 }
3085
3086 #[test]
3087 fn service_state_stopped_serde_roundtrip() {
3088 let state = ServiceState::Stopped;
3089 let json = serde_json::to_string(&state).unwrap();
3090 let de: ServiceState = serde_json::from_str(&json).unwrap();
3091 assert_eq!(de, ServiceState::Stopped);
3092 }
3093
3094 #[test]
3095 fn service_state_json_values_are_camel_case() {
3096 assert_eq!(
3097 serde_json::to_string(&ServiceState::Idle).unwrap(),
3098 "\"idle\""
3099 );
3100 assert_eq!(
3101 serde_json::to_string(&ServiceState::Initializing).unwrap(),
3102 "\"initializing\""
3103 );
3104 assert_eq!(
3105 serde_json::to_string(&ServiceState::Running).unwrap(),
3106 "\"running\""
3107 );
3108 assert_eq!(
3109 serde_json::to_string(&ServiceState::Stopped).unwrap(),
3110 "\"stopped\""
3111 );
3112 }
3113
3114 #[test]
3117 fn service_status_serde_roundtrip_idle() {
3118 let status = ServiceStatus {
3119 state: ServiceState::Idle,
3120 ..Default::default()
3121 };
3122 let json = serde_json::to_string(&status).unwrap();
3123 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3124 assert_eq!(de.state, ServiceState::Idle);
3125 assert_eq!(de.last_error, None);
3126 }
3127
3128 #[test]
3129 fn service_status_serde_roundtrip_with_error() {
3130 let status = ServiceStatus {
3131 state: ServiceState::Stopped,
3132 last_error: Some("init failed".into()),
3133 ..Default::default()
3134 };
3135 let json = serde_json::to_string(&status).unwrap();
3136 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3137 assert_eq!(de.state, ServiceState::Stopped);
3138 assert_eq!(de.last_error, Some("init failed".into()));
3139 }
3140
3141 #[test]
3142 fn service_status_json_keys_camel_case() {
3143 let status = ServiceStatus {
3144 state: ServiceState::Running,
3145 ..Default::default()
3146 };
3147 let json = serde_json::to_string(&status).unwrap();
3148 assert!(json.contains("\"state\":"), "state key: {json}");
3149 assert!(json.contains("\"lastError\":"), "lastError key: {json}");
3150 }
3151
3152 #[test]
3153 fn service_status_json_null_last_error() {
3154 let status = ServiceStatus {
3155 state: ServiceState::Idle,
3156 ..Default::default()
3157 };
3158 let json = serde_json::to_string(&status).unwrap();
3159 assert!(
3160 json.contains("\"lastError\":null"),
3161 "lastError should be null: {json}"
3162 );
3163 }
3164
3165 #[test]
3168 fn platform_serde_roundtrip() {
3169 for variant in [
3170 Platform::Android,
3171 Platform::Ios,
3172 Platform::Windows,
3173 Platform::Macos,
3174 Platform::Linux,
3175 Platform::Unknown,
3176 ] {
3177 let json = serde_json::to_string(&variant).unwrap();
3178 let de: Platform = serde_json::from_str(&json).unwrap();
3179 assert_eq!(de, variant);
3180 }
3181 }
3182
3183 #[test]
3184 fn platform_json_values_are_camel_case() {
3185 assert_eq!(
3186 serde_json::to_string(&Platform::Android).unwrap(),
3187 "\"android\""
3188 );
3189 assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
3190 assert_eq!(
3191 serde_json::to_string(&Platform::Windows).unwrap(),
3192 "\"windows\""
3193 );
3194 assert_eq!(
3195 serde_json::to_string(&Platform::Macos).unwrap(),
3196 "\"macos\""
3197 );
3198 assert_eq!(
3199 serde_json::to_string(&Platform::Linux).unwrap(),
3200 "\"linux\""
3201 );
3202 assert_eq!(
3203 serde_json::to_string(&Platform::Unknown).unwrap(),
3204 "\"unknown\""
3205 );
3206 }
3207
3208 #[test]
3211 fn lifecycle_mode_serde_roundtrip() {
3212 for variant in [
3213 LifecycleMode::AndroidForegroundService,
3214 LifecycleMode::IosBgTaskScheduler,
3215 LifecycleMode::DesktopInProcess,
3216 LifecycleMode::DesktopOsService,
3217 ] {
3218 let json = serde_json::to_string(&variant).unwrap();
3219 let de: LifecycleMode = serde_json::from_str(&json).unwrap();
3220 assert_eq!(de, variant);
3221 }
3222 }
3223
3224 #[test]
3225 fn lifecycle_mode_json_values_are_camel_case() {
3226 assert_eq!(
3227 serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
3228 "\"androidForegroundService\""
3229 );
3230 assert_eq!(
3231 serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
3232 "\"iosBgTaskScheduler\""
3233 );
3234 assert_eq!(
3235 serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
3236 "\"desktopInProcess\""
3237 );
3238 assert_eq!(
3239 serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
3240 "\"desktopOsService\""
3241 );
3242 }
3243
3244 #[test]
3247 fn lifecycle_guarantee_serde_roundtrip() {
3248 for variant in [
3249 LifecycleGuarantee::Guaranteed,
3250 LifecycleGuarantee::BestEffort,
3251 LifecycleGuarantee::Unsupported,
3252 ] {
3253 let json = serde_json::to_string(&variant).unwrap();
3254 let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
3255 assert_eq!(de, variant);
3256 }
3257 }
3258
3259 #[test]
3260 fn lifecycle_guarantee_json_values_are_camel_case() {
3261 assert_eq!(
3262 serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
3263 "\"guaranteed\""
3264 );
3265 assert_eq!(
3266 serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
3267 "\"bestEffort\""
3268 );
3269 assert_eq!(
3270 serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
3271 "\"unsupported\""
3272 );
3273 }
3274
3275 #[test]
3278 fn platform_capabilities_serde_roundtrip() {
3279 let caps = PlatformCapabilities {
3280 platform: Platform::Android,
3281 lifecycle_mode: LifecycleMode::AndroidForegroundService,
3282 survives_app_close: LifecycleGuarantee::BestEffort,
3283 survives_reboot: LifecycleGuarantee::BestEffort,
3284 survives_force_quit: LifecycleGuarantee::Unsupported,
3285 background_execution: LifecycleGuarantee::Guaranteed,
3286 limitations: vec!["OEM battery optimization".into()],
3287 required_setup: vec!["FOREGROUND_SERVICE permission".into()],
3288 };
3289 let json = serde_json::to_string(&caps).unwrap();
3290 let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
3291 assert_eq!(de, caps);
3292 }
3293
3294 #[test]
3295 fn platform_capabilities_json_keys_camel_case() {
3296 let caps = PlatformCapabilities {
3297 platform: Platform::Linux,
3298 lifecycle_mode: LifecycleMode::DesktopInProcess,
3299 survives_app_close: LifecycleGuarantee::Unsupported,
3300 survives_reboot: LifecycleGuarantee::Unsupported,
3301 survives_force_quit: LifecycleGuarantee::Unsupported,
3302 background_execution: LifecycleGuarantee::Guaranteed,
3303 limitations: vec![],
3304 required_setup: vec![],
3305 };
3306 let json = serde_json::to_string(&caps).unwrap();
3307 assert!(json.contains("\"platform\":"), "platform: {json}");
3308 assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
3309 assert!(
3310 json.contains("\"survivesAppClose\":"),
3311 "survivesAppClose: {json}"
3312 );
3313 assert!(
3314 json.contains("\"survivesReboot\":"),
3315 "survivesReboot: {json}"
3316 );
3317 assert!(
3318 json.contains("\"survivesForceQuit\":"),
3319 "survivesForceQuit: {json}"
3320 );
3321 assert!(
3322 json.contains("\"backgroundExecution\":"),
3323 "backgroundExecution: {json}"
3324 );
3325 assert!(json.contains("\"limitations\":"), "limitations: {json}");
3326 assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
3327 }
3328
3329 #[test]
3330 fn platform_capabilities_empty_collections_serialize() {
3331 let caps = PlatformCapabilities {
3332 platform: Platform::Unknown,
3333 lifecycle_mode: LifecycleMode::DesktopInProcess,
3334 survives_app_close: LifecycleGuarantee::Unsupported,
3335 survives_reboot: LifecycleGuarantee::Unsupported,
3336 survives_force_quit: LifecycleGuarantee::Unsupported,
3337 background_execution: LifecycleGuarantee::Unsupported,
3338 limitations: vec![],
3339 required_setup: vec![],
3340 };
3341 let json = serde_json::to_string(&caps).unwrap();
3342 assert!(json.contains("\"limitations\":[]"), "{json}");
3343 assert!(json.contains("\"requiredSetup\":[]"), "{json}");
3344 }
3345
3346 #[test]
3349 fn native_state_serde_roundtrip() {
3350 for variant in [
3351 NativeState::Idle,
3352 NativeState::Starting,
3353 NativeState::Running,
3354 NativeState::Stopping,
3355 NativeState::Timeout,
3356 NativeState::Expired,
3357 NativeState::Recovering,
3358 NativeState::Error,
3359 ] {
3360 let json = serde_json::to_string(&variant).unwrap();
3361 let de: NativeState = serde_json::from_str(&json).unwrap();
3362 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3363 }
3364 }
3365
3366 #[test]
3367 fn native_state_json_values_are_camel_case() {
3368 assert_eq!(
3369 serde_json::to_string(&NativeState::Idle).unwrap(),
3370 "\"idle\""
3371 );
3372 assert_eq!(
3373 serde_json::to_string(&NativeState::Starting).unwrap(),
3374 "\"starting\""
3375 );
3376 assert_eq!(
3377 serde_json::to_string(&NativeState::Running).unwrap(),
3378 "\"running\""
3379 );
3380 assert_eq!(
3381 serde_json::to_string(&NativeState::Stopping).unwrap(),
3382 "\"stopping\""
3383 );
3384 assert_eq!(
3385 serde_json::to_string(&NativeState::Timeout).unwrap(),
3386 "\"timeout\""
3387 );
3388 assert_eq!(
3389 serde_json::to_string(&NativeState::Expired).unwrap(),
3390 "\"expired\""
3391 );
3392 assert_eq!(
3393 serde_json::to_string(&NativeState::Recovering).unwrap(),
3394 "\"recovering\""
3395 );
3396 assert_eq!(
3397 serde_json::to_string(&NativeState::Error).unwrap(),
3398 "\"error\""
3399 );
3400 }
3401
3402 #[test]
3405 fn service_status_backward_compat_deserialize_old_json() {
3406 let old_json = r#"{"state":"running","lastError":null}"#;
3407 let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
3408 assert_eq!(status.state, ServiceState::Running);
3409 assert_eq!(status.last_error, None);
3410 assert_eq!(status.desired_running, None);
3411 assert_eq!(status.native_state, None);
3412 assert_eq!(status.platform_mode, None);
3413 assert_eq!(status.last_start_config, None);
3414 assert_eq!(status.last_heartbeat_at, None);
3415 assert_eq!(status.restart_attempt, None);
3416 assert_eq!(status.recovery_reason, None);
3417 assert_eq!(status.platform_error, None);
3418 }
3419
3420 #[test]
3421 fn service_status_new_fields_serialize_when_present() {
3422 let status = ServiceStatus {
3423 state: ServiceState::Running,
3424 last_error: None,
3425 desired_running: Some(true),
3426 native_state: Some(NativeState::Running),
3427 platform_mode: Some(LifecycleMode::AndroidForegroundService),
3428 last_start_config: Some(StartConfig::default()),
3429 last_heartbeat_at: Some(1234567890),
3430 restart_attempt: Some(2),
3431 recovery_reason: Some("boot recovery".into()),
3432 platform_error: Some("timeout exceeded".into()),
3433 };
3434 let json = serde_json::to_string(&status).unwrap();
3435 assert!(json.contains("\"desiredRunning\":true"), "{json}");
3436 assert!(json.contains("\"nativeState\":\"running\""), "{json}");
3437 assert!(
3438 json.contains("\"platformMode\":\"androidForegroundService\""),
3439 "{json}"
3440 );
3441 assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
3442 assert!(json.contains("\"restartAttempt\":2"), "{json}");
3443 assert!(
3444 json.contains("\"recoveryReason\":\"boot recovery\""),
3445 "{json}"
3446 );
3447 assert!(
3448 json.contains("\"platformError\":\"timeout exceeded\""),
3449 "{json}"
3450 );
3451 }
3452
3453 #[test]
3454 fn service_status_new_fields_absent_when_none() {
3455 let status = ServiceStatus {
3456 state: ServiceState::Idle,
3457 last_error: None,
3458 desired_running: None,
3459 native_state: None,
3460 platform_mode: None,
3461 last_start_config: None,
3462 last_heartbeat_at: None,
3463 restart_attempt: None,
3464 recovery_reason: None,
3465 platform_error: None,
3466 };
3467 let json = serde_json::to_string(&status).unwrap();
3468 assert!(!json.contains("desiredRunning"), "should be absent: {json}");
3469 assert!(!json.contains("nativeState"), "should be absent: {json}");
3470 assert!(!json.contains("platformMode"), "should be absent: {json}");
3471 assert!(
3472 !json.contains("lastStartConfig"),
3473 "should be absent: {json}"
3474 );
3475 assert!(
3476 !json.contains("lastHeartbeatAt"),
3477 "should be absent: {json}"
3478 );
3479 assert!(!json.contains("restartAttempt"), "should be absent: {json}");
3480 assert!(!json.contains("recoveryReason"), "should be absent: {json}");
3481 assert!(!json.contains("platformError"), "should be absent: {json}");
3482 }
3483
3484 #[test]
3485 fn service_status_default_impl() {
3486 let status = ServiceStatus::default();
3487 assert_eq!(status.state, ServiceState::Idle);
3488 assert_eq!(status.last_error, None);
3489 assert_eq!(status.desired_running, None);
3490 assert_eq!(status.native_state, None);
3491 assert_eq!(status.platform_mode, None);
3492 assert_eq!(status.last_start_config, None);
3493 assert_eq!(status.last_heartbeat_at, None);
3494 assert_eq!(status.restart_attempt, None);
3495 assert_eq!(status.recovery_reason, None);
3496 assert_eq!(status.platform_error, None);
3497 }
3498
3499 #[test]
3500 fn service_status_full_roundtrip_with_all_fields() {
3501 let status = ServiceStatus {
3502 state: ServiceState::Running,
3503 last_error: Some("previous crash".into()),
3504 desired_running: Some(true),
3505 native_state: Some(NativeState::Recovering),
3506 platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
3507 last_start_config: Some(StartConfig {
3508 service_label: "Sync".into(),
3509 foreground_service_type: "dataSync".into(),
3510 }),
3511 last_heartbeat_at: Some(999),
3512 restart_attempt: Some(3),
3513 recovery_reason: Some("force stop".into()),
3514 platform_error: Some("scheduler busy".into()),
3515 };
3516 let json = serde_json::to_string(&status).unwrap();
3517 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
3518 assert_eq!(de.state, ServiceState::Running);
3519 assert_eq!(de.last_error, Some("previous crash".into()));
3520 assert_eq!(de.desired_running, Some(true));
3521 assert_eq!(de.native_state, Some(NativeState::Recovering));
3522 assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
3523 assert!(de.last_start_config.is_some());
3524 assert_eq!(de.last_heartbeat_at, Some(999));
3525 assert_eq!(de.restart_attempt, Some(3));
3526 assert_eq!(de.recovery_reason, Some("force stop".into()));
3527 assert_eq!(de.platform_error, Some("scheduler busy".into()));
3528 }
3529
3530 #[test]
3531 fn platform_capabilities_deserialize_from_json() {
3532 let json = r#"{
3533 "platform":"ios",
3534 "lifecycleMode":"iosBgTaskScheduler",
3535 "survivesAppClose":"bestEffort",
3536 "survivesReboot":"bestEffort",
3537 "survivesForceQuit":"unsupported",
3538 "backgroundExecution":"bestEffort",
3539 "limitations":["Cannot guarantee continuous execution"],
3540 "requiredSetup":["UIBackgroundModes in Info.plist"]
3541 }"#;
3542 let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
3543 assert_eq!(caps.platform, Platform::Ios);
3544 assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
3545 assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
3546 assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
3547 assert_eq!(caps.limitations.len(), 1);
3548 assert_eq!(caps.required_setup.len(), 1);
3549 }
3550
3551 #[test]
3554 fn ios_scheduling_status_both_scheduled() {
3555 let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3556 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3557 assert!(status.refresh_scheduled);
3558 assert!(status.processing_scheduled);
3559 assert_eq!(status.refresh_error, None);
3560 assert_eq!(status.processing_error, None);
3561 }
3562
3563 #[test]
3564 fn ios_scheduling_status_partial_success() {
3565 let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
3566 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3567 assert!(status.refresh_scheduled);
3568 assert!(!status.processing_scheduled);
3569 assert_eq!(status.refresh_error, None);
3570 assert_eq!(status.processing_error, Some("not permitted".to_string()));
3571 }
3572
3573 #[test]
3574 fn ios_scheduling_status_with_errors() {
3575 let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
3576 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3577 assert!(!status.refresh_scheduled);
3578 assert!(!status.processing_scheduled);
3579 assert_eq!(status.refresh_error, Some("err1".to_string()));
3580 assert_eq!(status.processing_error, Some("err2".to_string()));
3581 }
3582
3583 #[test]
3584 fn ios_scheduling_status_serde_roundtrip() {
3585 let status = IOSSchedulingStatus {
3586 refresh_scheduled: true,
3587 processing_scheduled: false,
3588 refresh_error: None,
3589 processing_error: Some("busy".into()),
3590 };
3591 let json = serde_json::to_string(&status).unwrap();
3592 let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
3593 assert_eq!(de, status);
3594 }
3595
3596 #[test]
3597 fn ios_scheduling_status_json_keys_camel_case() {
3598 let status = IOSSchedulingStatus {
3599 refresh_scheduled: true,
3600 processing_scheduled: true,
3601 refresh_error: Some("err".into()),
3602 processing_error: None,
3603 };
3604 let json = serde_json::to_string(&status).unwrap();
3605 assert!(json.contains("\"refreshScheduled\":"), "{json}");
3606 assert!(json.contains("\"processingScheduled\":"), "{json}");
3607 assert!(json.contains("\"refreshError\":"), "{json}");
3608 assert!(
3609 !json.contains("processingError"),
3610 "None fields should be absent: {json}"
3611 );
3612 }
3613
3614 #[test]
3615 fn ios_scheduling_status_from_value_null_errors() {
3616 let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3618 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3619 assert!(status.refresh_scheduled);
3620 assert!(status.processing_scheduled);
3621 assert_eq!(status.refresh_error, None);
3622 assert_eq!(status.processing_error, None);
3623 }
3624
3625 #[test]
3626 fn ios_scheduling_status_from_value_missing_errors() {
3627 let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
3629 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
3630 assert!(status.refresh_scheduled);
3631 assert!(status.processing_scheduled);
3632 assert_eq!(status.refresh_error, None);
3633 assert_eq!(status.processing_error, None);
3634 }
3635
3636 const SWIFT_SCHEDULING_STATUS_PAYLOAD: &str = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
3652
3653 const SWIFT_DESIRED_STATE_PAYLOAD: &str = r#"{"desiredRunning":true,"lastStartConfig":"{\"label\":\"App\"}","lastScheduleError":null,"lastTaskKind":"refresh","lastTaskStartedAt":1719500000.5,"lastTaskCompletedAt":null,"lastCompletionReason":null,"notificationGranted":null}"#;
3658
3659 #[test]
3660 fn ios_scheduling_status_parses_exact_swift_payload() {
3661 let status: IOSSchedulingStatus =
3662 serde_json::from_value(serde_json::from_str(SWIFT_SCHEDULING_STATUS_PAYLOAD).unwrap())
3663 .expect("the exact Swift getSchedulingStatus payload must deserialize");
3664 assert!(status.refresh_scheduled);
3665 assert!(status.processing_scheduled);
3666 assert_eq!(status.refresh_error, None);
3667 assert_eq!(status.processing_error, None);
3668 }
3669
3670 #[test]
3671 fn ios_desired_state_status_parses_exact_swift_payload() {
3672 let status: IOSDesiredStateStatus =
3673 serde_json::from_value(serde_json::from_str(SWIFT_DESIRED_STATE_PAYLOAD).unwrap())
3674 .expect("the exact Swift getDesiredStateStatus payload must deserialize");
3675 assert!(status.desired_running);
3676 assert_eq!(
3677 status.last_start_config.as_deref(),
3678 Some("{\"label\":\"App\"}")
3679 );
3680 assert_eq!(status.last_schedule_error, None);
3681 assert_eq!(status.last_task_kind.as_deref(), Some("refresh"));
3682 assert_eq!(status.last_task_started_at, Some(1719500000.5));
3683 assert_eq!(status.last_task_completed_at, None);
3684 assert_eq!(status.last_completion_reason, None);
3685 assert_eq!(status.notification_granted, None);
3686 }
3687
3688 #[test]
3689 fn ios_desired_state_status_defaults_when_never_started() {
3690 let json = r#"{"desiredRunning":false,"lastStartConfig":null,"lastScheduleError":null,"lastTaskKind":null,"lastTaskStartedAt":null,"lastTaskCompletedAt":null}"#;
3693 let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
3694 assert!(!status.desired_running);
3695 assert_eq!(status.last_start_config, None);
3696 assert_eq!(status.last_task_started_at, None);
3697 }
3698
3699 #[test]
3700 fn ios_desired_state_status_camel_case_roundtrip() {
3701 let status = IOSDesiredStateStatus {
3702 desired_running: true,
3703 last_start_config: Some("{}".into()),
3704 last_task_kind: Some("processing".into()),
3705 last_task_started_at: Some(1.0),
3706 last_task_completed_at: None,
3707 last_schedule_error: Some("boom".into()),
3708 last_completion_reason: Some("expired".into()),
3709 notification_granted: Some(true),
3710 };
3711 let json = serde_json::to_string(&status).unwrap();
3712 assert!(json.contains("\"desiredRunning\":"), "{json}");
3713 assert!(json.contains("\"lastStartConfig\":"), "{json}");
3714 assert!(json.contains("\"lastTaskKind\":"), "{json}");
3715 assert!(json.contains("\"lastScheduleError\":"), "{json}");
3716 assert!(json.contains("\"lastCompletionReason\":"), "{json}");
3717 assert!(json.contains("\"notificationGranted\":"), "{json}");
3718 let de: IOSDesiredStateStatus = serde_json::from_str(&json).unwrap();
3719 assert_eq!(de, status);
3720 }
3721
3722 #[test]
3726 fn ios_desired_state_status_carries_last_completion_reason() {
3727 let json = r#"{"desiredRunning":true,"lastCompletionReason":"completed"}"#;
3728 let status: IOSDesiredStateStatus = serde_json::from_str(json).unwrap();
3729 assert_eq!(status.last_completion_reason.as_deref(), Some("completed"));
3730
3731 let legacy = r#"{"desiredRunning":false}"#;
3733 let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
3734 assert_eq!(de.last_completion_reason, None);
3735 }
3736
3737 #[test]
3742 fn ios_desired_state_status_carries_notification_granted() {
3743 let granted = r#"{"desiredRunning":true,"notificationGranted":true}"#;
3744 let status: IOSDesiredStateStatus = serde_json::from_str(granted).unwrap();
3745 assert_eq!(status.notification_granted, Some(true));
3746
3747 let denied = r#"{"desiredRunning":true,"notificationGranted":false}"#;
3748 let status: IOSDesiredStateStatus = serde_json::from_str(denied).unwrap();
3749 assert_eq!(status.notification_granted, Some(false));
3750
3751 let legacy = r#"{"desiredRunning":false}"#;
3753 let de: IOSDesiredStateStatus = serde_json::from_str(legacy).unwrap();
3754 assert_eq!(de.notification_granted, None);
3755 }
3756
3757 #[test]
3760 fn os_service_install_state_serde_roundtrip() {
3761 for variant in [
3762 OsServiceInstallState::NotInstalled,
3763 OsServiceInstallState::Installed,
3764 OsServiceInstallState::Running,
3765 ] {
3766 let json = serde_json::to_string(&variant).unwrap();
3767 let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
3768 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
3769 }
3770 }
3771
3772 #[test]
3773 fn os_service_install_state_json_values_camel_case() {
3774 assert_eq!(
3775 serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
3776 "\"notInstalled\""
3777 );
3778 assert_eq!(
3779 serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
3780 "\"installed\""
3781 );
3782 assert_eq!(
3783 serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
3784 "\"running\""
3785 );
3786 }
3787
3788 #[test]
3791 fn os_service_status_serde_roundtrip() {
3792 let status = OsServiceStatus {
3793 label: "com.example.bg-service".into(),
3794 mode: "systemd".into(),
3795 installed: OsServiceInstallState::Running,
3796 ipc_connected: true,
3797 socket_path: Some("/tmp/test.sock".into()),
3798 last_error: None,
3799 };
3800 let json = serde_json::to_string(&status).unwrap();
3801 let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
3802 assert_eq!(de.label, "com.example.bg-service");
3803 assert_eq!(de.mode, "systemd");
3804 assert_eq!(de.installed, OsServiceInstallState::Running);
3805 assert!(de.ipc_connected);
3806 assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
3807 assert_eq!(de.last_error, None);
3808 }
3809
3810 #[test]
3811 fn os_service_status_json_keys_camel_case() {
3812 let status = OsServiceStatus {
3813 label: "test".into(),
3814 mode: "launchd".into(),
3815 installed: OsServiceInstallState::Installed,
3816 ipc_connected: false,
3817 socket_path: Some("/run/test.sock".into()),
3818 last_error: Some("timeout".into()),
3819 };
3820 let json = serde_json::to_string(&status).unwrap();
3821 assert!(json.contains("\"label\":"), "{json}");
3822 assert!(json.contains("\"mode\":"), "{json}");
3823 assert!(json.contains("\"installed\":"), "{json}");
3824 assert!(json.contains("\"ipcConnected\":"), "{json}");
3825 assert!(json.contains("\"socketPath\":"), "{json}");
3826 assert!(json.contains("\"lastError\":"), "{json}");
3827 }
3828
3829 #[test]
3830 fn os_service_status_optional_fields_absent_when_none() {
3831 let status = OsServiceStatus {
3832 label: "test".into(),
3833 mode: "systemd".into(),
3834 installed: OsServiceInstallState::NotInstalled,
3835 ipc_connected: false,
3836 socket_path: None,
3837 last_error: None,
3838 };
3839 let json = serde_json::to_string(&status).unwrap();
3840 assert!(!json.contains("socketPath"), "should be absent: {json}");
3841 assert!(!json.contains("lastError"), "should be absent: {json}");
3842 }
3843
3844 #[test]
3845 fn os_service_status_with_all_optional_fields() {
3846 let status = OsServiceStatus {
3847 label: "com.test".into(),
3848 mode: "launchd".into(),
3849 installed: OsServiceInstallState::Running,
3850 ipc_connected: true,
3851 socket_path: Some("/var/run/com.test.sock".into()),
3852 last_error: Some("connection refused".into()),
3853 };
3854 let json = serde_json::to_string(&status).unwrap();
3855 assert!(
3856 json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
3857 "{json}"
3858 );
3859 assert!(
3860 json.contains("\"lastError\":\"connection refused\""),
3861 "{json}"
3862 );
3863 }
3864
3865 #[test]
3866 fn os_service_status_deserialize_from_json() {
3867 let json = r#"{
3868 "label":"com.example.svc",
3869 "mode":"systemd",
3870 "installed":"running",
3871 "ipcConnected":true,
3872 "socketPath":"/tmp/test.sock"
3873 }"#;
3874 let status: OsServiceStatus = serde_json::from_str(json).unwrap();
3875 assert_eq!(status.label, "com.example.svc");
3876 assert_eq!(status.mode, "systemd");
3877 assert_eq!(status.installed, OsServiceInstallState::Running);
3878 assert!(status.ipc_connected);
3879 assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
3880 assert_eq!(status.last_error, None);
3881 }
3882
3883 #[test]
3886 fn pending_task_info_serde_roundtrip() {
3887 let info = PendingTaskInfo {
3888 task_kind: "refresh".into(),
3889 identifier: "com.example.app.bg-refresh".into(),
3890 received_at: 1700000000.123,
3891 consumed_at: None,
3892 };
3893 let json = serde_json::to_string(&info).unwrap();
3894 let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
3895 assert_eq!(de, info);
3896 }
3897
3898 #[test]
3899 fn pending_task_info_json_keys_camel_case() {
3900 let info = PendingTaskInfo {
3901 task_kind: "processing".into(),
3902 identifier: "test-id".into(),
3903 received_at: 123456.0,
3904 consumed_at: Some(123500.0),
3905 };
3906 let json = serde_json::to_string(&info).unwrap();
3907 assert!(json.contains("\"taskKind\":"), "{json}");
3908 assert!(json.contains("\"identifier\":"), "{json}");
3909 assert!(json.contains("\"receivedAt\":"), "{json}");
3910 assert!(json.contains("\"consumedAt\":"), "{json}");
3911 }
3912
3913 #[test]
3914 fn pending_task_info_from_native_response() {
3915 let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
3917 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3918 assert_eq!(info.task_kind, "refresh");
3919 assert_eq!(info.identifier, "com.example.bg-refresh");
3920 assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
3921 assert_eq!(info.consumed_at, None);
3922 }
3923
3924 #[test]
3925 fn pending_task_info_processing_kind() {
3926 let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
3927 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3928 assert_eq!(info.task_kind, "processing");
3929 assert_eq!(info.identifier, "com.example.bg-processing");
3930 assert_eq!(info.consumed_at, None);
3931 }
3932
3933 #[test]
3934 fn pending_task_info_consumed_at_roundtrip() {
3935 let info = PendingTaskInfo {
3936 task_kind: "refresh".into(),
3937 identifier: "com.example.bg-refresh".into(),
3938 received_at: 1700000000.0,
3939 consumed_at: Some(1700000060.5),
3940 };
3941 let json = serde_json::to_string(&info).unwrap();
3942 assert!(json.contains("\"consumedAt\":1700000060.5"), "{json}");
3943 let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
3944 assert_eq!(de.consumed_at, Some(1700000060.5));
3945 }
3946
3947 #[test]
3948 fn pending_task_info_consumed_at_null_deserializes_to_none() {
3949 let json = r#"{"taskKind":"refresh","identifier":"id","receivedAt":1.0,"consumedAt":null}"#;
3950 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
3951 assert_eq!(info.consumed_at, None);
3952 }
3953
3954 #[test]
3957 fn from_pending_payload_unconsumed_returns_some() {
3958 let value = serde_json::json!({
3960 "taskKind": "refresh",
3961 "identifier": "com.example.bg-refresh",
3962 "receivedAt": 1700000000.0,
3963 "consumedAt": serde_json::Value::Null,
3964 });
3965 let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
3966 assert_eq!(
3967 pending,
3968 Some(PendingTaskInfo {
3969 task_kind: "refresh".into(),
3970 identifier: "com.example.bg-refresh".into(),
3971 received_at: 1700000000.0,
3972 consumed_at: None,
3973 })
3974 );
3975 }
3976
3977 #[test]
3978 fn from_pending_payload_consumed_returns_none() {
3979 let value = serde_json::json!({
3983 "taskKind": "processing",
3984 "identifier": "com.example.bg-processing",
3985 "receivedAt": 1700000000.0,
3986 "consumedAt": 1700000060.5,
3987 });
3988 let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
3989 assert_eq!(pending, None);
3990 }
3991
3992 #[test]
3993 fn from_pending_payload_no_record_returns_none() {
3994 let value = serde_json::json!({
3996 "taskKind": serde_json::Value::Null,
3997 "identifier": serde_json::Value::Null,
3998 "receivedAt": serde_json::Value::Null,
3999 "consumedAt": serde_json::Value::Null,
4000 });
4001 let pending = PendingTaskInfo::from_pending_payload(&value).unwrap();
4002 assert_eq!(pending, None);
4003 }
4004
4005 #[test]
4008 fn lifecycle_state_all_variants_serde_roundtrip() {
4009 for variant in [
4010 LifecycleState::Idle,
4011 LifecycleState::Starting,
4012 LifecycleState::Running,
4013 LifecycleState::Stopping,
4014 LifecycleState::Stopped,
4015 LifecycleState::Recovering,
4016 LifecycleState::RecoveryPending,
4017 LifecycleState::Expired,
4018 LifecycleState::Blocked,
4019 LifecycleState::Error,
4020 ] {
4021 let json = serde_json::to_string(&variant).unwrap();
4022 let de: LifecycleState = serde_json::from_str(&json).unwrap();
4023 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4024 }
4025 }
4026
4027 #[test]
4028 fn lifecycle_state_json_values_are_camel_case() {
4029 assert_eq!(
4030 serde_json::to_string(&LifecycleState::Idle).unwrap(),
4031 "\"idle\""
4032 );
4033 assert_eq!(
4034 serde_json::to_string(&LifecycleState::Starting).unwrap(),
4035 "\"starting\""
4036 );
4037 assert_eq!(
4038 serde_json::to_string(&LifecycleState::Running).unwrap(),
4039 "\"running\""
4040 );
4041 assert_eq!(
4042 serde_json::to_string(&LifecycleState::Stopping).unwrap(),
4043 "\"stopping\""
4044 );
4045 assert_eq!(
4046 serde_json::to_string(&LifecycleState::Stopped).unwrap(),
4047 "\"stopped\""
4048 );
4049 assert_eq!(
4050 serde_json::to_string(&LifecycleState::Recovering).unwrap(),
4051 "\"recovering\""
4052 );
4053 assert_eq!(
4054 serde_json::to_string(&LifecycleState::RecoveryPending).unwrap(),
4055 "\"recoveryPending\""
4056 );
4057 assert_eq!(
4058 serde_json::to_string(&LifecycleState::Expired).unwrap(),
4059 "\"expired\""
4060 );
4061 assert_eq!(
4062 serde_json::to_string(&LifecycleState::Blocked).unwrap(),
4063 "\"blocked\""
4064 );
4065 assert_eq!(
4066 serde_json::to_string(&LifecycleState::Error).unwrap(),
4067 "\"error\""
4068 );
4069 }
4070
4071 #[test]
4074 fn service_state_idle_maps_to_lifecycle_idle() {
4075 assert_eq!(
4076 LifecycleState::from(ServiceState::Idle),
4077 LifecycleState::Idle
4078 );
4079 }
4080
4081 #[test]
4082 fn service_state_initializing_maps_to_lifecycle_starting() {
4083 assert_eq!(
4084 LifecycleState::from(ServiceState::Initializing),
4085 LifecycleState::Starting
4086 );
4087 }
4088
4089 #[test]
4090 fn service_state_running_maps_to_lifecycle_running() {
4091 assert_eq!(
4092 LifecycleState::from(ServiceState::Running),
4093 LifecycleState::Running
4094 );
4095 }
4096
4097 #[test]
4098 fn service_state_stopped_maps_to_lifecycle_stopped() {
4099 assert_eq!(
4100 LifecycleState::from(ServiceState::Stopped),
4101 LifecycleState::Stopped
4102 );
4103 }
4104
4105 #[test]
4108 fn severity_all_variants_serde_roundtrip() {
4109 for variant in [Severity::Error, Severity::Warning, Severity::Info] {
4110 let json = serde_json::to_string(&variant).unwrap();
4111 let de: Severity = serde_json::from_str(&json).unwrap();
4112 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
4113 }
4114 }
4115
4116 #[test]
4117 fn severity_json_values_are_camel_case() {
4118 assert_eq!(
4119 serde_json::to_string(&Severity::Error).unwrap(),
4120 "\"error\""
4121 );
4122 assert_eq!(
4123 serde_json::to_string(&Severity::Warning).unwrap(),
4124 "\"warning\""
4125 );
4126 assert_eq!(serde_json::to_string(&Severity::Info).unwrap(), "\"info\"");
4127 }
4128
4129 #[test]
4132 fn validation_issue_serde_roundtrip() {
4133 let issue = ValidationIssue {
4134 severity: Severity::Error,
4135 code: "ANDROID_MISSING_PERMISSION".into(),
4136 message: "Missing FOREGROUND_SERVICE permission".into(),
4137 fix: Some("Add FOREGROUND_SERVICE permission to AndroidManifest.xml".into()),
4138 platform: Platform::Android,
4139 };
4140 let json = serde_json::to_string(&issue).unwrap();
4141 let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4142 assert_eq!(de, issue);
4143 }
4144
4145 #[test]
4146 fn validation_issue_without_fix() {
4147 let issue = ValidationIssue {
4148 severity: Severity::Warning,
4149 code: "IOS_SCHEDULER_BUSY".into(),
4150 message: "BGTaskScheduler is busy".into(),
4151 fix: None,
4152 platform: Platform::Ios,
4153 };
4154 let json = serde_json::to_string(&issue).unwrap();
4155 assert!(
4156 !json.contains("fix"),
4157 "fix should be absent when None: {json}"
4158 );
4159 let de: ValidationIssue = serde_json::from_str(&json).unwrap();
4160 assert_eq!(de, issue);
4161 }
4162
4163 #[test]
4164 fn validation_issue_json_keys_camel_case() {
4165 let issue = ValidationIssue {
4166 severity: Severity::Info,
4167 code: "TEST".into(),
4168 message: "test".into(),
4169 fix: Some("do something".into()),
4170 platform: Platform::Linux,
4171 };
4172 let json = serde_json::to_string(&issue).unwrap();
4173 assert!(json.contains("\"severity\":"), "{json}");
4174 assert!(json.contains("\"code\":"), "{json}");
4175 assert!(json.contains("\"message\":"), "{json}");
4176 assert!(json.contains("\"fix\":"), "{json}");
4177 assert!(json.contains("\"platform\":"), "{json}");
4178 }
4179
4180 #[test]
4183 fn lifecycle_status_serde_roundtrip_minimal() {
4184 let status = LifecycleStatus {
4185 state: LifecycleState::Idle,
4186 desired_running: false,
4187 recovery_enabled: false,
4188 recovery_pending: false,
4189 recovery_reason: None,
4190 last_start_config: None,
4191 last_platform_state: None,
4192 last_platform_error: None,
4193 last_error: None,
4194 platform: Platform::Unknown,
4195 capabilities: PlatformCapabilities {
4196 platform: Platform::Unknown,
4197 lifecycle_mode: LifecycleMode::DesktopInProcess,
4198 survives_app_close: LifecycleGuarantee::Unsupported,
4199 survives_reboot: LifecycleGuarantee::Unsupported,
4200 survives_force_quit: LifecycleGuarantee::Unsupported,
4201 background_execution: LifecycleGuarantee::Unsupported,
4202 limitations: vec![],
4203 required_setup: vec![],
4204 },
4205 issues: vec![],
4206 native_running: None,
4207 native_foreground: None,
4208 adopted: None,
4209 degraded: None,
4210 degraded_reason: None,
4211 data_dir: None,
4212 };
4213 let json = serde_json::to_string(&status).unwrap();
4214 let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4215 assert_eq!(de.state, LifecycleState::Idle);
4216 assert!(!de.desired_running);
4217 assert!(!de.recovery_enabled);
4218 assert!(!de.recovery_pending);
4219 assert_eq!(de.recovery_reason, None);
4220 assert_eq!(de.last_start_config, None);
4221 assert_eq!(de.last_platform_state, None);
4222 assert_eq!(de.last_platform_error, None);
4223 assert_eq!(de.last_error, None);
4224 assert_eq!(de.platform, Platform::Unknown);
4225 assert!(de.issues.is_empty());
4226 }
4227
4228 #[test]
4229 fn lifecycle_status_optional_fields_absent_when_none() {
4230 let status = LifecycleStatus {
4231 state: LifecycleState::Idle,
4232 desired_running: false,
4233 recovery_enabled: false,
4234 recovery_pending: false,
4235 recovery_reason: None,
4236 last_start_config: None,
4237 last_platform_state: None,
4238 last_platform_error: None,
4239 last_error: None,
4240 platform: Platform::Unknown,
4241 capabilities: PlatformCapabilities {
4242 platform: Platform::Unknown,
4243 lifecycle_mode: LifecycleMode::DesktopInProcess,
4244 survives_app_close: LifecycleGuarantee::Unsupported,
4245 survives_reboot: LifecycleGuarantee::Unsupported,
4246 survives_force_quit: LifecycleGuarantee::Unsupported,
4247 background_execution: LifecycleGuarantee::Unsupported,
4248 limitations: vec![],
4249 required_setup: vec![],
4250 },
4251 issues: vec![],
4252 native_running: None,
4253 native_foreground: None,
4254 adopted: None,
4255 degraded: None,
4256 degraded_reason: None,
4257 data_dir: None,
4258 };
4259 let json = serde_json::to_string(&status).unwrap();
4260 assert!(!json.contains("recoveryReason"), "should be absent: {json}");
4261 assert!(
4262 !json.contains("lastStartConfig"),
4263 "should be absent: {json}"
4264 );
4265 assert!(
4266 !json.contains("lastPlatformState"),
4267 "should be absent: {json}"
4268 );
4269 assert!(
4270 !json.contains("lastPlatformError"),
4271 "should be absent: {json}"
4272 );
4273 assert!(!json.contains("lastError"), "should be absent: {json}");
4274 assert!(!json.contains("nativeRunning"), "should be absent: {json}");
4275 assert!(
4276 !json.contains("nativeForeground"),
4277 "should be absent: {json}"
4278 );
4279 assert!(!json.contains("adopted"), "should be absent: {json}");
4280 assert!(!json.contains("degraded"), "should be absent: {json}");
4281 assert!(!json.contains("degradedReason"), "should be absent: {json}");
4282 }
4283
4284 #[test]
4285 fn lifecycle_status_full_roundtrip_with_all_fields() {
4286 let status = LifecycleStatus {
4287 state: LifecycleState::Running,
4288 desired_running: true,
4289 recovery_enabled: true,
4290 recovery_pending: false,
4291 recovery_reason: Some("boot recovery".into()),
4292 last_start_config: Some(StartConfig {
4293 service_label: "Sync".into(),
4294 foreground_service_type: "dataSync".into(),
4295 }),
4296 last_platform_state: Some("running".into()),
4297 last_platform_error: Some("timeout exceeded".into()),
4298 last_error: Some("previous crash".into()),
4299 platform: Platform::Android,
4300 capabilities: PlatformCapabilities {
4301 platform: Platform::Android,
4302 lifecycle_mode: LifecycleMode::AndroidForegroundService,
4303 survives_app_close: LifecycleGuarantee::BestEffort,
4304 survives_reboot: LifecycleGuarantee::BestEffort,
4305 survives_force_quit: LifecycleGuarantee::Unsupported,
4306 background_execution: LifecycleGuarantee::Guaranteed,
4307 limitations: vec!["OEM battery optimization".into()],
4308 required_setup: vec!["FOREGROUND_SERVICE permission".into()],
4309 },
4310 issues: vec![ValidationIssue {
4311 severity: Severity::Warning,
4312 code: "ANDROID_BATTERY_OPTIMIZED".into(),
4313 message: "Battery optimization may kill the service".into(),
4314 fix: Some("Request REQUEST_IGNORE_BATTERY_OPTIMIZATIONS".into()),
4315 platform: Platform::Android,
4316 }],
4317 native_running: Some(true),
4318 native_foreground: Some(true),
4319 adopted: Some(false),
4320 degraded: Some(false),
4321 degraded_reason: None,
4322 data_dir: None,
4323 };
4324 let json = serde_json::to_string(&status).unwrap();
4325 let de: LifecycleStatus = serde_json::from_str(&json).unwrap();
4326 assert_eq!(de.state, LifecycleState::Running);
4327 assert!(de.desired_running);
4328 assert!(de.recovery_enabled);
4329 assert!(!de.recovery_pending);
4330 assert_eq!(de.recovery_reason, Some("boot recovery".into()));
4331 assert!(de.last_start_config.is_some());
4332 assert_eq!(de.last_platform_state, Some("running".into()));
4333 assert_eq!(de.last_platform_error, Some("timeout exceeded".into()));
4334 assert_eq!(de.last_error, Some("previous crash".into()));
4335 assert_eq!(de.platform, Platform::Android);
4336 assert_eq!(de.issues.len(), 1);
4337 assert_eq!(de.native_running, Some(true));
4338 assert_eq!(de.native_foreground, Some(true));
4339 assert_eq!(de.adopted, Some(false));
4340 assert_eq!(de.degraded, Some(false));
4341 assert_eq!(de.degraded_reason, None);
4342 }
4343
4344 #[test]
4345 fn lifecycle_status_json_keys_camel_case() {
4346 let status = LifecycleStatus {
4347 state: LifecycleState::RecoveryPending,
4348 desired_running: true,
4349 recovery_enabled: true,
4350 recovery_pending: true,
4351 recovery_reason: Some("platform timeout".into()),
4352 last_start_config: None,
4353 last_platform_state: Some("timeout".into()),
4354 last_platform_error: None,
4355 last_error: None,
4356 platform: Platform::Ios,
4357 capabilities: PlatformCapabilities {
4358 platform: Platform::Ios,
4359 lifecycle_mode: LifecycleMode::IosBgTaskScheduler,
4360 survives_app_close: LifecycleGuarantee::BestEffort,
4361 survives_reboot: LifecycleGuarantee::BestEffort,
4362 survives_force_quit: LifecycleGuarantee::Unsupported,
4363 background_execution: LifecycleGuarantee::BestEffort,
4364 limitations: vec![],
4365 required_setup: vec![],
4366 },
4367 issues: vec![],
4368 native_running: None,
4369 native_foreground: None,
4370 adopted: None,
4371 degraded: None,
4372 degraded_reason: None,
4373 data_dir: None,
4374 };
4375 let json = serde_json::to_string(&status).unwrap();
4376 assert!(json.contains("\"state\":"), "{json}");
4377 assert!(json.contains("\"desiredRunning\":"), "{json}");
4378 assert!(json.contains("\"recoveryEnabled\":"), "{json}");
4379 assert!(json.contains("\"recoveryPending\":"), "{json}");
4380 assert!(json.contains("\"recoveryReason\":"), "{json}");
4381 assert!(json.contains("\"lastPlatformState\":"), "{json}");
4382 assert!(json.contains("\"platform\":"), "{json}");
4383 assert!(json.contains("\"capabilities\":"), "{json}");
4384 assert!(json.contains("\"issues\":"), "{json}");
4385 }
4386
4387 #[test]
4390 fn plugin_config_camel_case_json_deserializes_correctly() {
4391 let json = r#"{
4392 "androidForegroundServiceTypes": ["remoteMessaging", "dataSync"],
4393 "androidOnTimeout": "notifyUser",
4394 "androidNotificationChannelId": "bg_service",
4395 "androidNotificationChannelName": "Background Service",
4396 "androidShowStopAction": true,
4397 "iosSafetyTimeoutSecs": 28.0,
4398 "iosEarliestRefreshBeginMinutes": 15.0,
4399 "iosEarliestProcessingBeginMinutes": 15.0,
4400 "desktopServiceMode": "inProcess"
4401 }"#;
4402 let config: PluginConfig = serde_json::from_str(json).unwrap();
4403 assert_eq!(
4404 config.android_foreground_service_types,
4405 vec!["remoteMessaging", "dataSync"]
4406 );
4407 assert_eq!(config.android_on_timeout, "notifyUser");
4408 assert_eq!(config.android_notification_channel_id, "bg_service");
4409 assert_eq!(
4410 config.android_notification_channel_name,
4411 "Background Service"
4412 );
4413 assert!(config.android_show_stop_action);
4414 assert_eq!(config.ios_safety_timeout_secs, 28.0);
4415 assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
4416 assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
4417 #[cfg(feature = "desktop-service")]
4418 assert_eq!(config.desktop_service_mode, "inProcess");
4419 }
4420
4421 #[test]
4422 fn plugin_config_snake_case_json_falls_back_to_defaults() {
4423 let json = r#"{
4424 "android_foreground_service_types": ["remoteMessaging", "dataSync"],
4425 "desktop_service_mode": "inProcess"
4426 }"#;
4427 let config: PluginConfig = serde_json::from_str(json).unwrap();
4428 assert_eq!(
4430 config.android_foreground_service_types,
4431 vec!["remoteMessaging"],
4432 "snake_case key should fall back to default"
4433 );
4434 }
4435
4436 #[test]
4439 fn android_service_state_deserialize_full_kotlin_output() {
4440 let json = r#"{
4441 "nativeRunning": true,
4442 "nativeForeground": true,
4443 "desiredRunning": true,
4444 "durableState": "running",
4445 "serviceLabel": "App Service",
4446 "foregroundServiceType": "remoteMessaging",
4447 "notificationId": 9001,
4448 "notificationChannelId": "bg_service",
4449 "recoveryPending": false,
4450 "recoveryReason": null,
4451 "lastPlatformError": null,
4452 "dataDir": "/data/data/com.example.app"
4453 }"#;
4454 let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4455 assert!(state.native_running);
4456 assert!(state.native_foreground);
4457 assert!(state.desired_running);
4458 assert_eq!(state.durable_state, "running");
4459 assert_eq!(state.service_label, Some("App Service".into()));
4460 assert_eq!(
4461 state.foreground_service_type,
4462 Some("remoteMessaging".into())
4463 );
4464 assert_eq!(state.notification_id, Some(9001));
4465 assert_eq!(state.notification_channel_id, Some("bg_service".into()));
4466 assert!(!state.recovery_pending);
4467 assert_eq!(state.recovery_reason, None);
4468 assert_eq!(state.last_platform_error, None);
4469 assert_eq!(state.data_dir, "/data/data/com.example.app");
4470 }
4471
4472 #[test]
4473 fn android_service_state_deserialize_minimal() {
4474 let json = r#"{
4475 "nativeRunning": false,
4476 "nativeForeground": false,
4477 "desiredRunning": false,
4478 "durableState": "stopped",
4479 "recoveryPending": false,
4480 "dataDir": ""
4481 }"#;
4482 let state: AndroidServiceState = serde_json::from_str(json).unwrap();
4483 assert!(!state.native_running);
4484 assert!(!state.native_foreground);
4485 assert!(!state.desired_running);
4486 assert_eq!(state.durable_state, "stopped");
4487 assert_eq!(state.service_label, None);
4488 assert_eq!(state.foreground_service_type, None);
4489 assert_eq!(state.notification_id, None);
4490 assert_eq!(state.notification_channel_id, None);
4491 assert!(!state.recovery_pending);
4492 assert_eq!(state.recovery_reason, None);
4493 assert_eq!(state.last_platform_error, None);
4494 assert_eq!(state.data_dir, "");
4495 }
4496
4497 #[test]
4498 fn android_service_state_serde_roundtrip() {
4499 let state = AndroidServiceState {
4500 native_running: true,
4501 native_foreground: false,
4502 desired_running: true,
4503 durable_state: "starting".into(),
4504 service_label: Some("test".into()),
4505 foreground_service_type: None,
4506 notification_id: None,
4507 notification_channel_id: None,
4508 recovery_pending: true,
4509 recovery_reason: Some("boot".into()),
4510 last_platform_error: Some("crash".into()),
4511 data_dir: "/data/test".into(),
4512 };
4513 let json = serde_json::to_string(&state).unwrap();
4514 let de: AndroidServiceState = serde_json::from_str(&json).unwrap();
4515 assert_eq!(de, state);
4516 }
4517
4518 #[test]
4519 fn android_service_state_optional_fields_absent_when_none() {
4520 let state = AndroidServiceState {
4521 native_running: false,
4522 native_foreground: false,
4523 desired_running: false,
4524 durable_state: "unknown".into(),
4525 service_label: None,
4526 foreground_service_type: None,
4527 notification_id: None,
4528 notification_channel_id: None,
4529 recovery_pending: false,
4530 recovery_reason: None,
4531 last_platform_error: None,
4532 data_dir: "".into(),
4533 };
4534 let json = serde_json::to_string(&state).unwrap();
4535 assert!(!json.contains("serviceLabel"), "absent: {json}");
4536 assert!(!json.contains("foregroundServiceType"), "absent: {json}");
4537 assert!(!json.contains("notificationId"), "absent: {json}");
4538 assert!(!json.contains("notificationChannelId"), "absent: {json}");
4539 assert!(!json.contains("recoveryReason"), "absent: {json}");
4540 assert!(!json.contains("lastPlatformError"), "absent: {json}");
4541 }
4542
4543 #[test]
4546 fn lifecycle_status_native_fields_serialize_when_present() {
4547 let status = LifecycleStatus {
4548 state: LifecycleState::Running,
4549 desired_running: true,
4550 recovery_enabled: true,
4551 recovery_pending: false,
4552 recovery_reason: None,
4553 last_start_config: None,
4554 last_platform_state: None,
4555 last_platform_error: None,
4556 last_error: None,
4557 platform: Platform::Android,
4558 capabilities: PlatformCapabilities {
4559 platform: Platform::Android,
4560 lifecycle_mode: LifecycleMode::AndroidForegroundService,
4561 survives_app_close: LifecycleGuarantee::BestEffort,
4562 survives_reboot: LifecycleGuarantee::BestEffort,
4563 survives_force_quit: LifecycleGuarantee::Unsupported,
4564 background_execution: LifecycleGuarantee::Guaranteed,
4565 limitations: vec![],
4566 required_setup: vec![],
4567 },
4568 issues: vec![],
4569 native_running: Some(true),
4570 native_foreground: Some(true),
4571 adopted: Some(false),
4572 degraded: Some(false),
4573 degraded_reason: Some("native running but Rust idle".into()),
4574 data_dir: None,
4575 };
4576 let json = serde_json::to_string(&status).unwrap();
4577 assert!(json.contains("\"nativeRunning\":true"), "{json}");
4578 assert!(json.contains("\"nativeForeground\":true"), "{json}");
4579 assert!(json.contains("\"adopted\":false"), "{json}");
4580 assert!(json.contains("\"degraded\":false"), "{json}");
4581 assert!(
4582 json.contains("\"degradedReason\":\"native running but Rust idle\""),
4583 "{json}"
4584 );
4585 }
4586
4587 #[test]
4588 fn lifecycle_status_native_fields_deserialize_from_json() {
4589 let json = r#"{
4590 "state": "running",
4591 "desiredRunning": true,
4592 "recoveryEnabled": true,
4593 "recoveryPending": false,
4594 "platform": "android",
4595 "capabilities": {
4596 "platform": "android",
4597 "lifecycleMode": "androidForegroundService",
4598 "survivesAppClose": "bestEffort",
4599 "survivesReboot": "bestEffort",
4600 "survivesForceQuit": "unsupported",
4601 "backgroundExecution": "guaranteed",
4602 "limitations": [],
4603 "requiredSetup": []
4604 },
4605 "issues": [],
4606 "nativeRunning": true,
4607 "nativeForeground": false,
4608 "adopted": true,
4609 "degraded": true,
4610 "degradedReason": "split-brain detected"
4611 }"#;
4612 let status: LifecycleStatus = serde_json::from_str(json).unwrap();
4613 assert_eq!(status.native_running, Some(true));
4614 assert_eq!(status.native_foreground, Some(false));
4615 assert_eq!(status.adopted, Some(true));
4616 assert_eq!(status.degraded, Some(true));
4617 assert_eq!(status.degraded_reason, Some("split-brain detected".into()));
4618 }
4619}