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