diff --git a/CHANGELOG.md b/CHANGELOG.md index 3baa42f..e328d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- New `generic_alert` APNs payload mode: a visible alert whose title and body come from the new `apns.alert_title` / `apns.alert_body` configuration keys (content-free defaults), with no `mutable-content` flag, so deployments can show a notification without shipping a Notification Service Extension. An optional `apns.collapse_id` key sets the `apns-collapse-id` header (any payload mode) so rapid bursts coalesce into a single notification. - Optional error and panic reporting to a GlitchTip (Sentry-compatible) instance, enabled by setting `glitchtip.dsn` (or `TRANSPONDER_GLITCHTIP_DSN`). Only Transponder's own `ERROR` events and panics are sent, over a self-contained TLS transport (bundled roots, no system CA store dependency); dependency-crate errors and lower log levels are dropped, and Transponder never logs or panics with secret material. ### Fixed diff --git a/README.md b/README.md index b292d6d..32031a7 100644 --- a/README.md +++ b/README.md @@ -141,10 +141,21 @@ environment = "production" # Your iOS app's bundle identifier (e.g., "com.example.myapp") bundle_id = "" -# APNs payload mode: "silent" or "nse_prototype_alert" -# Keep production silent. Use nse_prototype_alert only for staging NSE prototype testing. +# APNs payload mode: "silent", "generic_alert", or "nse_prototype_alert" +# - silent: background push only (invisible without an NSE or foreground work) +# - generic_alert: visible alert with the configurable, content-free +# alert_title/alert_body below; requires no Notification Service Extension +# - nse_prototype_alert: staging NSE prototype testing only payload_mode = "silent" +# Alert copy for the generic_alert payload mode (keep it content-free) +alert_title = "New activity" +alert_body = "You have a new notification" + +# Optional apns-collapse-id header value (max 64 bytes); empty disables it. +# When set, APNs coalesces undelivered notifications with the same id. +collapse_id = "" + [fcm] # Enable FCM for Android push notifications enabled = false diff --git a/config/default.toml b/config/default.toml index 40b7f8e..c742be9 100644 --- a/config/default.toml +++ b/config/default.toml @@ -153,10 +153,25 @@ environment = "production" # iOS app bundle identifier bundle_id = "" -# APNs payload mode: "silent" or "nse_prototype_alert" -# Keep production silent. Use nse_prototype_alert only for staging NSE prototype testing. +# APNs payload mode: "silent", "generic_alert", or "nse_prototype_alert" +# - silent: background push only; invisible without a Notification Service +# Extension or foreground app work. +# - generic_alert: visible alert with the configurable, content-free +# alert_title/alert_body below; requires no Notification Service Extension. +# - nse_prototype_alert: staging NSE prototype testing only. payload_mode = "silent" +# Alert copy for the generic_alert payload mode. Keep these content-free: the +# transponder never knows message content or sender identity, and the alert +# must not pretend otherwise. +alert_title = "New activity" +alert_body = "You have a new notification" + +# Optional apns-collapse-id header value (max 64 bytes); empty disables it. +# When set, APNs coalesces undelivered notifications with the same id, so a +# rapid burst surfaces as one notification instead of a stack. +collapse_id = "" + [fcm] # Whether FCM (Android) push notifications are enabled enabled = false diff --git a/config/production.toml.example b/config/production.toml.example index 9a709fc..71b7c57 100644 --- a/config/production.toml.example +++ b/config/production.toml.example @@ -72,7 +72,14 @@ team_id = "" private_key_path = "/credentials/AuthKey_XXXXXXXXXX.p8" environment = "production" bundle_id = "" +# "silent", "generic_alert" (visible content-free alert, no NSE required), +# or "nse_prototype_alert" (staging NSE prototype testing only). payload_mode = "silent" +# Alert copy for the generic_alert payload mode (keep it content-free). +alert_title = "New activity" +alert_body = "You have a new notification" +# Optional apns-collapse-id header value (max 64 bytes); empty disables it. +collapse_id = "" [fcm] enabled = false diff --git a/src/config.rs b/src/config.rs index cfc7152..fc08bc6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -378,6 +378,10 @@ fn default_max_reconnect_attempts() -> u32 { 10 } +/// Maximum length in bytes APNs accepts for the `apns-collapse-id` header; +/// longer values are rejected per-send with a 400 `BadCollapseId`. +const APNS_COLLAPSE_ID_MAX_BYTES: usize = 64; + /// APNs push notification configuration. #[derive(Debug, Clone, Deserialize)] #[allow(dead_code)] @@ -409,6 +413,22 @@ pub struct ApnsConfig { /// APNs payload mode. #[serde(default)] pub payload_mode: ApnsPayloadMode, + + /// Alert title for the `generic_alert` payload mode. + #[serde(default = "default_apns_alert_title")] + pub alert_title: String, + + /// Alert body for the `generic_alert` payload mode. + #[serde(default = "default_apns_alert_body")] + pub alert_body: String, + + /// Optional `apns-collapse-id` header value; empty disables the header. + /// + /// When set, APNs coalesces undelivered notifications carrying the same + /// collapse identifier, so a rapid burst surfaces as a single notification + /// instead of a stack. + #[serde(default)] + pub collapse_id: String, } /// APNs gateway environment. @@ -448,20 +468,26 @@ pub enum ApnsPayloadMode { /// Alert payload for exercising the iOS Notification Service Extension prototype. NsePrototypeAlert, + + /// Visible alert with configurable, content-free title and body + /// (`apns.alert_title` / `apns.alert_body`). Requires no Notification + /// Service Extension: the payload carries no `mutable-content` flag and + /// never reveals message content or sender identity. + GenericAlert, } impl ApnsPayloadMode { pub(crate) fn push_type(self) -> &'static str { match self { Self::Silent => "background", - Self::NsePrototypeAlert => "alert", + Self::NsePrototypeAlert | Self::GenericAlert => "alert", } } pub(crate) fn priority(self) -> &'static str { match self { Self::Silent => "5", - Self::NsePrototypeAlert => "10", + Self::NsePrototypeAlert | Self::GenericAlert => "10", } } } @@ -471,6 +497,7 @@ impl std::fmt::Display for ApnsPayloadMode { match self { Self::Silent => f.write_str("silent"), Self::NsePrototypeAlert => f.write_str("nse_prototype_alert"), + Self::GenericAlert => f.write_str("generic_alert"), } } } @@ -479,6 +506,14 @@ fn default_apns_environment() -> ApnsEnvironment { ApnsEnvironment::Production } +fn default_apns_alert_title() -> String { + "New activity".to_string() +} + +fn default_apns_alert_body() -> String { + "You have a new notification".to_string() +} + /// FCM push notification configuration. #[derive(Debug, Clone, Deserialize)] pub struct FcmConfig { @@ -897,6 +932,9 @@ fn is_supported_config_key(config_key: &str) -> bool { | "apns.environment" | "apns.bundle_id" | "apns.payload_mode" + | "apns.alert_title" + | "apns.alert_body" + | "apns.collapse_id" | "fcm.enabled" | "fcm.service_account_path" | "fcm.project_id" @@ -1225,7 +1263,9 @@ impl GlitchtipConfig { } impl ApnsConfig { - /// Rejects enabled APNs configuration that is missing required credentials. + /// Rejects enabled APNs configuration that is missing required credentials, + /// a `generic_alert` mode with nothing to display, or an over-length + /// collapse identifier. fn validate(&self) -> std::result::Result<(), config::ConfigError> { if !self.enabled { return Ok(()); @@ -1246,6 +1286,26 @@ impl ApnsConfig { } } + // A generic alert with neither a title nor a body renders nothing + // visible on the device, defeating the purpose of the mode. + if self.payload_mode == ApnsPayloadMode::GenericAlert + && self.alert_title.trim().is_empty() + && self.alert_body.trim().is_empty() + { + return Err(config::ConfigError::Message( + "apns.alert_title or apns.alert_body must be set when apns.payload_mode = \"generic_alert\"".to_string(), + )); + } + + // APNs rejects `apns-collapse-id` values over 64 bytes with a 400 + // BadCollapseId on every send; fail at startup instead. + if self.collapse_id.len() > APNS_COLLAPSE_ID_MAX_BYTES { + return Err(config::ConfigError::Message(format!( + "apns.collapse_id must be at most {APNS_COLLAPSE_ID_MAX_BYTES} bytes, got {}", + self.collapse_id.len() + ))); + } + Ok(()) } @@ -1426,6 +1486,9 @@ mod tests { environment: ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; assert!(config.is_production()); @@ -1442,6 +1505,9 @@ mod tests { environment: ApnsEnvironment::Sandbox, bundle_id: String::new(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; assert!(!config.is_production()); @@ -1963,6 +2029,8 @@ mod tests { #[test] fn test_apns_config_defaults() { assert_eq!(default_apns_environment(), ApnsEnvironment::Production); + assert_eq!(default_apns_alert_title(), "New activity"); + assert_eq!(default_apns_alert_body(), "You have a new notification"); } #[test] @@ -2005,6 +2073,85 @@ mod tests { assert!(error.to_string().contains("loud_plaintext"), "{error}"); } + #[test] + fn test_apns_payload_mode_parses_generic_alert_with_default_copy() { + let config_content = r#" + [server] + private_key = "test" + + [apns] + payload_mode = "generic_alert" + "#; + + let file = create_temp_config(config_content); + let config = load_with_test_env(file.path(), &[]).unwrap(); + + assert_eq!(config.apns.payload_mode, ApnsPayloadMode::GenericAlert); + assert_eq!(config.apns.alert_title, "New activity"); + assert_eq!(config.apns.alert_body, "You have a new notification"); + assert_eq!(config.apns.collapse_id, ""); + } + + #[test] + fn test_apns_generic_alert_custom_copy_and_collapse_id() { + let config_content = r#" + [server] + private_key = "test" + + [apns] + payload_mode = "generic_alert" + alert_title = "Ping" + alert_body = "Something happened" + collapse_id = "ping" + "#; + + let file = create_temp_config(config_content); + let config = load_with_test_env(file.path(), &[]).unwrap(); + + assert_eq!(config.apns.alert_title, "Ping"); + assert_eq!(config.apns.alert_body, "Something happened"); + assert_eq!(config.apns.collapse_id, "ping"); + } + + #[test] + fn test_apns_generic_alert_without_title_or_body_rejected() { + // A generic alert with neither a title nor a body renders nothing + // visible, so an explicit double-blank is a startup error. + let error = from_test_env(&[ + ("TRANSPONDER_APNS_ENABLED", "true"), + ("TRANSPONDER_APNS_KEY_ID", "KEY123"), + ("TRANSPONDER_APNS_TEAM_ID", "TEAM123"), + ("TRANSPONDER_APNS_PRIVATE_KEY_PATH", "/keys/apns.p8"), + ("TRANSPONDER_APNS_BUNDLE_ID", "com.example.app"), + ("TRANSPONDER_APNS_PAYLOAD_MODE", "generic_alert"), + ("TRANSPONDER_APNS_ALERT_TITLE", ""), + ("TRANSPONDER_APNS_ALERT_BODY", " "), + ]) + .unwrap_err(); + + let message = error.to_string(); + assert!(message.contains("apns.alert_title"), "{message}"); + assert!(message.contains("generic_alert"), "{message}"); + } + + #[test] + fn test_apns_collapse_id_over_max_length_rejected() { + // APNs rejects over-length collapse ids per-send (400 BadCollapseId); + // the misconfiguration must fail loudly at startup instead. + let long_collapse_id = "x".repeat(APNS_COLLAPSE_ID_MAX_BYTES + 1); + let error = from_test_env(&[ + ("TRANSPONDER_APNS_ENABLED", "true"), + ("TRANSPONDER_APNS_KEY_ID", "KEY123"), + ("TRANSPONDER_APNS_TEAM_ID", "TEAM123"), + ("TRANSPONDER_APNS_PRIVATE_KEY_PATH", "/keys/apns.p8"), + ("TRANSPONDER_APNS_BUNDLE_ID", "com.example.app"), + ("TRANSPONDER_APNS_COLLAPSE_ID", long_collapse_id.as_str()), + ]) + .unwrap_err(); + + assert!(error.to_string().contains("apns.collapse_id"), "{error}"); + } + #[test] fn test_apns_environment_display() { assert_eq!(ApnsEnvironment::Production.to_string(), "production"); @@ -2204,6 +2351,9 @@ mod tests { environment: ApnsEnvironment::Production, bundle_id: String::new(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; assert!(config.is_production()); } @@ -2218,6 +2368,9 @@ mod tests { environment: ApnsEnvironment::Sandbox, bundle_id: String::new(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; assert!(!config.is_production()); } diff --git a/src/nostr/events/processor.rs b/src/nostr/events/processor.rs index 426ce9f..8eb1bed 100644 --- a/src/nostr/events/processor.rs +++ b/src/nostr/events/processor.rs @@ -1224,6 +1224,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let metrics = Metrics::new().expect("metrics"); let push_dispatcher = Arc::new(PushDispatcher::with_metrics( @@ -1267,6 +1270,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let metrics = Metrics::new().expect("metrics"); let push_dispatcher = Arc::new(PushDispatcher::with_metrics( @@ -2526,6 +2532,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let push_dispatcher = Arc::new(PushDispatcher::with_metrics( Some(ApnsClient::mock(apns_config, true)), diff --git a/src/push/apns.rs b/src/push/apns.rs index cb166fe..0318b33 100644 --- a/src/push/apns.rs +++ b/src/push/apns.rs @@ -39,6 +39,7 @@ struct ApnsClaims { enum ApnsPayload { Silent(ApnsSilentPayload), NsePrototypeAlert(ApnsNsePrototypeAlertPayload), + GenericAlert(ApnsGenericAlertPayload), } #[derive(Debug, Serialize)] @@ -62,13 +63,22 @@ impl Default for ApnsPayload { } } -impl From for ApnsPayload { - fn from(mode: ApnsPayloadMode) -> Self { - match mode { +impl ApnsPayload { + fn for_config(config: &ApnsConfig) -> Self { + match config.payload_mode { ApnsPayloadMode::Silent => Self::default(), ApnsPayloadMode::NsePrototypeAlert => { Self::NsePrototypeAlert(ApnsNsePrototypeAlertPayload::default()) } + ApnsPayloadMode::GenericAlert => Self::GenericAlert(ApnsGenericAlertPayload { + aps: ApnsGenericAlertAps { + alert: ApnsGenericAlert { + title: config.alert_title.clone(), + body: config.alert_body.clone(), + }, + sound: "default", + }, + }), } } } @@ -115,6 +125,31 @@ struct ApnsAlert { body: &'static str, } +/// Payload for [`ApnsPayloadMode::GenericAlert`]: a visible alert carrying +/// only the operator-configured title/body — no message content, no sender +/// identity, and no `mutable-content` flag (no Notification Service Extension +/// is required to display it). +#[derive(Debug, Serialize)] +struct ApnsGenericAlertPayload { + aps: ApnsGenericAlertAps, +} + +#[derive(Debug, Serialize)] +struct ApnsGenericAlertAps { + alert: ApnsGenericAlert, + sound: &'static str, +} + +#[derive(Debug, Serialize)] +struct ApnsGenericAlert { + /// Omitted from the payload when configured empty (body-only alert). + #[serde(skip_serializing_if = "String::is_empty")] + title: String, + /// Omitted from the payload when configured empty (title-only alert). + #[serde(skip_serializing_if = "String::is_empty")] + body: String, +} + fn redacted_device_token_id(_device_token: &str) -> &'static str { "" } @@ -127,15 +162,19 @@ fn device_token_url(base_url: &str, device_token: &str) -> Zeroizing { struct ApnsRequestParts { push_type: &'static str, priority: &'static str, + /// `apns-collapse-id` header value; `None` leaves the header off. + collapse_id: Option, payload: ApnsPayload, } impl ApnsRequestParts { - fn for_mode(mode: ApnsPayloadMode) -> Self { + fn for_config(config: &ApnsConfig) -> Self { + let mode = config.payload_mode; Self { push_type: mode.push_type(), priority: mode.priority(), - payload: ApnsPayload::from(mode), + collapse_id: (!config.collapse_id.is_empty()).then(|| config.collapse_id.clone()), + payload: ApnsPayload::for_config(config), } } } @@ -364,7 +403,7 @@ impl ApnsClient { // device-token URL is a zeroizing buffer (issue #126); reqwest still // materializes the serialized body/headers into non-zeroized buffers // it owns, which is the accepted #126 posture (see build_request). - let request_parts = ApnsRequestParts::for_mode(self.config.payload_mode); + let request_parts = ApnsRequestParts::for_config(&self.config); let url = device_token_url(self.base_url(), device_token.as_str()); debug!( @@ -411,13 +450,17 @@ impl ApnsClient { request_parts: &ApnsRequestParts, ) -> reqwest::RequestBuilder { let authorization = Zeroizing::new(format!("bearer {auth_token}")); - self.http_client + let mut request = self + .http_client .post(url) .header("apns-push-type", request_parts.push_type) .header("apns-priority", request_parts.priority) .header("apns-topic", &self.config.bundle_id) - .header("authorization", authorization.as_str()) - .json(&request_parts.payload) + .header("authorization", authorization.as_str()); + if let Some(collapse_id) = request_parts.collapse_id.as_deref() { + request = request.header("apns-collapse-id", collapse_id); + } + request.json(&request_parts.payload) } async fn handle_response( @@ -705,6 +748,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), } } @@ -760,7 +806,7 @@ mod tests { config.payload_mode = ApnsPayloadMode::Silent; config.bundle_id = "dev.ipf.whitenoise.staging".to_string(); let client = ApnsClient::mock(config, false); - let request_parts = ApnsRequestParts::for_mode(client.config.payload_mode); + let request_parts = ApnsRequestParts::for_config(&client.config); let request = client .build_request( @@ -793,7 +839,7 @@ mod tests { config.payload_mode = ApnsPayloadMode::NsePrototypeAlert; config.bundle_id = "dev.ipf.whitenoise.staging".to_string(); let client = ApnsClient::mock(config, false); - let request_parts = ApnsRequestParts::for_mode(client.config.payload_mode); + let request_parts = ApnsRequestParts::for_config(&client.config); let request = client .build_request( @@ -826,6 +872,117 @@ mod tests { ); } + #[test] + fn test_generic_alert_mode_builds_alert_headers_and_payload() { + let mut config = test_config(); + config.payload_mode = ApnsPayloadMode::GenericAlert; + config.alert_title = "New activity".to_string(); + config.alert_body = "You have a new notification".to_string(); + let client = ApnsClient::mock(config, false); + let request_parts = ApnsRequestParts::for_config(&client.config); + + let request = client + .build_request( + "https://api.push.apple.com/3/device/aabbccdd11223344", + "test-token", + &request_parts, + ) + .build() + .unwrap(); + + assert_eq!(header_value(&request, "apns-push-type"), "alert"); + assert_eq!(header_value(&request, "apns-priority"), "10"); + assert_eq!(header_value(&request, "apns-topic"), "com.example.app"); + // No collapse id configured: the header must be absent entirely. + assert!(request.headers().get("apns-collapse-id").is_none()); + // The exact payload: configured title/body only — in particular no + // `mutable-content` (no NSE required) and no prototype marker field. + assert_eq!( + request_json(&request), + serde_json::json!({ + "aps": { + "alert": { + "title": "New activity", + "body": "You have a new notification" + }, + "sound": "default" + } + }) + ); + } + + #[test] + fn test_generic_alert_mode_omits_empty_body_from_alert() { + let mut config = test_config(); + config.payload_mode = ApnsPayloadMode::GenericAlert; + config.alert_title = "New activity".to_string(); + config.alert_body = String::new(); + let client = ApnsClient::mock(config, false); + let request_parts = ApnsRequestParts::for_config(&client.config); + + let request = client + .build_request( + "https://api.push.apple.com/3/device/aabbccdd11223344", + "test-token", + &request_parts, + ) + .build() + .unwrap(); + + // A title-only alert must not carry an empty "body" key. + assert_eq!( + request_json(&request), + serde_json::json!({ + "aps": { + "alert": { + "title": "New activity" + }, + "sound": "default" + } + }) + ); + } + + #[test] + fn test_generic_alert_mode_sets_collapse_id_header_when_configured() { + let mut config = test_config(); + config.payload_mode = ApnsPayloadMode::GenericAlert; + config.collapse_id = "new-activity".to_string(); + let client = ApnsClient::mock(config, false); + let request_parts = ApnsRequestParts::for_config(&client.config); + + let request = client + .build_request( + "https://api.push.apple.com/3/device/aabbccdd11223344", + "test-token", + &request_parts, + ) + .build() + .unwrap(); + + assert_eq!(header_value(&request, "apns-collapse-id"), "new-activity"); + } + + #[test] + fn test_collapse_id_header_applies_to_any_payload_mode() { + let mut config = test_config(); + config.payload_mode = ApnsPayloadMode::Silent; + config.collapse_id = "sync".to_string(); + let client = ApnsClient::mock(config, false); + let request_parts = ApnsRequestParts::for_config(&client.config); + + let request = client + .build_request( + "https://api.push.apple.com/3/device/aabbccdd11223344", + "test-token", + &request_parts, + ) + .build() + .unwrap(); + + assert_eq!(header_value(&request, "apns-collapse-id"), "sync"); + } + #[tokio::test] async fn test_send_once_returns_transport_error_after_connect_retries() { let proxy_addr = { @@ -853,7 +1010,7 @@ mod tests { initial_backoff: Duration::from_millis(1), }; - let request_parts = ApnsRequestParts::for_mode(client.config.payload_mode); + let request_parts = ApnsRequestParts::for_config(&client.config); let url = device_token_url(client.config.base_url(), "aabbccdd11223344"); let result = client .send_once_with_transport_retry_config(url.as_str(), &request_parts, &retry_config) @@ -880,6 +1037,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: String::new(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::new(config).await.unwrap(); @@ -1557,6 +1717,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, true); @@ -1573,6 +1736,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, true); @@ -1589,6 +1755,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, true); @@ -1605,6 +1774,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: String::new(), // Missing payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, true); @@ -1621,6 +1793,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, false); // No encoding key @@ -1669,6 +1844,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, false); @@ -1688,6 +1866,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, true); @@ -1715,6 +1896,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; // Create a client without an encoding key to test the error case @@ -1740,6 +1924,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::mock(config, false); // No encoding key @@ -1767,6 +1954,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::new(config).await.unwrap(); @@ -1783,6 +1973,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let result = ApnsClient::new(config).await; @@ -1860,6 +2053,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; // Client without encoding key - should fail when trying to generate @@ -1895,6 +2091,9 @@ OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::new(config).await.unwrap(); @@ -1926,6 +2125,9 @@ OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let metrics = Metrics::new().unwrap(); @@ -2041,6 +2243,9 @@ OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r environment: crate::config::ApnsEnvironment::Production, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let client = ApnsClient::new(config).await.unwrap(); diff --git a/src/push/dispatcher/mod.rs b/src/push/dispatcher/mod.rs index 2170fdc..19213fd 100644 --- a/src/push/dispatcher/mod.rs +++ b/src/push/dispatcher/mod.rs @@ -1063,6 +1063,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_only = PushDispatcher::new(Some(ApnsClient::mock(apns_config, true)), None); assert!(apns_only.accepts(Platform::Apns)); @@ -1131,6 +1134,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(config, true); @@ -1198,6 +1204,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, true); let dispatcher = PushDispatcher::new(Some(apns_client), None); @@ -1249,6 +1258,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, true); @@ -1320,6 +1332,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let metrics = Metrics::new().unwrap(); @@ -1364,6 +1379,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, true); @@ -1395,6 +1413,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: String::new(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, false); let dispatcher = PushDispatcher::new(Some(apns_client), None); @@ -1445,6 +1466,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }, true, )), @@ -1604,6 +1628,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }, true, )), @@ -1643,6 +1670,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }, true, )), @@ -1700,6 +1730,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, true); let dispatcher = PushDispatcher::new(Some(apns_client), None); @@ -1722,6 +1755,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let dispatcher = Arc::new(PushDispatcher::new( Some(ApnsClient::mock(apns_config, true)), @@ -1769,6 +1805,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let dispatcher = Arc::new(PushDispatcher::new( Some(ApnsClient::mock(apns_config, true)), @@ -1836,6 +1875,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, true); let metrics = Metrics::new().unwrap(); @@ -1892,6 +1934,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let metrics = Metrics::new().unwrap(); @@ -2094,6 +2139,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let mut client = ApnsClient::mock(apns_config, true); client.test_base_url = Some(mock_server.uri()); diff --git a/src/server/health.rs b/src/server/health.rs index d6de2f0..e2dec84 100644 --- a/src/server/health.rs +++ b/src/server/health.rs @@ -777,6 +777,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, true); let push_dispatcher = Arc::new(PushDispatcher::new(Some(apns_client), None)); @@ -847,6 +850,9 @@ mod tests { environment: crate::config::ApnsEnvironment::Sandbox, bundle_id: "com.example.app".to_string(), payload_mode: Default::default(), + alert_title: String::new(), + alert_body: String::new(), + collapse_id: String::new(), }; let apns_client = ApnsClient::mock(apns_config, true); let push_dispatcher = Arc::new(PushDispatcher::new(Some(apns_client), None));