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")]
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 #[cfg(feature = "desktop-service")]
194 #[serde(default = "default_desktop_service_mode")]
195 pub desktop_service_mode: String,
196
197 #[cfg(feature = "desktop-service")]
200 #[serde(default)]
201 pub desktop_service_label: Option<String>,
202
203 #[cfg(feature = "desktop-service")]
207 #[serde(default)]
208 pub desktop_service_autostart: bool,
209
210 #[cfg(feature = "desktop-service")]
215 #[serde(default)]
216 pub desktop_start_service_if_missing: bool,
217
218 #[cfg(feature = "desktop-service")]
223 #[serde(default = "default_desktop_service_start_timeout_ms")]
224 pub desktop_service_start_timeout_ms: u64,
225}
226
227fn default_ios_safety_timeout() -> f64 {
228 28.0
229}
230
231fn default_ios_cancel_listener_timeout_secs() -> u64 {
232 14400
233}
234
235fn default_ios_processing_safety_timeout_secs() -> f64 {
236 0.0
237}
238
239fn default_ios_earliest_refresh_begin_minutes() -> f64 {
240 15.0
241}
242
243fn default_ios_earliest_processing_begin_minutes() -> f64 {
244 15.0
245}
246
247fn default_android_foreground_service_types() -> Vec<String> {
248 vec!["dataSync".into()]
249}
250
251fn default_android_on_timeout() -> String {
252 "notifyUser".into()
253}
254
255fn default_android_notification_channel_id() -> String {
256 "bg_service".into()
257}
258
259fn default_android_notification_channel_name() -> String {
260 "Background Service".into()
261}
262
263fn default_android_notification_id() -> u32 {
264 9001
265}
266
267fn default_true() -> bool {
268 true
269}
270
271fn default_channel_capacity() -> usize {
272 16
273}
274
275#[cfg(feature = "desktop-service")]
276fn default_desktop_service_mode() -> String {
277 "inProcess".into()
278}
279
280#[cfg(feature = "desktop-service")]
281fn default_desktop_service_start_timeout_ms() -> u64 {
282 5000
283}
284
285impl Default for StartConfig {
286 fn default() -> Self {
287 Self {
288 service_label: default_label(),
289 foreground_service_type: default_foreground_service_type(),
290 }
291 }
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
299#[serde(rename_all = "camelCase")]
300#[non_exhaustive]
301pub enum ServiceState {
302 Idle,
304 Initializing,
306 Running,
308 Stopped,
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319#[non_exhaustive]
320pub enum NativeState {
321 Idle,
322 Starting,
323 Running,
324 Stopping,
325 Timeout,
326 Expired,
327 Recovering,
328 Error,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(rename_all = "camelCase")]
336pub struct ServiceStatus {
337 pub state: ServiceState,
339 pub last_error: Option<String>,
341
342 #[serde(skip_serializing_if = "Option::is_none")]
345 pub desired_running: Option<bool>,
346 #[serde(skip_serializing_if = "Option::is_none")]
348 pub native_state: Option<NativeState>,
349 #[serde(skip_serializing_if = "Option::is_none")]
351 pub platform_mode: Option<LifecycleMode>,
352 #[serde(skip_serializing_if = "Option::is_none")]
354 pub last_start_config: Option<StartConfig>,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub last_heartbeat_at: Option<u64>,
358 #[serde(skip_serializing_if = "Option::is_none")]
360 pub restart_attempt: Option<u32>,
361 #[serde(skip_serializing_if = "Option::is_none")]
363 pub recovery_reason: Option<String>,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub platform_error: Option<String>,
367}
368
369impl Default for ServiceStatus {
370 fn default() -> Self {
371 Self {
372 state: ServiceState::Idle,
373 last_error: None,
374 desired_running: None,
375 native_state: None,
376 platform_mode: None,
377 last_start_config: None,
378 last_heartbeat_at: None,
379 restart_attempt: None,
380 recovery_reason: None,
381 platform_error: None,
382 }
383 }
384}
385
386#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "camelCase")]
391#[non_exhaustive]
392pub enum Platform {
393 Android,
394 Ios,
395 Windows,
396 Macos,
397 Linux,
398 Unknown,
399}
400
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404#[non_exhaustive]
405pub enum LifecycleMode {
406 AndroidForegroundService,
407 IosBgTaskScheduler,
408 DesktopInProcess,
409 DesktopOsService,
410}
411
412#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(rename_all = "camelCase")]
419#[non_exhaustive]
420pub enum LifecycleGuarantee {
421 Guaranteed,
422 BestEffort,
423 Unsupported,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
431#[serde(rename_all = "camelCase")]
432#[non_exhaustive]
433pub struct PlatformCapabilities {
434 pub platform: Platform,
435 pub lifecycle_mode: LifecycleMode,
436 pub survives_app_close: LifecycleGuarantee,
437 pub survives_reboot: LifecycleGuarantee,
438 pub survives_force_quit: LifecycleGuarantee,
439 pub background_execution: LifecycleGuarantee,
440 pub limitations: Vec<String>,
441 pub required_setup: Vec<String>,
442}
443
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
449#[serde(rename_all = "camelCase")]
450#[non_exhaustive]
451pub enum OsServiceInstallState {
452 NotInstalled,
454 Installed,
456 Running,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
465#[serde(rename_all = "camelCase")]
466#[non_exhaustive]
467pub struct OsServiceStatus {
468 pub label: String,
470 pub mode: String,
472 pub installed: OsServiceInstallState,
474 pub ipc_connected: bool,
476 #[serde(skip_serializing_if = "Option::is_none")]
478 pub socket_path: Option<String>,
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub last_error: Option<String>,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
486#[serde(rename_all = "camelCase", tag = "type")]
487#[non_exhaustive]
488pub enum PluginEvent {
489 Started,
491 Stopped { reason: String },
493 Error { message: String },
495}
496
497impl Default for PluginConfig {
498 fn default() -> Self {
499 Self {
500 ios_safety_timeout_secs: default_ios_safety_timeout(),
501 ios_cancel_listener_timeout_secs: default_ios_cancel_listener_timeout_secs(),
502 ios_processing_safety_timeout_secs: default_ios_processing_safety_timeout_secs(),
503 ios_earliest_refresh_begin_minutes: default_ios_earliest_refresh_begin_minutes(),
504 ios_earliest_processing_begin_minutes: default_ios_earliest_processing_begin_minutes(),
505 ios_requires_external_power: false,
506 ios_requires_network_connectivity: false,
507 channel_capacity: default_channel_capacity(),
508 android_foreground_service_types: default_android_foreground_service_types(),
509 android_validate_foreground_service_type: default_true(),
510 android_on_timeout: default_android_on_timeout(),
511 android_notification_channel_id: default_android_notification_channel_id(),
512 android_notification_channel_name: default_android_notification_channel_name(),
513 android_notification_id: default_android_notification_id(),
514 android_notification_small_icon: None,
515 android_show_stop_action: default_true(),
516 #[cfg(feature = "desktop-service")]
517 desktop_service_mode: default_desktop_service_mode(),
518 #[cfg(feature = "desktop-service")]
519 desktop_service_label: None,
520 #[cfg(feature = "desktop-service")]
521 desktop_service_autostart: false,
522 #[cfg(feature = "desktop-service")]
523 desktop_start_service_if_missing: false,
524 #[cfg(feature = "desktop-service")]
525 desktop_service_start_timeout_ms: default_desktop_service_start_timeout_ms(),
526 }
527 }
528}
529
530#[derive(Debug, Serialize)]
534#[serde(rename_all = "camelCase")]
535#[allow(dead_code)]
536pub(crate) struct StartKeepaliveArgs<'a> {
537 pub label: &'a str,
538 pub foreground_service_type: &'a str,
539 #[serde(skip_serializing_if = "Option::is_none")]
541 pub ios_safety_timeout_secs: Option<f64>,
542 #[serde(skip_serializing_if = "Option::is_none")]
545 pub ios_processing_safety_timeout_secs: Option<f64>,
546 #[serde(skip_serializing_if = "Option::is_none")]
548 pub ios_earliest_refresh_begin_minutes: Option<f64>,
549 #[serde(skip_serializing_if = "Option::is_none")]
551 pub ios_earliest_processing_begin_minutes: Option<f64>,
552 #[serde(skip_serializing_if = "Option::is_none")]
554 pub ios_requires_external_power: Option<bool>,
555 #[serde(skip_serializing_if = "Option::is_none")]
557 pub ios_requires_network_connectivity: Option<bool>,
558}
559
560#[doc(hidden)]
565#[derive(Debug, Clone, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct AutoStartConfig {
568 pub pending: bool,
569 pub label: Option<String>,
570 pub service_type: Option<String>,
571}
572
573impl AutoStartConfig {
574 pub fn into_start_config(self) -> Option<StartConfig> {
576 if self.pending {
577 self.label.map(|label| StartConfig {
578 service_label: label,
579 foreground_service_type: self
580 .service_type
581 .unwrap_or_else(default_foreground_service_type),
582 })
583 } else {
584 None
585 }
586 }
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
596#[serde(rename_all = "camelCase")]
597#[non_exhaustive]
598pub struct PendingTaskInfo {
599 pub task_kind: String,
601 pub identifier: String,
603 pub received_at: f64,
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
613#[serde(rename_all = "camelCase")]
614#[non_exhaustive]
615pub struct IOSSchedulingStatus {
616 pub refresh_scheduled: bool,
618 pub processing_scheduled: bool,
620 #[serde(default)]
622 #[serde(skip_serializing_if = "Option::is_none")]
623 pub refresh_error: Option<String>,
624 #[serde(default)]
626 #[serde(skip_serializing_if = "Option::is_none")]
627 pub processing_error: Option<String>,
628}
629
630#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
635#[serde(rename_all = "camelCase")]
636#[non_exhaustive]
637pub struct SetupIssue {
638 pub code: String,
640 pub message: String,
642 pub platform: Platform,
644 #[serde(skip_serializing_if = "Option::is_none")]
646 pub fix: Option<String>,
647}
648
649#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
655#[serde(rename_all = "camelCase")]
656#[non_exhaustive]
657pub struct SetupValidationReport {
658 pub ok: bool,
660 pub errors: Vec<SetupIssue>,
662 pub warnings: Vec<SetupIssue>,
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669
670 #[test]
673 fn start_config_default_label() {
674 let config = StartConfig::default();
675 assert_eq!(config.service_label, "Service running");
676 }
677
678 #[test]
679 fn start_config_custom_label() {
680 let config = StartConfig {
681 service_label: "Syncing data".into(),
682 ..Default::default()
683 };
684 assert_eq!(config.service_label, "Syncing data");
685 }
686
687 #[test]
688 fn start_config_serde_roundtrip_default() {
689 let config = StartConfig::default();
690 let json = serde_json::to_string(&config).unwrap();
691 let de: StartConfig = serde_json::from_str(&json).unwrap();
692 assert_eq!(de.service_label, config.service_label);
693 }
694
695 #[test]
696 fn start_config_serde_roundtrip_custom() {
697 let config = StartConfig {
698 service_label: "My service".into(),
699 ..Default::default()
700 };
701 let json = serde_json::to_string(&config).unwrap();
702 let de: StartConfig = serde_json::from_str(&json).unwrap();
703 assert_eq!(de.service_label, "My service");
704 }
705
706 #[test]
707 fn start_config_deserialize_missing_field_uses_default() {
708 let json = "{}";
710 let de: StartConfig = serde_json::from_str(json).unwrap();
711 assert_eq!(de.service_label, "Service running");
712 }
713
714 #[test]
715 fn start_config_json_key_is_camel_case() {
716 let config = StartConfig {
717 service_label: "test".into(),
718 ..Default::default()
719 };
720 let json = serde_json::to_string(&config).unwrap();
721 assert!(
722 json.contains("serviceLabel"),
723 "JSON should use camelCase: {json}"
724 );
725 }
726
727 #[test]
730 fn plugin_event_started_serde_roundtrip() {
731 let event = PluginEvent::Started;
732 let json = serde_json::to_string(&event).unwrap();
733 let de: PluginEvent = serde_json::from_str(&json).unwrap();
734 assert!(matches!(de, PluginEvent::Started));
735 }
736
737 #[test]
738 fn plugin_event_stopped_serde_roundtrip() {
739 let event = PluginEvent::Stopped {
740 reason: "cancelled".into(),
741 };
742 let json = serde_json::to_string(&event).unwrap();
743 let de: PluginEvent = serde_json::from_str(&json).unwrap();
744 match de {
745 PluginEvent::Stopped { reason } => assert_eq!(reason, "cancelled"),
746 other => panic!("Expected Stopped, got {other:?}"),
747 }
748 }
749
750 #[test]
751 fn plugin_event_error_serde_roundtrip() {
752 let event = PluginEvent::Error {
753 message: "init failed".into(),
754 };
755 let json = serde_json::to_string(&event).unwrap();
756 let de: PluginEvent = serde_json::from_str(&json).unwrap();
757 match de {
758 PluginEvent::Error { message } => assert_eq!(message, "init failed"),
759 other => panic!("Expected Error, got {other:?}"),
760 }
761 }
762
763 #[test]
764 fn plugin_event_tagged_json_format() {
765 let event = PluginEvent::Started;
766 let json = serde_json::to_string(&event).unwrap();
767 assert!(json.contains("\"type\":\"started\""), "Tagged JSON: {json}");
768 }
769
770 #[test]
771 fn plugin_event_stopped_json_keys_camel_case() {
772 let event = PluginEvent::Stopped {
773 reason: "done".into(),
774 };
775 let json = serde_json::to_string(&event).unwrap();
776 assert!(json.contains("\"type\":\"stopped\""), "Tag: {json}");
777 assert!(json.contains("\"reason\":\"done\""), "Reason: {json}");
778 }
779
780 #[test]
781 fn plugin_event_error_json_keys_camel_case() {
782 let event = PluginEvent::Error {
783 message: "oops".into(),
784 };
785 let json = serde_json::to_string(&event).unwrap();
786 assert!(json.contains("\"type\":\"error\""), "Tag: {json}");
787 assert!(json.contains("\"message\":\"oops\""), "Message: {json}");
788 }
789
790 #[test]
793 fn start_config_default_service_type() {
794 let config = StartConfig::default();
795 assert_eq!(config.foreground_service_type, "dataSync");
796 }
797
798 #[test]
799 fn start_config_custom_service_type() {
800 let config = StartConfig {
801 service_label: "test".into(),
802 foreground_service_type: "specialUse".into(),
803 };
804 assert_eq!(config.foreground_service_type, "specialUse");
805 }
806
807 #[test]
808 fn start_config_serde_roundtrip_service_type() {
809 let config = StartConfig {
810 service_label: "test".into(),
811 foreground_service_type: "specialUse".into(),
812 };
813 let json = serde_json::to_string(&config).unwrap();
814 let de: StartConfig = serde_json::from_str(&json).unwrap();
815 assert_eq!(de.foreground_service_type, "specialUse");
816 }
817
818 #[test]
819 fn start_config_deserialize_missing_service_type() {
820 let json = r#"{"serviceLabel":"test"}"#;
821 let de: StartConfig = serde_json::from_str(json).unwrap();
822 assert_eq!(de.foreground_service_type, "dataSync");
823 }
824
825 #[test]
826 fn start_config_deserialize_special_use() {
827 let json = r#"{"serviceLabel":"test","foregroundServiceType":"specialUse"}"#;
828 let de: StartConfig = serde_json::from_str(json).unwrap();
829 assert_eq!(de.foreground_service_type, "specialUse");
830 }
831
832 #[test]
833 fn start_config_unrecognized_type_rejected_by_validation() {
834 let json = r#"{"serviceLabel":"test","foregroundServiceType":"customType"}"#;
836 let de: StartConfig = serde_json::from_str(json).unwrap();
837 assert_eq!(de.foreground_service_type, "customType");
838 let result = validate_foreground_service_type(&de.foreground_service_type);
840 assert!(
841 result.is_err(),
842 "validation should reject unrecognized type"
843 );
844 let err_msg = result.unwrap_err().to_string();
845 assert!(
846 err_msg.contains("customType"),
847 "error should mention the invalid type: {err_msg}"
848 );
849 }
850
851 #[test]
852 fn start_config_json_key_is_camel_case_service_type() {
853 let config = StartConfig {
854 service_label: "test".into(),
855 foreground_service_type: "specialUse".into(),
856 };
857 let json = serde_json::to_string(&config).unwrap();
858 assert!(
859 json.contains("foregroundServiceType"),
860 "JSON should use camelCase: {json}"
861 );
862 }
863
864 #[test]
867 fn auto_start_config_pending_with_label_returns_start_config() {
868 let json = r#"{"pending": true, "label": "Syncing"}"#;
869 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
870 let result = config.into_start_config();
871 assert!(result.is_some());
872 let start_config = result.unwrap();
873 assert_eq!(start_config.service_label, "Syncing");
874 assert_eq!(start_config.foreground_service_type, "dataSync");
875 }
876
877 #[test]
878 fn auto_start_config_not_pending_returns_none() {
879 let json = r#"{"pending": false, "label": null}"#;
880 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
881 let result = config.into_start_config();
882 assert!(result.is_none());
883 }
884
885 #[test]
886 fn auto_start_config_pending_no_label_returns_none() {
887 let json = r#"{"pending": true, "label": null}"#;
888 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
889 let result = config.into_start_config();
890 assert!(result.is_none());
891 }
892
893 #[test]
894 fn auto_start_config_with_service_type_preserves_it() {
895 let json = r#"{"pending":true,"label":"test","serviceType":"specialUse"}"#;
896 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
897 assert_eq!(config.service_type, Some("specialUse".to_string()));
898 let result = config.into_start_config();
899 assert!(result.is_some());
900 let start_config = result.unwrap();
901 assert_eq!(start_config.foreground_service_type, "specialUse");
902 }
903
904 #[test]
905 fn auto_start_config_without_service_type_uses_default() {
906 let json = r#"{"pending":true,"label":"test"}"#;
907 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
908 assert_eq!(config.service_type, None);
909 let result = config.into_start_config();
910 assert!(result.is_some());
911 assert_eq!(result.unwrap().foreground_service_type, "dataSync");
912 }
913
914 #[test]
915 fn auto_start_config_null_service_type_uses_default() {
916 let json = r#"{"pending":true,"label":"test","serviceType":null}"#;
917 let config: AutoStartConfig = serde_json::from_str(json).unwrap();
918 assert_eq!(config.service_type, None);
919 let result = config.into_start_config();
920 assert!(result.is_some());
921 assert_eq!(result.unwrap().foreground_service_type, "dataSync");
922 }
923
924 #[test]
927 fn plugin_config_default_ios_safety_timeout() {
928 let json = "{}";
929 let config: PluginConfig = serde_json::from_str(json).unwrap();
930 assert_eq!(config.ios_safety_timeout_secs, 28.0);
931 }
932
933 #[test]
934 fn plugin_config_custom_ios_safety_timeout() {
935 let json = r#"{"iosSafetyTimeoutSecs":15.0}"#;
936 let config: PluginConfig = serde_json::from_str(json).unwrap();
937 assert_eq!(config.ios_safety_timeout_secs, 15.0);
938 }
939
940 #[test]
941 fn plugin_config_serde_roundtrip_preserves_value() {
942 let config = PluginConfig {
943 ios_safety_timeout_secs: 30.0,
944 ios_cancel_listener_timeout_secs: 14400,
945 ios_processing_safety_timeout_secs: 0.0,
946 ios_earliest_refresh_begin_minutes: 20.0,
947 ios_earliest_processing_begin_minutes: 30.0,
948 ios_requires_external_power: true,
949 ios_requires_network_connectivity: true,
950 ..Default::default()
951 };
952 let json = serde_json::to_string(&config).unwrap();
953 let de: PluginConfig = serde_json::from_str(&json).unwrap();
954 assert_eq!(de.ios_safety_timeout_secs, 30.0);
955 assert_eq!(de.ios_earliest_refresh_begin_minutes, 20.0);
956 assert_eq!(de.ios_earliest_processing_begin_minutes, 30.0);
957 assert!(de.ios_requires_external_power);
958 assert!(de.ios_requires_network_connectivity);
959 }
960
961 #[test]
962 fn plugin_config_default_impl() {
963 let config = PluginConfig::default();
964 assert_eq!(config.ios_safety_timeout_secs, 28.0);
965 assert_eq!(config.channel_capacity, 16);
966 }
967
968 #[test]
969 fn plugin_config_default_cancel_timeout() {
970 let json = "{}";
971 let config: PluginConfig = serde_json::from_str(json).unwrap();
972 assert_eq!(config.ios_cancel_listener_timeout_secs, 14400);
973 }
974
975 #[test]
976 fn plugin_config_custom_cancel_timeout() {
977 let json = r#"{"iosCancelListenerTimeoutSecs":7200}"#;
978 let config: PluginConfig = serde_json::from_str(json).unwrap();
979 assert_eq!(config.ios_cancel_listener_timeout_secs, 7200);
980 }
981
982 #[test]
983 fn plugin_config_cancel_timeout_serde_roundtrip() {
984 let config = PluginConfig {
985 ios_cancel_listener_timeout_secs: 3600,
986 ..Default::default()
987 };
988 let json = serde_json::to_string(&config).unwrap();
989 let de: PluginConfig = serde_json::from_str(&json).unwrap();
990 assert_eq!(de.ios_cancel_listener_timeout_secs, 3600);
991 }
992
993 #[test]
996 fn plugin_config_processing_timeout_default() {
997 let json = "{}";
998 let config: PluginConfig = serde_json::from_str(json).unwrap();
999 assert_eq!(config.ios_processing_safety_timeout_secs, 0.0);
1000 }
1001
1002 #[test]
1003 fn plugin_config_processing_timeout_custom() {
1004 let json = r#"{"iosProcessingSafetyTimeoutSecs":60.0}"#;
1005 let config: PluginConfig = serde_json::from_str(json).unwrap();
1006 assert_eq!(config.ios_processing_safety_timeout_secs, 60.0);
1007 }
1008
1009 #[test]
1010 fn plugin_config_processing_timeout_serde_roundtrip() {
1011 let config = PluginConfig {
1012 ios_processing_safety_timeout_secs: 120.0,
1013 ..Default::default()
1014 };
1015 let json = serde_json::to_string(&config).unwrap();
1016 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1017 assert_eq!(de.ios_processing_safety_timeout_secs, 120.0);
1018 }
1019
1020 #[test]
1023 fn start_keepalive_args_with_timeout() {
1024 let args = StartKeepaliveArgs {
1025 label: "Test",
1026 foreground_service_type: "dataSync",
1027 ios_safety_timeout_secs: Some(15.0),
1028 ios_processing_safety_timeout_secs: None,
1029 ios_earliest_refresh_begin_minutes: None,
1030 ios_earliest_processing_begin_minutes: None,
1031 ios_requires_external_power: None,
1032 ios_requires_network_connectivity: None,
1033 };
1034 let json = serde_json::to_string(&args).unwrap();
1035 assert!(
1036 json.contains("\"iosSafetyTimeoutSecs\":15.0"),
1037 "JSON should contain iosSafetyTimeoutSecs: {json}"
1038 );
1039 }
1040
1041 #[test]
1042 fn start_keepalive_args_without_timeout() {
1043 let args = StartKeepaliveArgs {
1044 label: "Test",
1045 foreground_service_type: "dataSync",
1046 ios_safety_timeout_secs: None,
1047 ios_processing_safety_timeout_secs: None,
1048 ios_earliest_refresh_begin_minutes: None,
1049 ios_earliest_processing_begin_minutes: None,
1050 ios_requires_external_power: None,
1051 ios_requires_network_connectivity: None,
1052 };
1053 let json = serde_json::to_string(&args).unwrap();
1054 assert!(
1055 !json.contains("iosSafetyTimeoutSecs"),
1056 "JSON should NOT contain iosSafetyTimeoutSecs when None: {json}"
1057 );
1058 }
1059
1060 #[test]
1061 fn start_keepalive_args_processing_timeout() {
1062 let args = StartKeepaliveArgs {
1063 label: "Test",
1064 foreground_service_type: "dataSync",
1065 ios_safety_timeout_secs: None,
1066 ios_processing_safety_timeout_secs: Some(60.0),
1067 ios_earliest_refresh_begin_minutes: None,
1068 ios_earliest_processing_begin_minutes: None,
1069 ios_requires_external_power: None,
1070 ios_requires_network_connectivity: None,
1071 };
1072 let json = serde_json::to_string(&args).unwrap();
1073 assert!(
1074 json.contains("\"iosProcessingSafetyTimeoutSecs\":60.0"),
1075 "JSON should contain iosProcessingSafetyTimeoutSecs: {json}"
1076 );
1077 }
1078
1079 #[test]
1080 fn start_keepalive_args_no_processing_timeout() {
1081 let args = StartKeepaliveArgs {
1082 label: "Test",
1083 foreground_service_type: "dataSync",
1084 ios_safety_timeout_secs: None,
1085 ios_processing_safety_timeout_secs: None,
1086 ios_earliest_refresh_begin_minutes: None,
1087 ios_earliest_processing_begin_minutes: None,
1088 ios_requires_external_power: None,
1089 ios_requires_network_connectivity: None,
1090 };
1091 let json = serde_json::to_string(&args).unwrap();
1092 assert!(
1093 !json.contains("iosProcessingSafetyTimeoutSecs"),
1094 "JSON should NOT contain iosProcessingSafetyTimeoutSecs when None: {json}"
1095 );
1096 }
1097
1098 #[test]
1099 fn start_keepalive_args_camel_case_keys() {
1100 let args = StartKeepaliveArgs {
1101 label: "Test",
1102 foreground_service_type: "specialUse",
1103 ios_safety_timeout_secs: None,
1104 ios_processing_safety_timeout_secs: None,
1105 ios_earliest_refresh_begin_minutes: None,
1106 ios_earliest_processing_begin_minutes: None,
1107 ios_requires_external_power: None,
1108 ios_requires_network_connectivity: None,
1109 };
1110 let json = serde_json::to_string(&args).unwrap();
1111 assert!(json.contains("\"label\""), "label: {json}");
1112 assert!(
1113 json.contains("\"foregroundServiceType\""),
1114 "foregroundServiceType: {json}"
1115 );
1116 }
1117
1118 #[test]
1119 fn start_keepalive_args_scheduling_intervals() {
1120 let args = StartKeepaliveArgs {
1121 label: "Test",
1122 foreground_service_type: "dataSync",
1123 ios_safety_timeout_secs: None,
1124 ios_processing_safety_timeout_secs: None,
1125 ios_earliest_refresh_begin_minutes: Some(30.0),
1126 ios_earliest_processing_begin_minutes: Some(60.0),
1127 ios_requires_external_power: None,
1128 ios_requires_network_connectivity: None,
1129 };
1130 let json = serde_json::to_string(&args).unwrap();
1131 assert!(
1132 json.contains("\"iosEarliestRefreshBeginMinutes\":30.0"),
1133 "JSON should contain iosEarliestRefreshBeginMinutes: {json}"
1134 );
1135 assert!(
1136 json.contains("\"iosEarliestProcessingBeginMinutes\":60.0"),
1137 "JSON should contain iosEarliestProcessingBeginMinutes: {json}"
1138 );
1139 }
1140
1141 #[test]
1142 fn start_keepalive_args_processing_options() {
1143 let args = StartKeepaliveArgs {
1144 label: "Test",
1145 foreground_service_type: "dataSync",
1146 ios_safety_timeout_secs: None,
1147 ios_processing_safety_timeout_secs: None,
1148 ios_earliest_refresh_begin_minutes: None,
1149 ios_earliest_processing_begin_minutes: None,
1150 ios_requires_external_power: Some(true),
1151 ios_requires_network_connectivity: Some(true),
1152 };
1153 let json = serde_json::to_string(&args).unwrap();
1154 assert!(
1155 json.contains("\"iosRequiresExternalPower\":true"),
1156 "JSON should contain iosRequiresExternalPower: {json}"
1157 );
1158 assert!(
1159 json.contains("\"iosRequiresNetworkConnectivity\":true"),
1160 "JSON should contain iosRequiresNetworkConnectivity: {json}"
1161 );
1162 }
1163
1164 #[test]
1167 fn plugin_config_earliest_refresh_default() {
1168 let json = "{}";
1169 let config: PluginConfig = serde_json::from_str(json).unwrap();
1170 assert_eq!(config.ios_earliest_refresh_begin_minutes, 15.0);
1171 }
1172
1173 #[test]
1174 fn plugin_config_earliest_processing_default() {
1175 let json = "{}";
1176 let config: PluginConfig = serde_json::from_str(json).unwrap();
1177 assert_eq!(config.ios_earliest_processing_begin_minutes, 15.0);
1178 }
1179
1180 #[test]
1181 fn plugin_config_requires_external_power_default() {
1182 let json = "{}";
1183 let config: PluginConfig = serde_json::from_str(json).unwrap();
1184 assert!(!config.ios_requires_external_power);
1185 }
1186
1187 #[test]
1188 fn plugin_config_requires_network_connectivity_default() {
1189 let json = "{}";
1190 let config: PluginConfig = serde_json::from_str(json).unwrap();
1191 assert!(!config.ios_requires_network_connectivity);
1192 }
1193
1194 #[test]
1195 fn plugin_config_custom_scheduling_intervals() {
1196 let json =
1197 r#"{"iosEarliestRefreshBeginMinutes":30.0,"iosEarliestProcessingBeginMinutes":60.0}"#;
1198 let config: PluginConfig = serde_json::from_str(json).unwrap();
1199 assert_eq!(config.ios_earliest_refresh_begin_minutes, 30.0);
1200 assert_eq!(config.ios_earliest_processing_begin_minutes, 60.0);
1201 }
1202
1203 #[test]
1204 fn plugin_config_custom_processing_options() {
1205 let json = r#"{"iosRequiresExternalPower":true,"iosRequiresNetworkConnectivity":true}"#;
1206 let config: PluginConfig = serde_json::from_str(json).unwrap();
1207 assert!(config.ios_requires_external_power);
1208 assert!(config.ios_requires_network_connectivity);
1209 }
1210
1211 #[test]
1214 fn plugin_config_channel_capacity_default() {
1215 let json = "{}";
1216 let config: PluginConfig = serde_json::from_str(json).unwrap();
1217 assert_eq!(config.channel_capacity, 16);
1218 }
1219
1220 #[test]
1221 fn plugin_config_channel_capacity_custom() {
1222 let json = r#"{"channelCapacity":32}"#;
1223 let config: PluginConfig = serde_json::from_str(json).unwrap();
1224 assert_eq!(config.channel_capacity, 32);
1225 }
1226
1227 #[test]
1228 fn plugin_config_channel_capacity_serde_roundtrip() {
1229 let config = PluginConfig {
1230 channel_capacity: 64,
1231 ..Default::default()
1232 };
1233 let json = serde_json::to_string(&config).unwrap();
1234 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1235 assert_eq!(de.channel_capacity, 64);
1236 }
1237
1238 #[test]
1239 fn plugin_config_channel_capacity_json_key_camel_case() {
1240 let config = PluginConfig {
1241 channel_capacity: 32,
1242 ..Default::default()
1243 };
1244 let json = serde_json::to_string(&config).unwrap();
1245 assert!(
1246 json.contains("channelCapacity"),
1247 "JSON should use camelCase: {json}"
1248 );
1249 }
1250
1251 #[test]
1254 fn plugin_config_android_fgs_types_default() {
1255 let json = "{}";
1256 let config: PluginConfig = serde_json::from_str(json).unwrap();
1257 assert_eq!(config.android_foreground_service_types, vec!["dataSync"]);
1258 }
1259
1260 #[test]
1261 fn plugin_config_android_fgs_types_custom() {
1262 let json = r#"{"androidForegroundServiceTypes":["dataSync","specialUse"]}"#;
1263 let config: PluginConfig = serde_json::from_str(json).unwrap();
1264 assert_eq!(
1265 config.android_foreground_service_types,
1266 vec!["dataSync", "specialUse"]
1267 );
1268 }
1269
1270 #[test]
1271 fn plugin_config_android_fgs_types_serde_roundtrip() {
1272 let config = PluginConfig {
1273 android_foreground_service_types: vec!["location".into(), "connectedDevice".into()],
1274 ..Default::default()
1275 };
1276 let json = serde_json::to_string(&config).unwrap();
1277 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1278 assert_eq!(
1279 de.android_foreground_service_types,
1280 vec!["location", "connectedDevice"]
1281 );
1282 }
1283
1284 #[test]
1285 fn plugin_config_android_fgs_types_json_key_camel_case() {
1286 let config = PluginConfig {
1287 android_foreground_service_types: vec!["specialUse".into()],
1288 ..Default::default()
1289 };
1290 let json = serde_json::to_string(&config).unwrap();
1291 assert!(
1292 json.contains("androidForegroundServiceTypes"),
1293 "JSON should use camelCase: {json}"
1294 );
1295 }
1296
1297 #[test]
1298 fn plugin_config_android_validate_default() {
1299 let json = "{}";
1300 let config: PluginConfig = serde_json::from_str(json).unwrap();
1301 assert!(config.android_validate_foreground_service_type);
1302 }
1303
1304 #[test]
1305 fn plugin_config_android_validate_false() {
1306 let json = r#"{"androidValidateForegroundServiceType":false}"#;
1307 let config: PluginConfig = serde_json::from_str(json).unwrap();
1308 assert!(!config.android_validate_foreground_service_type);
1309 }
1310
1311 #[test]
1312 fn plugin_config_android_validate_serde_roundtrip() {
1313 let config = PluginConfig {
1314 android_validate_foreground_service_type: false,
1315 ..Default::default()
1316 };
1317 let json = serde_json::to_string(&config).unwrap();
1318 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1319 assert!(!de.android_validate_foreground_service_type);
1320 }
1321
1322 #[test]
1323 fn plugin_config_android_validate_json_key_camel_case() {
1324 let config = PluginConfig {
1325 android_validate_foreground_service_type: false,
1326 ..Default::default()
1327 };
1328 let json = serde_json::to_string(&config).unwrap();
1329 assert!(
1330 json.contains("androidValidateForegroundServiceType"),
1331 "JSON should use camelCase: {json}"
1332 );
1333 }
1334
1335 #[test]
1338 fn plugin_config_android_on_timeout_default() {
1339 let json = "{}";
1340 let config: PluginConfig = serde_json::from_str(json).unwrap();
1341 assert_eq!(config.android_on_timeout, "notifyUser");
1342 }
1343
1344 #[test]
1345 fn plugin_config_android_on_timeout_custom() {
1346 let json = r#"{"androidOnTimeout":"stop"}"#;
1347 let config: PluginConfig = serde_json::from_str(json).unwrap();
1348 assert_eq!(config.android_on_timeout, "stop");
1349 }
1350
1351 #[test]
1352 fn plugin_config_android_on_timeout_schedule_recovery() {
1353 let json = r#"{"androidOnTimeout":"scheduleRecovery"}"#;
1354 let config: PluginConfig = serde_json::from_str(json).unwrap();
1355 assert_eq!(config.android_on_timeout, "scheduleRecovery");
1356 }
1357
1358 #[test]
1359 fn plugin_config_android_on_timeout_serde_roundtrip() {
1360 let config = PluginConfig {
1361 android_on_timeout: "stop".into(),
1362 ..Default::default()
1363 };
1364 let json = serde_json::to_string(&config).unwrap();
1365 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1366 assert_eq!(de.android_on_timeout, "stop");
1367 }
1368
1369 #[test]
1370 fn plugin_config_android_on_timeout_json_key_camel_case() {
1371 let config = PluginConfig {
1372 android_on_timeout: "notifyUser".into(),
1373 ..Default::default()
1374 };
1375 let json = serde_json::to_string(&config).unwrap();
1376 assert!(
1377 json.contains("androidOnTimeout"),
1378 "JSON should use camelCase: {json}"
1379 );
1380 }
1381
1382 #[test]
1383 fn plugin_config_android_notification_channel_id_default() {
1384 let json = "{}";
1385 let config: PluginConfig = serde_json::from_str(json).unwrap();
1386 assert_eq!(config.android_notification_channel_id, "bg_service");
1387 }
1388
1389 #[test]
1390 fn plugin_config_android_notification_channel_id_custom() {
1391 let json = r#"{"androidNotificationChannelId":"my_channel"}"#;
1392 let config: PluginConfig = serde_json::from_str(json).unwrap();
1393 assert_eq!(config.android_notification_channel_id, "my_channel");
1394 }
1395
1396 #[test]
1397 fn plugin_config_android_notification_channel_id_serde_roundtrip() {
1398 let config = PluginConfig {
1399 android_notification_channel_id: "custom_ch".into(),
1400 ..Default::default()
1401 };
1402 let json = serde_json::to_string(&config).unwrap();
1403 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1404 assert_eq!(de.android_notification_channel_id, "custom_ch");
1405 }
1406
1407 #[test]
1408 fn plugin_config_android_notification_channel_id_json_key_camel_case() {
1409 let config = PluginConfig {
1410 android_notification_channel_id: "test".into(),
1411 ..Default::default()
1412 };
1413 let json = serde_json::to_string(&config).unwrap();
1414 assert!(
1415 json.contains("androidNotificationChannelId"),
1416 "JSON should use camelCase: {json}"
1417 );
1418 }
1419
1420 #[test]
1421 fn plugin_config_android_notification_channel_name_default() {
1422 let json = "{}";
1423 let config: PluginConfig = serde_json::from_str(json).unwrap();
1424 assert_eq!(
1425 config.android_notification_channel_name,
1426 "Background Service"
1427 );
1428 }
1429
1430 #[test]
1431 fn plugin_config_android_notification_channel_name_custom() {
1432 let json = r#"{"androidNotificationChannelName":"My Service"}"#;
1433 let config: PluginConfig = serde_json::from_str(json).unwrap();
1434 assert_eq!(config.android_notification_channel_name, "My Service");
1435 }
1436
1437 #[test]
1438 fn plugin_config_android_notification_channel_name_serde_roundtrip() {
1439 let config = PluginConfig {
1440 android_notification_channel_name: "Sync Service".into(),
1441 ..Default::default()
1442 };
1443 let json = serde_json::to_string(&config).unwrap();
1444 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1445 assert_eq!(de.android_notification_channel_name, "Sync Service");
1446 }
1447
1448 #[test]
1449 fn plugin_config_android_notification_channel_name_json_key_camel_case() {
1450 let config = PluginConfig {
1451 android_notification_channel_name: "Test".into(),
1452 ..Default::default()
1453 };
1454 let json = serde_json::to_string(&config).unwrap();
1455 assert!(
1456 json.contains("androidNotificationChannelName"),
1457 "JSON should use camelCase: {json}"
1458 );
1459 }
1460
1461 #[test]
1462 fn plugin_config_android_notification_id_default() {
1463 let json = "{}";
1464 let config: PluginConfig = serde_json::from_str(json).unwrap();
1465 assert_eq!(config.android_notification_id, 9001);
1466 }
1467
1468 #[test]
1469 fn plugin_config_android_notification_id_custom() {
1470 let json = r#"{"androidNotificationId":1234}"#;
1471 let config: PluginConfig = serde_json::from_str(json).unwrap();
1472 assert_eq!(config.android_notification_id, 1234);
1473 }
1474
1475 #[test]
1476 fn plugin_config_android_notification_id_serde_roundtrip() {
1477 let config = PluginConfig {
1478 android_notification_id: 42,
1479 ..Default::default()
1480 };
1481 let json = serde_json::to_string(&config).unwrap();
1482 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1483 assert_eq!(de.android_notification_id, 42);
1484 }
1485
1486 #[test]
1487 fn plugin_config_android_notification_id_json_key_camel_case() {
1488 let config = PluginConfig {
1489 android_notification_id: 5555,
1490 ..Default::default()
1491 };
1492 let json = serde_json::to_string(&config).unwrap();
1493 assert!(
1494 json.contains("androidNotificationId"),
1495 "JSON should use camelCase: {json}"
1496 );
1497 }
1498
1499 #[test]
1500 fn plugin_config_android_notification_small_icon_default() {
1501 let json = "{}";
1502 let config: PluginConfig = serde_json::from_str(json).unwrap();
1503 assert_eq!(config.android_notification_small_icon, None);
1504 }
1505
1506 #[test]
1507 fn plugin_config_android_notification_small_icon_custom() {
1508 let json = r#"{"androidNotificationSmallIcon":"ic_notification"}"#;
1509 let config: PluginConfig = serde_json::from_str(json).unwrap();
1510 assert_eq!(
1511 config.android_notification_small_icon,
1512 Some("ic_notification".to_string())
1513 );
1514 }
1515
1516 #[test]
1517 fn plugin_config_android_notification_small_icon_serde_roundtrip() {
1518 let config = PluginConfig {
1519 android_notification_small_icon: Some("my_icon".into()),
1520 ..Default::default()
1521 };
1522 let json = serde_json::to_string(&config).unwrap();
1523 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1524 assert_eq!(de.android_notification_small_icon, Some("my_icon".into()));
1525 }
1526
1527 #[test]
1528 fn plugin_config_android_notification_small_icon_absent_when_none() {
1529 let config = PluginConfig {
1530 android_notification_small_icon: None,
1531 ..Default::default()
1532 };
1533 let json = serde_json::to_string(&config).unwrap();
1534 assert!(
1535 !json.contains("androidNotificationSmallIcon"),
1536 "should be absent when None: {json}"
1537 );
1538 }
1539
1540 #[test]
1541 fn plugin_config_android_notification_small_icon_json_key_camel_case() {
1542 let config = PluginConfig {
1543 android_notification_small_icon: Some("icon".into()),
1544 ..Default::default()
1545 };
1546 let json = serde_json::to_string(&config).unwrap();
1547 assert!(
1548 json.contains("androidNotificationSmallIcon"),
1549 "JSON should use camelCase: {json}"
1550 );
1551 }
1552
1553 #[test]
1554 fn plugin_config_android_show_stop_action_default() {
1555 let json = "{}";
1556 let config: PluginConfig = serde_json::from_str(json).unwrap();
1557 assert!(config.android_show_stop_action);
1558 }
1559
1560 #[test]
1561 fn plugin_config_android_show_stop_action_false() {
1562 let json = r#"{"androidShowStopAction":false}"#;
1563 let config: PluginConfig = serde_json::from_str(json).unwrap();
1564 assert!(!config.android_show_stop_action);
1565 }
1566
1567 #[test]
1568 fn plugin_config_android_show_stop_action_serde_roundtrip() {
1569 let config = PluginConfig {
1570 android_show_stop_action: false,
1571 ..Default::default()
1572 };
1573 let json = serde_json::to_string(&config).unwrap();
1574 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1575 assert!(!de.android_show_stop_action);
1576 }
1577
1578 #[test]
1579 fn plugin_config_android_show_stop_action_json_key_camel_case() {
1580 let config = PluginConfig {
1581 android_show_stop_action: false,
1582 ..Default::default()
1583 };
1584 let json = serde_json::to_string(&config).unwrap();
1585 assert!(
1586 json.contains("androidShowStopAction"),
1587 "JSON should use camelCase: {json}"
1588 );
1589 }
1590
1591 #[test]
1592 fn plugin_config_android_timeout_notification_full_roundtrip() {
1593 let config = PluginConfig {
1594 android_on_timeout: "scheduleRecovery".into(),
1595 android_notification_channel_id: "my_ch".into(),
1596 android_notification_channel_name: "My Channel".into(),
1597 android_notification_id: 42,
1598 android_notification_small_icon: Some("ic_bg".into()),
1599 android_show_stop_action: false,
1600 ..Default::default()
1601 };
1602 let json = serde_json::to_string(&config).unwrap();
1603 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1604 assert_eq!(de.android_on_timeout, "scheduleRecovery");
1605 assert_eq!(de.android_notification_channel_id, "my_ch");
1606 assert_eq!(de.android_notification_channel_name, "My Channel");
1607 assert_eq!(de.android_notification_id, 42);
1608 assert_eq!(de.android_notification_small_icon, Some("ic_bg".into()));
1609 assert!(!de.android_show_stop_action);
1610 }
1611
1612 #[cfg(feature = "desktop-service")]
1615 #[test]
1616 fn plugin_config_desktop_mode_default() {
1617 let json = "{}";
1618 let config: PluginConfig = serde_json::from_str(json).unwrap();
1619 assert_eq!(config.desktop_service_mode, "inProcess");
1620 }
1621
1622 #[cfg(feature = "desktop-service")]
1623 #[test]
1624 fn plugin_config_desktop_mode_custom() {
1625 let json = r#"{"desktopServiceMode":"osService"}"#;
1626 let config: PluginConfig = serde_json::from_str(json).unwrap();
1627 assert_eq!(config.desktop_service_mode, "osService");
1628 }
1629
1630 #[cfg(feature = "desktop-service")]
1631 #[test]
1632 fn plugin_config_desktop_mode_serde_roundtrip() {
1633 let config = PluginConfig {
1634 desktop_service_mode: "osService".into(),
1635 ..Default::default()
1636 };
1637 let json = serde_json::to_string(&config).unwrap();
1638 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1639 assert_eq!(de.desktop_service_mode, "osService");
1640 }
1641
1642 #[cfg(feature = "desktop-service")]
1643 #[test]
1644 fn plugin_config_desktop_label_default() {
1645 let json = "{}";
1646 let config: PluginConfig = serde_json::from_str(json).unwrap();
1647 assert_eq!(config.desktop_service_label, None);
1648 }
1649
1650 #[cfg(feature = "desktop-service")]
1651 #[test]
1652 fn plugin_config_desktop_label_custom() {
1653 let json = r#"{"desktopServiceLabel":"my.svc"}"#;
1654 let config: PluginConfig = serde_json::from_str(json).unwrap();
1655 assert_eq!(config.desktop_service_label, Some("my.svc".to_string()));
1656 }
1657
1658 #[cfg(feature = "desktop-service")]
1661 #[test]
1662 fn plugin_config_desktop_autostart_default() {
1663 let json = "{}";
1664 let config: PluginConfig = serde_json::from_str(json).unwrap();
1665 assert!(!config.desktop_service_autostart);
1666 }
1667
1668 #[cfg(feature = "desktop-service")]
1669 #[test]
1670 fn plugin_config_desktop_autostart_true() {
1671 let json = r#"{"desktopServiceAutostart":true}"#;
1672 let config: PluginConfig = serde_json::from_str(json).unwrap();
1673 assert!(config.desktop_service_autostart);
1674 }
1675
1676 #[cfg(feature = "desktop-service")]
1677 #[test]
1678 fn plugin_config_desktop_autostart_serde_roundtrip() {
1679 let config = PluginConfig {
1680 desktop_service_autostart: true,
1681 ..Default::default()
1682 };
1683 let json = serde_json::to_string(&config).unwrap();
1684 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1685 assert!(de.desktop_service_autostart);
1686 }
1687
1688 #[cfg(feature = "desktop-service")]
1689 #[test]
1690 fn plugin_config_desktop_autostart_json_key_camel_case() {
1691 let config = PluginConfig {
1692 desktop_service_autostart: true,
1693 ..Default::default()
1694 };
1695 let json = serde_json::to_string(&config).unwrap();
1696 assert!(
1697 json.contains("desktopServiceAutostart"),
1698 "JSON should use camelCase: {json}"
1699 );
1700 }
1701
1702 #[cfg(feature = "desktop-service")]
1703 #[test]
1704 fn plugin_config_desktop_start_if_missing_default() {
1705 let json = "{}";
1706 let config: PluginConfig = serde_json::from_str(json).unwrap();
1707 assert!(!config.desktop_start_service_if_missing);
1708 }
1709
1710 #[cfg(feature = "desktop-service")]
1711 #[test]
1712 fn plugin_config_desktop_start_if_missing_true() {
1713 let json = r#"{"desktopStartServiceIfMissing":true}"#;
1714 let config: PluginConfig = serde_json::from_str(json).unwrap();
1715 assert!(config.desktop_start_service_if_missing);
1716 }
1717
1718 #[cfg(feature = "desktop-service")]
1719 #[test]
1720 fn plugin_config_desktop_start_if_missing_serde_roundtrip() {
1721 let config = PluginConfig {
1722 desktop_start_service_if_missing: true,
1723 ..Default::default()
1724 };
1725 let json = serde_json::to_string(&config).unwrap();
1726 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1727 assert!(de.desktop_start_service_if_missing);
1728 }
1729
1730 #[cfg(feature = "desktop-service")]
1731 #[test]
1732 fn plugin_config_desktop_start_if_missing_json_key_camel_case() {
1733 let config = PluginConfig {
1734 desktop_start_service_if_missing: true,
1735 ..Default::default()
1736 };
1737 let json = serde_json::to_string(&config).unwrap();
1738 assert!(
1739 json.contains("desktopStartServiceIfMissing"),
1740 "JSON should use camelCase: {json}"
1741 );
1742 }
1743
1744 #[cfg(feature = "desktop-service")]
1745 #[test]
1746 fn plugin_config_desktop_start_timeout_default() {
1747 let json = "{}";
1748 let config: PluginConfig = serde_json::from_str(json).unwrap();
1749 assert_eq!(config.desktop_service_start_timeout_ms, 5000);
1750 }
1751
1752 #[cfg(feature = "desktop-service")]
1753 #[test]
1754 fn plugin_config_desktop_start_timeout_custom() {
1755 let json = r#"{"desktopServiceStartTimeoutMs":10000}"#;
1756 let config: PluginConfig = serde_json::from_str(json).unwrap();
1757 assert_eq!(config.desktop_service_start_timeout_ms, 10000);
1758 }
1759
1760 #[cfg(feature = "desktop-service")]
1761 #[test]
1762 fn plugin_config_desktop_start_timeout_serde_roundtrip() {
1763 let config = PluginConfig {
1764 desktop_service_start_timeout_ms: 15000,
1765 ..Default::default()
1766 };
1767 let json = serde_json::to_string(&config).unwrap();
1768 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1769 assert_eq!(de.desktop_service_start_timeout_ms, 15000);
1770 }
1771
1772 #[cfg(feature = "desktop-service")]
1773 #[test]
1774 fn plugin_config_desktop_start_timeout_json_key_camel_case() {
1775 let config = PluginConfig {
1776 desktop_service_start_timeout_ms: 3000,
1777 ..Default::default()
1778 };
1779 let json = serde_json::to_string(&config).unwrap();
1780 assert!(
1781 json.contains("desktopServiceStartTimeoutMs"),
1782 "JSON should use camelCase: {json}"
1783 );
1784 }
1785
1786 #[cfg(feature = "desktop-service")]
1787 #[test]
1788 fn plugin_config_desktop_all_new_fields_roundtrip() {
1789 let config = PluginConfig {
1790 desktop_service_autostart: true,
1791 desktop_start_service_if_missing: true,
1792 desktop_service_start_timeout_ms: 8000,
1793 ..Default::default()
1794 };
1795 let json = serde_json::to_string(&config).unwrap();
1796 let de: PluginConfig = serde_json::from_str(&json).unwrap();
1797 assert!(de.desktop_service_autostart);
1798 assert!(de.desktop_start_service_if_missing);
1799 assert_eq!(de.desktop_service_start_timeout_ms, 8000);
1800 }
1801
1802 use tauri::AppHandle;
1803
1804 #[cfg(mobile)]
1808 #[allow(dead_code)]
1809 fn service_context_mobile_fields_with_values<R: Runtime>(app: AppHandle<R>) {
1810 let ctx = ServiceContext {
1811 notifier: Notifier { app: app.clone() },
1812 app,
1813 shutdown: CancellationToken::new(),
1814 service_label: "Syncing".into(),
1815 foreground_service_type: "dataSync".into(),
1816 };
1817 assert_eq!(ctx.service_label, "Syncing");
1818 assert_eq!(ctx.foreground_service_type, "dataSync");
1819 }
1820
1821 #[cfg(not(mobile))]
1823 #[allow(dead_code)]
1824 fn service_context_desktop_no_mobile_fields<R: Runtime>(app: AppHandle<R>) {
1825 let ctx = ServiceContext {
1826 notifier: Notifier { app: app.clone() },
1827 app,
1828 shutdown: CancellationToken::new(),
1829 };
1830 let _ = ctx;
1832 }
1833
1834 #[test]
1837 fn validate_data_sync_passes() {
1838 assert!(
1839 validate_foreground_service_type("dataSync").is_ok(),
1840 "dataSync should be valid"
1841 );
1842 }
1843
1844 #[test]
1845 fn validate_special_use_passes() {
1846 assert!(
1847 validate_foreground_service_type("specialUse").is_ok(),
1848 "specialUse should be valid"
1849 );
1850 }
1851
1852 #[test]
1853 fn validate_invalid_type_returns_platform_error() {
1854 let result = validate_foreground_service_type("invalidType");
1855 assert!(result.is_err(), "invalidType should be rejected");
1856 match result {
1857 Err(crate::error::ServiceError::Platform(msg)) => {
1858 assert!(
1859 msg.contains("invalidType"),
1860 "error should mention the type: {msg}"
1861 );
1862 }
1863 other => panic!("Expected Platform error, got: {other:?}"),
1864 }
1865 }
1866
1867 #[test]
1868 fn validate_all_14_types_pass() {
1869 for &t in VALID_FOREGROUND_SERVICE_TYPES {
1870 assert!(
1871 validate_foreground_service_type(t).is_ok(),
1872 "{t} should be valid"
1873 );
1874 }
1875 }
1876
1877 #[test]
1878 fn valid_types_count_is_14() {
1879 assert_eq!(
1880 VALID_FOREGROUND_SERVICE_TYPES.len(),
1881 14,
1882 "should have exactly 14 valid types"
1883 );
1884 }
1885
1886 #[test]
1887 fn validate_empty_string_returns_error() {
1888 let result = validate_foreground_service_type("");
1889 assert!(result.is_err(), "empty string should be rejected");
1890 }
1891
1892 #[test]
1893 fn validate_case_sensitive() {
1894 let result = validate_foreground_service_type("DataSync");
1896 assert!(
1897 result.is_err(),
1898 "validation should be case-sensitive: DataSync should fail"
1899 );
1900 }
1901
1902 #[test]
1905 fn service_state_idle_serde_roundtrip() {
1906 let state = ServiceState::Idle;
1907 let json = serde_json::to_string(&state).unwrap();
1908 let de: ServiceState = serde_json::from_str(&json).unwrap();
1909 assert_eq!(de, ServiceState::Idle);
1910 }
1911
1912 #[test]
1913 fn service_state_initializing_serde_roundtrip() {
1914 let state = ServiceState::Initializing;
1915 let json = serde_json::to_string(&state).unwrap();
1916 let de: ServiceState = serde_json::from_str(&json).unwrap();
1917 assert_eq!(de, ServiceState::Initializing);
1918 }
1919
1920 #[test]
1921 fn service_state_running_serde_roundtrip() {
1922 let state = ServiceState::Running;
1923 let json = serde_json::to_string(&state).unwrap();
1924 let de: ServiceState = serde_json::from_str(&json).unwrap();
1925 assert_eq!(de, ServiceState::Running);
1926 }
1927
1928 #[test]
1929 fn service_state_stopped_serde_roundtrip() {
1930 let state = ServiceState::Stopped;
1931 let json = serde_json::to_string(&state).unwrap();
1932 let de: ServiceState = serde_json::from_str(&json).unwrap();
1933 assert_eq!(de, ServiceState::Stopped);
1934 }
1935
1936 #[test]
1937 fn service_state_json_values_are_camel_case() {
1938 assert_eq!(
1939 serde_json::to_string(&ServiceState::Idle).unwrap(),
1940 "\"idle\""
1941 );
1942 assert_eq!(
1943 serde_json::to_string(&ServiceState::Initializing).unwrap(),
1944 "\"initializing\""
1945 );
1946 assert_eq!(
1947 serde_json::to_string(&ServiceState::Running).unwrap(),
1948 "\"running\""
1949 );
1950 assert_eq!(
1951 serde_json::to_string(&ServiceState::Stopped).unwrap(),
1952 "\"stopped\""
1953 );
1954 }
1955
1956 #[test]
1959 fn service_status_serde_roundtrip_idle() {
1960 let status = ServiceStatus {
1961 state: ServiceState::Idle,
1962 ..Default::default()
1963 };
1964 let json = serde_json::to_string(&status).unwrap();
1965 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
1966 assert_eq!(de.state, ServiceState::Idle);
1967 assert_eq!(de.last_error, None);
1968 }
1969
1970 #[test]
1971 fn service_status_serde_roundtrip_with_error() {
1972 let status = ServiceStatus {
1973 state: ServiceState::Stopped,
1974 last_error: Some("init failed".into()),
1975 ..Default::default()
1976 };
1977 let json = serde_json::to_string(&status).unwrap();
1978 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
1979 assert_eq!(de.state, ServiceState::Stopped);
1980 assert_eq!(de.last_error, Some("init failed".into()));
1981 }
1982
1983 #[test]
1984 fn service_status_json_keys_camel_case() {
1985 let status = ServiceStatus {
1986 state: ServiceState::Running,
1987 ..Default::default()
1988 };
1989 let json = serde_json::to_string(&status).unwrap();
1990 assert!(json.contains("\"state\":"), "state key: {json}");
1991 assert!(json.contains("\"lastError\":"), "lastError key: {json}");
1992 }
1993
1994 #[test]
1995 fn service_status_json_null_last_error() {
1996 let status = ServiceStatus {
1997 state: ServiceState::Idle,
1998 ..Default::default()
1999 };
2000 let json = serde_json::to_string(&status).unwrap();
2001 assert!(
2002 json.contains("\"lastError\":null"),
2003 "lastError should be null: {json}"
2004 );
2005 }
2006
2007 #[test]
2010 fn platform_serde_roundtrip() {
2011 for variant in [
2012 Platform::Android,
2013 Platform::Ios,
2014 Platform::Windows,
2015 Platform::Macos,
2016 Platform::Linux,
2017 Platform::Unknown,
2018 ] {
2019 let json = serde_json::to_string(&variant).unwrap();
2020 let de: Platform = serde_json::from_str(&json).unwrap();
2021 assert_eq!(de, variant);
2022 }
2023 }
2024
2025 #[test]
2026 fn platform_json_values_are_camel_case() {
2027 assert_eq!(
2028 serde_json::to_string(&Platform::Android).unwrap(),
2029 "\"android\""
2030 );
2031 assert_eq!(serde_json::to_string(&Platform::Ios).unwrap(), "\"ios\"");
2032 assert_eq!(
2033 serde_json::to_string(&Platform::Windows).unwrap(),
2034 "\"windows\""
2035 );
2036 assert_eq!(
2037 serde_json::to_string(&Platform::Macos).unwrap(),
2038 "\"macos\""
2039 );
2040 assert_eq!(
2041 serde_json::to_string(&Platform::Linux).unwrap(),
2042 "\"linux\""
2043 );
2044 assert_eq!(
2045 serde_json::to_string(&Platform::Unknown).unwrap(),
2046 "\"unknown\""
2047 );
2048 }
2049
2050 #[test]
2053 fn lifecycle_mode_serde_roundtrip() {
2054 for variant in [
2055 LifecycleMode::AndroidForegroundService,
2056 LifecycleMode::IosBgTaskScheduler,
2057 LifecycleMode::DesktopInProcess,
2058 LifecycleMode::DesktopOsService,
2059 ] {
2060 let json = serde_json::to_string(&variant).unwrap();
2061 let de: LifecycleMode = serde_json::from_str(&json).unwrap();
2062 assert_eq!(de, variant);
2063 }
2064 }
2065
2066 #[test]
2067 fn lifecycle_mode_json_values_are_camel_case() {
2068 assert_eq!(
2069 serde_json::to_string(&LifecycleMode::AndroidForegroundService).unwrap(),
2070 "\"androidForegroundService\""
2071 );
2072 assert_eq!(
2073 serde_json::to_string(&LifecycleMode::IosBgTaskScheduler).unwrap(),
2074 "\"iosBgTaskScheduler\""
2075 );
2076 assert_eq!(
2077 serde_json::to_string(&LifecycleMode::DesktopInProcess).unwrap(),
2078 "\"desktopInProcess\""
2079 );
2080 assert_eq!(
2081 serde_json::to_string(&LifecycleMode::DesktopOsService).unwrap(),
2082 "\"desktopOsService\""
2083 );
2084 }
2085
2086 #[test]
2089 fn lifecycle_guarantee_serde_roundtrip() {
2090 for variant in [
2091 LifecycleGuarantee::Guaranteed,
2092 LifecycleGuarantee::BestEffort,
2093 LifecycleGuarantee::Unsupported,
2094 ] {
2095 let json = serde_json::to_string(&variant).unwrap();
2096 let de: LifecycleGuarantee = serde_json::from_str(&json).unwrap();
2097 assert_eq!(de, variant);
2098 }
2099 }
2100
2101 #[test]
2102 fn lifecycle_guarantee_json_values_are_camel_case() {
2103 assert_eq!(
2104 serde_json::to_string(&LifecycleGuarantee::Guaranteed).unwrap(),
2105 "\"guaranteed\""
2106 );
2107 assert_eq!(
2108 serde_json::to_string(&LifecycleGuarantee::BestEffort).unwrap(),
2109 "\"bestEffort\""
2110 );
2111 assert_eq!(
2112 serde_json::to_string(&LifecycleGuarantee::Unsupported).unwrap(),
2113 "\"unsupported\""
2114 );
2115 }
2116
2117 #[test]
2120 fn platform_capabilities_serde_roundtrip() {
2121 let caps = PlatformCapabilities {
2122 platform: Platform::Android,
2123 lifecycle_mode: LifecycleMode::AndroidForegroundService,
2124 survives_app_close: LifecycleGuarantee::BestEffort,
2125 survives_reboot: LifecycleGuarantee::BestEffort,
2126 survives_force_quit: LifecycleGuarantee::Unsupported,
2127 background_execution: LifecycleGuarantee::Guaranteed,
2128 limitations: vec!["OEM battery optimization".into()],
2129 required_setup: vec!["FOREGROUND_SERVICE permission".into()],
2130 };
2131 let json = serde_json::to_string(&caps).unwrap();
2132 let de: PlatformCapabilities = serde_json::from_str(&json).unwrap();
2133 assert_eq!(de, caps);
2134 }
2135
2136 #[test]
2137 fn platform_capabilities_json_keys_camel_case() {
2138 let caps = PlatformCapabilities {
2139 platform: Platform::Linux,
2140 lifecycle_mode: LifecycleMode::DesktopInProcess,
2141 survives_app_close: LifecycleGuarantee::Unsupported,
2142 survives_reboot: LifecycleGuarantee::Unsupported,
2143 survives_force_quit: LifecycleGuarantee::Unsupported,
2144 background_execution: LifecycleGuarantee::Guaranteed,
2145 limitations: vec![],
2146 required_setup: vec![],
2147 };
2148 let json = serde_json::to_string(&caps).unwrap();
2149 assert!(json.contains("\"platform\":"), "platform: {json}");
2150 assert!(json.contains("\"lifecycleMode\":"), "lifecycleMode: {json}");
2151 assert!(
2152 json.contains("\"survivesAppClose\":"),
2153 "survivesAppClose: {json}"
2154 );
2155 assert!(
2156 json.contains("\"survivesReboot\":"),
2157 "survivesReboot: {json}"
2158 );
2159 assert!(
2160 json.contains("\"survivesForceQuit\":"),
2161 "survivesForceQuit: {json}"
2162 );
2163 assert!(
2164 json.contains("\"backgroundExecution\":"),
2165 "backgroundExecution: {json}"
2166 );
2167 assert!(json.contains("\"limitations\":"), "limitations: {json}");
2168 assert!(json.contains("\"requiredSetup\":"), "requiredSetup: {json}");
2169 }
2170
2171 #[test]
2172 fn platform_capabilities_empty_collections_serialize() {
2173 let caps = PlatformCapabilities {
2174 platform: Platform::Unknown,
2175 lifecycle_mode: LifecycleMode::DesktopInProcess,
2176 survives_app_close: LifecycleGuarantee::Unsupported,
2177 survives_reboot: LifecycleGuarantee::Unsupported,
2178 survives_force_quit: LifecycleGuarantee::Unsupported,
2179 background_execution: LifecycleGuarantee::Unsupported,
2180 limitations: vec![],
2181 required_setup: vec![],
2182 };
2183 let json = serde_json::to_string(&caps).unwrap();
2184 assert!(json.contains("\"limitations\":[]"), "{json}");
2185 assert!(json.contains("\"requiredSetup\":[]"), "{json}");
2186 }
2187
2188 #[test]
2191 fn native_state_serde_roundtrip() {
2192 for variant in [
2193 NativeState::Idle,
2194 NativeState::Starting,
2195 NativeState::Running,
2196 NativeState::Stopping,
2197 NativeState::Timeout,
2198 NativeState::Expired,
2199 NativeState::Recovering,
2200 NativeState::Error,
2201 ] {
2202 let json = serde_json::to_string(&variant).unwrap();
2203 let de: NativeState = serde_json::from_str(&json).unwrap();
2204 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
2205 }
2206 }
2207
2208 #[test]
2209 fn native_state_json_values_are_camel_case() {
2210 assert_eq!(
2211 serde_json::to_string(&NativeState::Idle).unwrap(),
2212 "\"idle\""
2213 );
2214 assert_eq!(
2215 serde_json::to_string(&NativeState::Starting).unwrap(),
2216 "\"starting\""
2217 );
2218 assert_eq!(
2219 serde_json::to_string(&NativeState::Running).unwrap(),
2220 "\"running\""
2221 );
2222 assert_eq!(
2223 serde_json::to_string(&NativeState::Stopping).unwrap(),
2224 "\"stopping\""
2225 );
2226 assert_eq!(
2227 serde_json::to_string(&NativeState::Timeout).unwrap(),
2228 "\"timeout\""
2229 );
2230 assert_eq!(
2231 serde_json::to_string(&NativeState::Expired).unwrap(),
2232 "\"expired\""
2233 );
2234 assert_eq!(
2235 serde_json::to_string(&NativeState::Recovering).unwrap(),
2236 "\"recovering\""
2237 );
2238 assert_eq!(
2239 serde_json::to_string(&NativeState::Error).unwrap(),
2240 "\"error\""
2241 );
2242 }
2243
2244 #[test]
2247 fn service_status_backward_compat_deserialize_old_json() {
2248 let old_json = r#"{"state":"running","lastError":null}"#;
2249 let status: ServiceStatus = serde_json::from_str(old_json).unwrap();
2250 assert_eq!(status.state, ServiceState::Running);
2251 assert_eq!(status.last_error, None);
2252 assert_eq!(status.desired_running, None);
2253 assert_eq!(status.native_state, None);
2254 assert_eq!(status.platform_mode, None);
2255 assert_eq!(status.last_start_config, None);
2256 assert_eq!(status.last_heartbeat_at, None);
2257 assert_eq!(status.restart_attempt, None);
2258 assert_eq!(status.recovery_reason, None);
2259 assert_eq!(status.platform_error, None);
2260 }
2261
2262 #[test]
2263 fn service_status_new_fields_serialize_when_present() {
2264 let status = ServiceStatus {
2265 state: ServiceState::Running,
2266 last_error: None,
2267 desired_running: Some(true),
2268 native_state: Some(NativeState::Running),
2269 platform_mode: Some(LifecycleMode::AndroidForegroundService),
2270 last_start_config: Some(StartConfig::default()),
2271 last_heartbeat_at: Some(1234567890),
2272 restart_attempt: Some(2),
2273 recovery_reason: Some("boot recovery".into()),
2274 platform_error: Some("timeout exceeded".into()),
2275 };
2276 let json = serde_json::to_string(&status).unwrap();
2277 assert!(json.contains("\"desiredRunning\":true"), "{json}");
2278 assert!(json.contains("\"nativeState\":\"running\""), "{json}");
2279 assert!(
2280 json.contains("\"platformMode\":\"androidForegroundService\""),
2281 "{json}"
2282 );
2283 assert!(json.contains("\"lastHeartbeatAt\":1234567890"), "{json}");
2284 assert!(json.contains("\"restartAttempt\":2"), "{json}");
2285 assert!(
2286 json.contains("\"recoveryReason\":\"boot recovery\""),
2287 "{json}"
2288 );
2289 assert!(
2290 json.contains("\"platformError\":\"timeout exceeded\""),
2291 "{json}"
2292 );
2293 }
2294
2295 #[test]
2296 fn service_status_new_fields_absent_when_none() {
2297 let status = ServiceStatus {
2298 state: ServiceState::Idle,
2299 last_error: None,
2300 desired_running: None,
2301 native_state: None,
2302 platform_mode: None,
2303 last_start_config: None,
2304 last_heartbeat_at: None,
2305 restart_attempt: None,
2306 recovery_reason: None,
2307 platform_error: None,
2308 };
2309 let json = serde_json::to_string(&status).unwrap();
2310 assert!(!json.contains("desiredRunning"), "should be absent: {json}");
2311 assert!(!json.contains("nativeState"), "should be absent: {json}");
2312 assert!(!json.contains("platformMode"), "should be absent: {json}");
2313 assert!(
2314 !json.contains("lastStartConfig"),
2315 "should be absent: {json}"
2316 );
2317 assert!(
2318 !json.contains("lastHeartbeatAt"),
2319 "should be absent: {json}"
2320 );
2321 assert!(!json.contains("restartAttempt"), "should be absent: {json}");
2322 assert!(!json.contains("recoveryReason"), "should be absent: {json}");
2323 assert!(!json.contains("platformError"), "should be absent: {json}");
2324 }
2325
2326 #[test]
2327 fn service_status_default_impl() {
2328 let status = ServiceStatus::default();
2329 assert_eq!(status.state, ServiceState::Idle);
2330 assert_eq!(status.last_error, None);
2331 assert_eq!(status.desired_running, None);
2332 assert_eq!(status.native_state, None);
2333 assert_eq!(status.platform_mode, None);
2334 assert_eq!(status.last_start_config, None);
2335 assert_eq!(status.last_heartbeat_at, None);
2336 assert_eq!(status.restart_attempt, None);
2337 assert_eq!(status.recovery_reason, None);
2338 assert_eq!(status.platform_error, None);
2339 }
2340
2341 #[test]
2342 fn service_status_full_roundtrip_with_all_fields() {
2343 let status = ServiceStatus {
2344 state: ServiceState::Running,
2345 last_error: Some("previous crash".into()),
2346 desired_running: Some(true),
2347 native_state: Some(NativeState::Recovering),
2348 platform_mode: Some(LifecycleMode::IosBgTaskScheduler),
2349 last_start_config: Some(StartConfig {
2350 service_label: "Sync".into(),
2351 foreground_service_type: "dataSync".into(),
2352 }),
2353 last_heartbeat_at: Some(999),
2354 restart_attempt: Some(3),
2355 recovery_reason: Some("force stop".into()),
2356 platform_error: Some("scheduler busy".into()),
2357 };
2358 let json = serde_json::to_string(&status).unwrap();
2359 let de: ServiceStatus = serde_json::from_str(&json).unwrap();
2360 assert_eq!(de.state, ServiceState::Running);
2361 assert_eq!(de.last_error, Some("previous crash".into()));
2362 assert_eq!(de.desired_running, Some(true));
2363 assert_eq!(de.native_state, Some(NativeState::Recovering));
2364 assert_eq!(de.platform_mode, Some(LifecycleMode::IosBgTaskScheduler));
2365 assert!(de.last_start_config.is_some());
2366 assert_eq!(de.last_heartbeat_at, Some(999));
2367 assert_eq!(de.restart_attempt, Some(3));
2368 assert_eq!(de.recovery_reason, Some("force stop".into()));
2369 assert_eq!(de.platform_error, Some("scheduler busy".into()));
2370 }
2371
2372 #[test]
2373 fn platform_capabilities_deserialize_from_json() {
2374 let json = r#"{
2375 "platform":"ios",
2376 "lifecycleMode":"iosBgTaskScheduler",
2377 "survivesAppClose":"bestEffort",
2378 "survivesReboot":"bestEffort",
2379 "survivesForceQuit":"unsupported",
2380 "backgroundExecution":"bestEffort",
2381 "limitations":["Cannot guarantee continuous execution"],
2382 "requiredSetup":["UIBackgroundModes in Info.plist"]
2383 }"#;
2384 let caps: PlatformCapabilities = serde_json::from_str(json).unwrap();
2385 assert_eq!(caps.platform, Platform::Ios);
2386 assert_eq!(caps.lifecycle_mode, LifecycleMode::IosBgTaskScheduler);
2387 assert_eq!(caps.survives_app_close, LifecycleGuarantee::BestEffort);
2388 assert_eq!(caps.background_execution, LifecycleGuarantee::BestEffort);
2389 assert_eq!(caps.limitations.len(), 1);
2390 assert_eq!(caps.required_setup.len(), 1);
2391 }
2392
2393 #[test]
2396 fn ios_scheduling_status_both_scheduled() {
2397 let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
2398 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2399 assert!(status.refresh_scheduled);
2400 assert!(status.processing_scheduled);
2401 assert_eq!(status.refresh_error, None);
2402 assert_eq!(status.processing_error, None);
2403 }
2404
2405 #[test]
2406 fn ios_scheduling_status_partial_success() {
2407 let json = r#"{"refreshScheduled":true,"processingScheduled":false,"processingError":"not permitted"}"#;
2408 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2409 assert!(status.refresh_scheduled);
2410 assert!(!status.processing_scheduled);
2411 assert_eq!(status.refresh_error, None);
2412 assert_eq!(status.processing_error, Some("not permitted".to_string()));
2413 }
2414
2415 #[test]
2416 fn ios_scheduling_status_with_errors() {
2417 let json = r#"{"refreshScheduled":false,"processingScheduled":false,"refreshError":"err1","processingError":"err2"}"#;
2418 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2419 assert!(!status.refresh_scheduled);
2420 assert!(!status.processing_scheduled);
2421 assert_eq!(status.refresh_error, Some("err1".to_string()));
2422 assert_eq!(status.processing_error, Some("err2".to_string()));
2423 }
2424
2425 #[test]
2426 fn ios_scheduling_status_serde_roundtrip() {
2427 let status = IOSSchedulingStatus {
2428 refresh_scheduled: true,
2429 processing_scheduled: false,
2430 refresh_error: None,
2431 processing_error: Some("busy".into()),
2432 };
2433 let json = serde_json::to_string(&status).unwrap();
2434 let de: IOSSchedulingStatus = serde_json::from_str(&json).unwrap();
2435 assert_eq!(de, status);
2436 }
2437
2438 #[test]
2439 fn ios_scheduling_status_json_keys_camel_case() {
2440 let status = IOSSchedulingStatus {
2441 refresh_scheduled: true,
2442 processing_scheduled: true,
2443 refresh_error: Some("err".into()),
2444 processing_error: None,
2445 };
2446 let json = serde_json::to_string(&status).unwrap();
2447 assert!(json.contains("\"refreshScheduled\":"), "{json}");
2448 assert!(json.contains("\"processingScheduled\":"), "{json}");
2449 assert!(json.contains("\"refreshError\":"), "{json}");
2450 assert!(
2451 !json.contains("processingError"),
2452 "None fields should be absent: {json}"
2453 );
2454 }
2455
2456 #[test]
2457 fn ios_scheduling_status_from_value_null_errors() {
2458 let json = r#"{"refreshScheduled":true,"processingScheduled":true,"refreshError":null,"processingError":null}"#;
2460 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2461 assert!(status.refresh_scheduled);
2462 assert!(status.processing_scheduled);
2463 assert_eq!(status.refresh_error, None);
2464 assert_eq!(status.processing_error, None);
2465 }
2466
2467 #[test]
2468 fn ios_scheduling_status_from_value_missing_errors() {
2469 let json = r#"{"refreshScheduled":true,"processingScheduled":true}"#;
2471 let status: IOSSchedulingStatus = serde_json::from_str(json).unwrap();
2472 assert!(status.refresh_scheduled);
2473 assert!(status.processing_scheduled);
2474 assert_eq!(status.refresh_error, None);
2475 assert_eq!(status.processing_error, None);
2476 }
2477
2478 #[test]
2481 fn os_service_install_state_serde_roundtrip() {
2482 for variant in [
2483 OsServiceInstallState::NotInstalled,
2484 OsServiceInstallState::Installed,
2485 OsServiceInstallState::Running,
2486 ] {
2487 let json = serde_json::to_string(&variant).unwrap();
2488 let de: OsServiceInstallState = serde_json::from_str(&json).unwrap();
2489 assert_eq!(de, variant, "roundtrip failed for {variant:?}");
2490 }
2491 }
2492
2493 #[test]
2494 fn os_service_install_state_json_values_camel_case() {
2495 assert_eq!(
2496 serde_json::to_string(&OsServiceInstallState::NotInstalled).unwrap(),
2497 "\"notInstalled\""
2498 );
2499 assert_eq!(
2500 serde_json::to_string(&OsServiceInstallState::Installed).unwrap(),
2501 "\"installed\""
2502 );
2503 assert_eq!(
2504 serde_json::to_string(&OsServiceInstallState::Running).unwrap(),
2505 "\"running\""
2506 );
2507 }
2508
2509 #[test]
2512 fn os_service_status_serde_roundtrip() {
2513 let status = OsServiceStatus {
2514 label: "com.example.bg-service".into(),
2515 mode: "systemd".into(),
2516 installed: OsServiceInstallState::Running,
2517 ipc_connected: true,
2518 socket_path: Some("/tmp/test.sock".into()),
2519 last_error: None,
2520 };
2521 let json = serde_json::to_string(&status).unwrap();
2522 let de: OsServiceStatus = serde_json::from_str(&json).unwrap();
2523 assert_eq!(de.label, "com.example.bg-service");
2524 assert_eq!(de.mode, "systemd");
2525 assert_eq!(de.installed, OsServiceInstallState::Running);
2526 assert!(de.ipc_connected);
2527 assert_eq!(de.socket_path, Some("/tmp/test.sock".into()));
2528 assert_eq!(de.last_error, None);
2529 }
2530
2531 #[test]
2532 fn os_service_status_json_keys_camel_case() {
2533 let status = OsServiceStatus {
2534 label: "test".into(),
2535 mode: "launchd".into(),
2536 installed: OsServiceInstallState::Installed,
2537 ipc_connected: false,
2538 socket_path: Some("/run/test.sock".into()),
2539 last_error: Some("timeout".into()),
2540 };
2541 let json = serde_json::to_string(&status).unwrap();
2542 assert!(json.contains("\"label\":"), "{json}");
2543 assert!(json.contains("\"mode\":"), "{json}");
2544 assert!(json.contains("\"installed\":"), "{json}");
2545 assert!(json.contains("\"ipcConnected\":"), "{json}");
2546 assert!(json.contains("\"socketPath\":"), "{json}");
2547 assert!(json.contains("\"lastError\":"), "{json}");
2548 }
2549
2550 #[test]
2551 fn os_service_status_optional_fields_absent_when_none() {
2552 let status = OsServiceStatus {
2553 label: "test".into(),
2554 mode: "systemd".into(),
2555 installed: OsServiceInstallState::NotInstalled,
2556 ipc_connected: false,
2557 socket_path: None,
2558 last_error: None,
2559 };
2560 let json = serde_json::to_string(&status).unwrap();
2561 assert!(!json.contains("socketPath"), "should be absent: {json}");
2562 assert!(!json.contains("lastError"), "should be absent: {json}");
2563 }
2564
2565 #[test]
2566 fn os_service_status_with_all_optional_fields() {
2567 let status = OsServiceStatus {
2568 label: "com.test".into(),
2569 mode: "launchd".into(),
2570 installed: OsServiceInstallState::Running,
2571 ipc_connected: true,
2572 socket_path: Some("/var/run/com.test.sock".into()),
2573 last_error: Some("connection refused".into()),
2574 };
2575 let json = serde_json::to_string(&status).unwrap();
2576 assert!(
2577 json.contains("\"socketPath\":\"/var/run/com.test.sock\""),
2578 "{json}"
2579 );
2580 assert!(
2581 json.contains("\"lastError\":\"connection refused\""),
2582 "{json}"
2583 );
2584 }
2585
2586 #[test]
2587 fn os_service_status_deserialize_from_json() {
2588 let json = r#"{
2589 "label":"com.example.svc",
2590 "mode":"systemd",
2591 "installed":"running",
2592 "ipcConnected":true,
2593 "socketPath":"/tmp/test.sock"
2594 }"#;
2595 let status: OsServiceStatus = serde_json::from_str(json).unwrap();
2596 assert_eq!(status.label, "com.example.svc");
2597 assert_eq!(status.mode, "systemd");
2598 assert_eq!(status.installed, OsServiceInstallState::Running);
2599 assert!(status.ipc_connected);
2600 assert_eq!(status.socket_path, Some("/tmp/test.sock".into()));
2601 assert_eq!(status.last_error, None);
2602 }
2603
2604 #[test]
2607 fn pending_task_info_serde_roundtrip() {
2608 let info = PendingTaskInfo {
2609 task_kind: "refresh".into(),
2610 identifier: "com.example.app.bg-refresh".into(),
2611 received_at: 1700000000.123,
2612 };
2613 let json = serde_json::to_string(&info).unwrap();
2614 let de: PendingTaskInfo = serde_json::from_str(&json).unwrap();
2615 assert_eq!(de, info);
2616 }
2617
2618 #[test]
2619 fn pending_task_info_json_keys_camel_case() {
2620 let info = PendingTaskInfo {
2621 task_kind: "processing".into(),
2622 identifier: "test-id".into(),
2623 received_at: 123456.0,
2624 };
2625 let json = serde_json::to_string(&info).unwrap();
2626 assert!(json.contains("\"taskKind\":"), "{json}");
2627 assert!(json.contains("\"identifier\":"), "{json}");
2628 assert!(json.contains("\"receivedAt\":"), "{json}");
2629 }
2630
2631 #[test]
2632 fn pending_task_info_from_native_response() {
2633 let json = r#"{"taskKind":"refresh","identifier":"com.example.bg-refresh","receivedAt":1700000000.456}"#;
2635 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
2636 assert_eq!(info.task_kind, "refresh");
2637 assert_eq!(info.identifier, "com.example.bg-refresh");
2638 assert!((info.received_at - 1700000000.456).abs() < f64::EPSILON);
2639 }
2640
2641 #[test]
2642 fn pending_task_info_processing_kind() {
2643 let json = r#"{"taskKind":"processing","identifier":"com.example.bg-processing","receivedAt":1700000000.0}"#;
2644 let info: PendingTaskInfo = serde_json::from_str(json).unwrap();
2645 assert_eq!(info.task_kind, "processing");
2646 assert_eq!(info.identifier, "com.example.bg-processing");
2647 }
2648}