From abd1017c73654e61fee5a00cdc5dfa9136056a5a Mon Sep 17 00:00:00 2001 From: Jacopo Date: Wed, 17 Sep 2025 18:36:40 +0200 Subject: [PATCH] Switch away from Apnotic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the `apnotic`→`net-http2` depencency in favor of a custom implementation using the `httpx` client. **Backward compatibility:** The `apple_data` payload now expects a format which matches exactly the [APNs API](https://developer.apple.com/documentation/usernotifications/generating-a-remote-notification#Payload-key-reference). Previously, the [Apnotic](https://github.com/ostinelli/apnotic) format was used instead. The key differences are: - The field keys now use dash `-` as a separator instead of an underscore `_`. - The header fields can be overridden using their exact name, e.g. `apns-priority` instead of a custom name set by Apnotic (`priority`). *before:* ```ruby notification = ApplicationPushNotification .with_apple(category: "observable", thread_id: "greeting", priority: "1") .new(title: "Hello world!") ``` *after:* ```ruby notification = ApplicationPushNotification .with_apple(aps: { category: "observable", "thread-id": "greeting"}, "apns-priority": "1") .new(title: "Hello world!") ``` There is a temporary backward-compatibility layer which takes care of the translation from the Apnotic format, but it will be removed in the next release. See https://github.com/rails/action_push_native/issues/25#issuecomment-3301537314 --- Gemfile.lock | 10 +- README.md | 10 +- action_push_native.gemspec | 3 +- .../action_push_native/notification_job.rb | 2 +- lib/action_push_native.rb | 7 +- .../configured_notification.rb | 2 +- lib/action_push_native/service/apns.rb | 154 +++++++------- .../service/apns/apnotic_legacy_converter.rb | 38 ++++ .../service/apns/httpx_session.rb | 26 +++ .../service/apns/token_provider.rb | 33 +++ .../install/templates/config/push.yml.tt | 2 +- test/dummy/config/push.yml | 2 +- .../configured_notification_test.rb | 2 +- .../action_push_native/service/apns_test.rb | 189 ++++++++++++------ test/test_helper.rb | 1 + 15 files changed, 326 insertions(+), 155 deletions(-) create mode 100644 lib/action_push_native/service/apns/apnotic_legacy_converter.rb create mode 100644 lib/action_push_native/service/apns/httpx_session.rb create mode 100644 lib/action_push_native/service/apns/token_provider.rb diff --git a/Gemfile.lock b/Gemfile.lock index 9758f21..7985f05 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,8 +4,9 @@ PATH action_push_native (0.1.0) activejob (>= 8.0) activerecord (>= 8.0) - apnotic (~> 1.7) googleauth (~> 1.14) + httpx (~> 1.6) + jwt (>= 2) net-http (~> 0.6) railties (>= 8.0) @@ -52,9 +53,6 @@ GEM uri (>= 0.13.1) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - apnotic (1.7.2) - connection_pool (~> 2) - net-http2 (>= 0.18.3, < 2) ast (2.4.3) base64 (0.3.0) benchmark (0.4.1) @@ -92,6 +90,8 @@ GEM signet (>= 0.16, < 2.a) hashdiff (1.2.0) http-2 (1.1.1) + httpx (1.6.0) + http-2 (>= 1.0.0) i18n (1.14.7) concurrent-ruby (~> 1.0) io-console (0.8.1) @@ -114,8 +114,6 @@ GEM multi_json (1.17.0) net-http (0.6.0) uri - net-http2 (0.19.0) - http-2 (>= 1.0) nokogiri (1.18.9-aarch64-linux-gnu) racc (~> 1.4) nokogiri (1.18.9-aarch64-linux-musl) diff --git a/README.md b/README.md index 23a394e..b91523c 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ You can use `with_apple` for Apple and `with_google` for Google: ```ruby notification = ApplicationPushNotification - .with_apple(category: "observable") + .with_apple(aps: { category: "observable", "thread-id": "greeting"}, "apns-priority": "1") .with_google(data: { badge: 1 }) .new(title: "Hello world!") ``` @@ -234,7 +234,7 @@ and `body`. You can create a silent notification via the `silent` method: ```ruby -notification = ApplicationPushNotification.silent.with_data(id: 1) +notification = ApplicationPushNotification.silent.with_data(id: 1).new ``` This will create a silent notification for both Apple and Google platforms and sets an application @@ -271,7 +271,7 @@ by adding extra arguments to the notification constructor: data = { calendar_id: @calendar.id, identity_id: @identity.id } notification = CalendarPushNotification - .with_apple(custom_payload: data) + .with_apple(data) .with_google(data: data) .new(calendar_id: 123) @@ -311,7 +311,7 @@ end | :sound | The sound to play when the notification is received. | :high_priority | Whether the notification should be sent with high priority (default: true). | :google_data | The Google-specific payload for the notification. -| :apple_data | The Apple-specific payload for the notification. +| :apple_data | The Apple-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc. | :data | The data payload for the notification, sent to all platforms. | ** | Any additional attributes passed to the constructor will be merged in the `context` hash. @@ -320,7 +320,7 @@ end | Name | Description |------------------|------------ | :with_apple | Set the Apple-specific payload for the notification. -| :with_google | Set the Google-specific payload for the notification. +| :with_google | Set the Google-specific payload for the notification. It can also be used to override APNs request headers, such as `apns-push-type`, `apns-priority`, etc. | :with_data | Set the data payload for the notification, sent to all platforms. | :silent | Create a silent notification that does not trigger a visual alert on the device. diff --git a/action_push_native.gemspec b/action_push_native.gemspec index 2fdf51b..5aebb7d 100644 --- a/action_push_native.gemspec +++ b/action_push_native.gemspec @@ -35,7 +35,8 @@ Gem::Specification.new do |spec| spec.add_dependency "activerecord", rails_version spec.add_dependency "activejob", rails_version spec.add_dependency "railties", rails_version - spec.add_dependency "apnotic", "~> 1.7" + spec.add_dependency "httpx", "~> 1.6" + spec.add_dependency "jwt", ">= 2" spec.add_dependency "googleauth", "~> 1.14" spec.add_dependency "net-http", "~> 0.6" end diff --git a/app/jobs/action_push_native/notification_job.rb b/app/jobs/action_push_native/notification_job.rb index 575aa0b..eaeaed3 100644 --- a/app/jobs/action_push_native/notification_job.rb +++ b/app/jobs/action_push_native/notification_job.rb @@ -40,7 +40,7 @@ def exponential_backoff_delay(executions) with_options retry_options do retry_on TimeoutError, wait: 1.minute - retry_on ConnectionError, ConnectionPool::TimeoutError, attempts: 20 + retry_on ConnectionError, HTTPX::PoolTimeoutError, attempts: 20 # Altough unexpected, these are short-lived errors that can be retried most of the times. retry_on ForbiddenError, BadRequestError diff --git a/lib/action_push_native.rb b/lib/action_push_native.rb index 1d950ea..29b9489 100644 --- a/lib/action_push_native.rb +++ b/lib/action_push_native.rb @@ -4,8 +4,9 @@ require "action_push_native/engine" require "action_push_native/errors" require "net/http" -require "apnotic" +require "httpx" require "googleauth" +require "jwt" loader= Zeitwerk::Loader.for_gem(warn_on_extra_files: false) loader.ignore("#{__dir__}/generators") @@ -37,4 +38,8 @@ def self.config_for(platform, notification) platform_config end end + + def self.deprecator + @deprecator ||= ActiveSupport::Deprecation.new + end end diff --git a/lib/action_push_native/configured_notification.rb b/lib/action_push_native/configured_notification.rb index 7c7ab6c..f97f79b 100644 --- a/lib/action_push_native/configured_notification.rb +++ b/lib/action_push_native/configured_notification.rb @@ -16,7 +16,7 @@ def with_data(data) def silent @options = options.merge(high_priority: false) - with_apple(content_available: 1) + with_apple(aps: { "content-available": 1 }) end def with_apple(apple_data) diff --git a/lib/action_push_native/service/apns.rb b/lib/action_push_native/service/apns.rb index e92536e..6c905ea 100644 --- a/lib/action_push_native/service/apns.rb +++ b/lib/action_push_native/service/apns.rb @@ -3,118 +3,114 @@ module ActionPushNative module Service class Apns - DEFAULT_TIMEOUT = 30.seconds - DEFAULT_POOL_SIZE = 5 - def initialize(config) @config = config end - # Per-application connection pools - cattr_accessor :connection_pools + # Per-application HTTPX session + cattr_accessor :httpx_sessions def push(notification) - reset_connection_error - - connection_pool.with do |connection| - rescue_and_reraise_network_errors do - apnotic_notification = apnotic_notification_from(notification) - Rails.logger.info("Pushing APNs notification: #{apnotic_notification.apns_id}") - - response = connection.push \ - apnotic_notification, - timeout: config[:request_timeout] || DEFAULT_TIMEOUT - raise connection_error if connection_error - handle_response_error(response) unless response&.ok? - end - end + notification.apple_data = ApnoticLegacyConverter.convert(notification.apple_data) if notification.apple_data.present? + + headers, payload = headers_from(notification), payload_from(notification) + Rails.logger.info("Pushing APNs notification: #{headers[:"apns-id"]}") + response = httpx_session.post("https://api.push.apple.com/3/device/#{notification.token}", json: payload, headers: headers) + handle_error(response) if response.error end private - attr_reader :config, :connection_error + attr_reader :config - def reset_connection_error - @connection_error = nil + PRIORITIES = { high: 10, normal: 5 }.freeze + HEADERS = %i[ apns-id apns-push-type apns-priority apns-topic apns-expiration apns-collapse-id ].freeze + + def headers_from(notification) + push_type = notification.apple_data&.dig(:aps, :"content-available") == 1 ? "background" : "alert" + custom_apple_headers = notification.apple_data&.slice(*HEADERS) || {} + + { + "apns-push-type": push_type, + "apns-id": SecureRandom.uuid, + "apns-priority": notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal], + "apns-topic": config.fetch(:topic) + }.merge(custom_apple_headers).compact end - def connection_pool - self.class.connection_pools ||= {} - self.class.connection_pools[config] ||= build_connection_pool + def payload_from(notification) + payload = \ + { + aps: { + alert: { title: notification.title, body: notification.body }, + badge: notification.badge, + "thread-id": notification.thread_id, + sound: notification.sound + } + } + + payload = payload.merge notification.data if notification.data.present? + custom_apple_payload = notification.apple_data&.except(*HEADERS) || {} + payload = payload.deep_merge custom_apple_payload + + payload.dig(:aps, :alert)&.compact! + payload[:aps]&.compact_blank! + payload.compact end - def build_connection_pool - build_method = config[:connect_to_development_server] ? "development" : "new" - Apnotic::ConnectionPool.public_send(build_method, { - auth_method: :token, - cert_path: StringIO.new(config.fetch(:encryption_key)), - key_id: config.fetch(:key_id), - team_id: config.fetch(:team_id) - }, size: config[:connection_pool_size] || DEFAULT_POOL_SIZE) do |connection| - # Prevents the main thread from crashing collecting the connection error from the off-thread - # and raising it afterwards. - connection.on(:error) { |error| @connection_error = error } - end + def httpx_session + self.class.httpx_sessions ||= {} + self.class.httpx_sessions[config] ||= HttpxSession.new(config) end - def rescue_and_reraise_network_errors - begin - yield - rescue Errno::ETIMEDOUT => e - raise ActionPushNative::TimeoutError, e.message - rescue Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError => e - raise ActionPushNative::ConnectionError, e.message - rescue OpenSSL::SSL::SSLError => e - if e.message.include?("SSL_connect") - raise ActionPushNative::ConnectionError, e.message - else - raise - end + def handle_error(response) + if response.is_a?(HTTPX::ErrorResponse) + handle_network_error(response.error) + else + handle_apns_error(response) end end - PRIORITIES = { high: 10, normal: 5 }.freeze - - def apnotic_notification_from(notification) - Apnotic::Notification.new(notification.token).tap do |n| - n.topic = config.fetch(:topic) - n.alert = { title: notification.title, body: notification.body }.compact - n.badge = notification.badge - n.thread_id = notification.thread_id - n.sound = notification.sound - n.priority = notification.high_priority ? PRIORITIES[:high] : PRIORITIES[:normal] - n.custom_payload = notification.data - notification.apple_data&.each do |key, value| - n.public_send("#{key.to_s.underscore}=", value) + def handle_network_error(error) + case error + when Errno::ETIMEDOUT, HTTPX::TimeoutError + raise ActionPushNative::TimeoutError, error.message + when Errno::ECONNRESET, Errno::ECONNABORTED, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, + SocketError, IOError, EOFError, Errno::EPIPE, Errno::EINVAL, HTTPX::ConnectionError, + HTTPX::TLSError, HTTPX::Connection::HTTP2::Error + raise ActionPushNative::ConnectionError, error.message + when OpenSSL::SSL::SSLError + if error.message.include?("SSL_connect") + raise ActionPushNative::ConnectionError, error.message + else + raise end end end - def handle_response_error(response) - code = response&.status - reason = response.body["reason"] if response + def handle_apns_error(response) + status = response.status + reason = JSON.parse(response.body.to_s)["reason"] unless response.body.empty? - Rails.logger.error("APNs response error #{code}: #{reason}") if reason + Rails.logger.error("APNs response error #{status}: #{reason}") if reason - case [ code, reason ] - in [ nil, _ ] - raise ActionPushNative::TimeoutError - in [ "400", "BadDeviceToken" ] + case [ status, reason ] + in [ 400, "BadDeviceToken" ] raise ActionPushNative::TokenError, reason - in [ "400", "DeviceTokenNotForTopic" ] + in [ 400, "DeviceTokenNotForTopic" ] raise ActionPushNative::BadDeviceTopicError, reason - in [ "400", _ ] + in [ 400, _ ] raise ActionPushNative::BadRequestError, reason - in [ "403", _ ] + in [ 403, _ ] raise ActionPushNative::ForbiddenError, reason - in [ "404", _ ] + in [ 404, _ ] raise ActionPushNative::NotFoundError, reason - in [ "410", _ ] + in [ 410, _ ] raise ActionPushNative::TokenError, reason - in [ "413", _ ] + in [ 413, _ ] raise ActionPushNative::PayloadTooLargeError, reason - in [ "429", _ ] + in [ 429, _ ] raise ActionPushNative::TooManyRequestsError, reason - in [ "503", _ ] + in [ 503, _ ] raise ActionPushNative::ServiceUnavailableError, reason else raise ActionPushNative::InternalServerError, reason diff --git a/lib/action_push_native/service/apns/apnotic_legacy_converter.rb b/lib/action_push_native/service/apns/apnotic_legacy_converter.rb new file mode 100644 index 0000000..f7e4fa9 --- /dev/null +++ b/lib/action_push_native/service/apns/apnotic_legacy_converter.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Converts the legacy `apple_data` format from the Apnotic gem +# to the new format expected by the APNs API. +# +# Temporary compatibility layer: It will be removed in the next release. +class ActionPushNative::Service::Apns::ApnoticLegacyConverter + APS_FIELDS = %i[ + alert badge sound content_available category url_args mutable_content thread_id + target_content_id interruption_level relevance_score + stale_date content_state timestamp event dismissal_date + ].freeze + APNS_HEADERS = %i[ expiration priority topic push_type ] + + def self.convert(apple_data) + apple_data.each_with_object({}) do |(key, value), converted| + if key.in?(APS_FIELDS) + converted[:aps] ||= {} + converted_key = key.to_s.dasherize.to_sym + converted[:aps][converted_key] = value + ActionPushNative.deprecator.warn("Passing the `#{key}` field directly is deprecated. Please use `.with_apple(aps: { \"#{converted_key}\": ... })` instead.") + elsif key.in?(APNS_HEADERS) + converted_key = "apns-#{key.to_s.dasherize}".to_sym + converted[converted_key] = value + ActionPushNative.deprecator.warn("Passing the `#{key}` header directly is deprecated. Please use `.with_apple(\"#{converted_key}\": ...)` instead.") + elsif key == :apns_collapse_id + converted_key = key.to_s.dasherize.to_sym + converted[converted_key] = value + ActionPushNative.deprecator.warn("Passing the `#{key}` header directly is deprecated. Please use `.with_apple(\"#{converted_key}\": ...)` instead.") + elsif key == :custom_payload + converted.merge!(value) + ActionPushNative.deprecator.warn("Passing `custom_payload` is deprecated. Please use `.with_apple(#{value})` instead.") + else + converted[key] = value + end + end + end +end diff --git a/lib/action_push_native/service/apns/httpx_session.rb b/lib/action_push_native/service/apns/httpx_session.rb new file mode 100644 index 0000000..a408ae7 --- /dev/null +++ b/lib/action_push_native/service/apns/httpx_session.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ActionPushNative::Service::Apns::HttpxSession + DEFAULT_POOL_SIZE = 5 + DEFAULT_REQUEST_TIMEOUT = 30.seconds + DEVELOPMENT_SERVER_URL = "https://api.sandbox.push.apple.com:443" + PRODUCTION_SERVER_URL = "https://api.push.apple.com:443" + + def initialize(config) + @session = \ + HTTPX. + plugin(:persistent, close_on_fork: true). + with(pool_options: { max_connections: config[:connection_pool_size] || DEFAULT_POOL_SIZE }). + with(timeout: { request_timeout: config[:request_timeout] || DEFAULT_REQUEST_TIMEOUT }). + with(origin: config[:connect_to_development_server] ? DEVELOPMENT_SERVER_URL : PRODUCTION_SERVER_URL) + @token_provider = ActionPushNative::Service::Apns::TokenProvider.new(config) + end + + def post(*uri, **options) + options[:headers][:authorization] = "Bearer #{token_provider.fresh_access_token}" + session.post(*uri, **options) + end + + private + attr_reader :token_provider, :session +end diff --git a/lib/action_push_native/service/apns/token_provider.rb b/lib/action_push_native/service/apns/token_provider.rb new file mode 100644 index 0000000..a262a9c --- /dev/null +++ b/lib/action_push_native/service/apns/token_provider.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class ActionPushNative::Service::Apns::TokenProvider + EXPIRED = -1 + + def initialize(config) + @config = config + @expires_at = EXPIRED + end + + def fresh_access_token + regenerate_if_expired + token + end + + private + attr_reader :config, :token, :expires_at + + # See https://developer.apple.com/documentation/usernotifications/establishing-a-token-based-connection-to-apns#Refresh-your-token-regularly + def regenerate_if_expired + if Time.now.utc >= expires_at + @expires_at = 30.minutes.from_now.utc + @token = generate + end + end + + def generate + payload = { iss: config.fetch(:team_id), iat: Time.now.utc.to_i } + header = { kid: config.fetch(:key_id) } + private_key = OpenSSL::PKey::EC.new(config.fetch(:encryption_key)) + JWT.encode(payload, private_key, "ES256", header) + end +end diff --git a/lib/generators/action_push_native/install/templates/config/push.yml.tt b/lib/generators/action_push_native/install/templates/config/push.yml.tt index 80dfdbe..3b80f7f 100644 --- a/lib/generators/action_push_native/install/templates/config/push.yml.tt +++ b/lib/generators/action_push_native/install/templates/config/push.yml.tt @@ -11,7 +11,7 @@ shared: topic: your.bundle.identifier # Set this to the number of threads used to process notifications (default: 5). - # When the pool size is too small a ConnectionPool::TimeoutError error will be raised. + # When the pool size is too small a HTTPX::PoolTimeoutError error will be raised. # connection_pool_size: 5 # Change the request timeout (default: 30). diff --git a/test/dummy/config/push.yml b/test/dummy/config/push.yml index 1c42b05..039678b 100644 --- a/test/dummy/config/push.yml +++ b/test/dummy/config/push.yml @@ -11,7 +11,7 @@ shared: topic: your.bundle.identifier # Set this to the number of threads used to process notifications (default: 5). - # When the pool size is too small a ConnectionPool::TimeoutError error will be raised. + # When the pool size is too small a HTTPX::PoolTimeoutError error will be raised. # connection_pool_size: 5 # Change the request timeout (default: 30). diff --git a/test/lib/action_push_native/configured_notification_test.rb b/test/lib/action_push_native/configured_notification_test.rb index 91eede3..66392b7 100644 --- a/test/lib/action_push_native/configured_notification_test.rb +++ b/test/lib/action_push_native/configured_notification_test.rb @@ -5,7 +5,7 @@ class Notification::ConfiguredNotificationTest < ActiveSupport::TestCase test "silent notification" do notification = ActionPushNative::Notification.silent.new(title: "Hi!") assert_equal false, notification.high_priority - assert_equal({ content_available: 1 }, notification.apple_data) + assert_equal({ aps: { "content-available": 1 } }, notification.apple_data) assert_equal("Hi!", notification.title) end diff --git a/test/lib/action_push_native/service/apns_test.rb b/test/lib/action_push_native/service/apns_test.rb index 6e565cd..3f064a5 100644 --- a/test/lib/action_push_native/service/apns_test.rb +++ b/test/lib/action_push_native/service/apns_test.rb @@ -3,105 +3,178 @@ module ActionPushNative module Service class ApnsTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Deprecation + setup do @notification = build_notification + ActionPushNative::Service::Apns::TokenProvider.any_instance.stubs(:fresh_access_token).returns("fake_token") @config = ActionPushNative.config_for(:apple, @notification) @apns = Apns.new(@config) end - teardown { Apns.connection_pools = {} } test "push" do - connection_pool = FakeConnectionPool.new(FakeResponse.new(status: "200")) - Apns.connection_pools = { @config => connection_pool } + payload = \ + { + aps: { + alert: { title: "Hi!", body: "This is a push notification" }, + badge: 1, + "thread-id": "12345", + sound: "default", + category: "readable" + }, + person: "Jacopo" + } + + headers = \ + { + "Apns-Priority"=>"5", + "Apns-Push-Type"=>"alert", + "Apns-Topic"=>"your.bundle.identifier", + "Authorization"=>"Bearer fake_token" + } + + stub_request(:post, "https://api.push.apple.com/3/device/123"). + with(body: payload.to_json, headers: headers). + to_return(status: 200) + + assert_nothing_raised { @apns.push(@notification) } + end - @apns.push(@notification) + test "push silent notification" do + notification = ActionPushNative::Notification.silent.with_data(id: "1").new + notification.token = "123" - assert_equal 1, connection_pool.deliveries.size - - options = connection_pool.deliveries.first[:options] - assert_equal 60, options[:timeout] - - delivery = connection_pool.deliveries.first[:notification] - assert_equal "your.bundle.identifier", delivery.topic - assert_equal "123", delivery.token - assert_equal "Hi!", delivery.alert[:title] - assert_equal "This is a push notification", delivery.alert[:body] - assert_equal 1, delivery.badge - assert_equal "12345", delivery.thread_id - assert_equal "default", delivery.sound - assert_equal "readable", delivery.category - assert_equal 5, delivery.priority - assert_equal "Jacopo", delivery.custom_payload[:person] + payload = { aps: { "content-available": 1 }, id: "1" } + + headers = \ + { + "Apns-Priority"=>"5", + "Apns-Push-Type"=>"background", + "Apns-Topic"=>"your.bundle.identifier", + "Authorization"=>"Bearer fake_token" + } + + stub_request(:post, "https://api.push.apple.com/3/device/123"). + with(body: payload.to_json, headers: headers). + to_return(status: 200) + + assert_nothing_raised { @apns.push(notification) } end test "push response error" do - connection_pool = FakeConnectionPool.new(FakeResponse.new(status: "400")) - Apns.connection_pools = { @config => connection_pool } + stub_request(:post, "https://api.push.apple.com/3/device/123"). + to_return(status: 400) assert_raises ActionPushNative::BadRequestError do @apns.push(@notification) end - connection_pool = FakeConnectionPool.new(FakeResponse.new(status: "400", body: { reason: "BadDeviceToken" })) - Apns.connection_pools = { @config => connection_pool } + stub_request(:post, "https://api.push.apple.com/3/device/123"). + to_return(status: 400, body: { reason: "BadDeviceToken" }.to_json) assert_raises ActionPushNative::TokenError do @apns.push(@notification) end + + stub_request(:post, "https://api.push.apple.com/3/device/123"). + to_raise(Errno::ECONNRESET.new("Connection reset by peer")) + + assert_raises ActionPushNative::ConnectionError do + @apns.push(@notification) + end end test "push apns payload can be overridden" do - connection_pool = FakeConnectionPool.new(FakeResponse.new(status: "200")) - high_priority = 10 - Apns.connection_pools = { @config => connection_pool } - @notification.apple_data = { priority: high_priority, "thread-id": "changed", custom_payload: nil } + @notification.apple_data = { aps: { "thread-id": "changed" } } - @apns.push(@notification) + payload = \ + { + aps: { + alert: { title: "Hi!", body: "This is a push notification" }, + badge: 1, + "thread-id": "changed", + sound: "default" + }, + person: "Jacopo" + } - delivery = connection_pool.deliveries.first[:notification] - assert_equal high_priority, delivery.priority - assert_equal "changed", delivery.thread_id - assert_nil delivery.custom_payload + stub_request(:post, "https://api.push.apple.com/3/device/123"). + with(body: payload.to_json). + to_return(status: 200) + + assert_nothing_raised { @apns.push(@notification) } end - private - class FakeConnectionPool - attr_reader :deliveries + test "push apns headers can be overridden" do + @notification.apple_data = { "apns-priority": 10, "apns-expiration": 20 } - def initialize(response) - @response = response - @deliveries = [] - end + payload = \ + { + aps: { + alert: { title: "Hi!", body: "This is a push notification" }, + badge: 1, + "thread-id": "12345", + sound: "default" + }, + person: "Jacopo" + } - def with - yield self - end + headers = { "apns-priority": 10, "apns-expiration": 20 } - def push(notification, options = {}) - deliveries.push(notification:, options:) - response - end + stub_request(:post, "https://api.push.apple.com/3/device/123"). + with(body: payload.to_json, headers: headers). + to_return(status: 200) - private - attr_reader :response + assert_nothing_raised do + @apns.push(@notification) end + end - class FakeResponse - attr_reader :status, :body + test "apnotic legacy format compatibility" do + @notification.apple_data = { priority: 10, alert: { title: "Overridden!", body: nil }, custom_payload: { person: "Rosa" } } - def initialize(status:, body: {}) - @status = status - @body = body.stringify_keys - end + payload = \ + { + aps: { + alert: { title: "Overridden!" }, + badge: 1, + "thread-id": "12345", + sound: "default" + }, + person: "Rosa" + } + + headers = { "apns-priority": 10 } - def ok? - status.start_with?("20") + stub_request(:post, "https://api.push.apple.com/3/device/123"). + with(body: payload.to_json, headers: headers). + to_return(status: 200) + + assert_nothing_raised do + assert_deprecated(/field directly is deprecated/, ActionPushNative.deprecator) do + @apns.push(@notification) end end + end + + test "access tokens are refreshed every 30 minutes" do + stub_request(:post, "https://api.push.apple.com/3/device/123") + ActionPushNative::Service::Apns::TokenProvider.any_instance.unstub(:fresh_access_token) + + ActionPushNative::Service::Apns::TokenProvider.any_instance.stubs(:generate).once.returns("fake_token") + @apns.push(@notification) + @apns.push(@notification) + + ActionPushNative::Service::Apns::TokenProvider.any_instance.stubs(:generate).once.returns("new_fake_token") + travel 31.minutes do + @apns.push(@notification) + end + end + private def build_notification ActionPushNative::Notification - .with_apple(category: "readable") + .with_apple(aps: { category: "readable" }) .with_data(person: "Jacopo") .new( title: "Hi!", diff --git a/test/test_helper.rb b/test/test_helper.rb index 53e49e4..c070714 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,7 @@ require_relative "../test/dummy/config/environment" ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] require "rails/test_help" +require "httpx/adapters/webmock" require "webmock/minitest" # Load fixtures from the engine