Add native push notification infrastructure

- Add action_push_native gem and SaaS configuration
- Add device registration API and UI
- Add User::Devices concern
- Add NotificationPusher::Native for push delivery
- Add tests and fixtures for native push
This commit is contained in:
Fernando Olivares
2026-01-14 14:38:09 -06:00
committed by Rosa Gutierrez
parent 628a43ef61
commit 3c54cd84fc
21 changed files with 791 additions and 3 deletions
+3
View File
@@ -11,6 +11,9 @@ gem "fizzy-saas", path: "saas"
gem "console1984", bc: "console1984"
gem "audits1984", bc: "audits1984", branch: "flavorjones/coworker-api"
# Native push notifications (iOS/Android)
gem "action_push_native"
# Telemetry
gem "rails_structured_logging", bc: "rails-structured-logging"
gem "sentry-ruby"
+38
View File
@@ -195,6 +195,13 @@ PATH
GEM
remote: https://rubygems.org/
specs:
action_push_native (0.3.0)
activejob (>= 8.0)
activerecord (>= 8.0)
googleauth (~> 1.14)
httpx (~> 1.6)
jwt (>= 2)
railties (>= 8.0)
action_text-trix (2.1.16)
railties
actionpack-xml_parser (2.0.1)
@@ -279,6 +286,12 @@ GEM
tzinfo
faker (3.6.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-aarch64-linux-musl)
ffi (1.17.2-arm-linux-gnu)
@@ -296,7 +309,22 @@ GEM
globalid (1.3.0)
activesupport (>= 6.1)
gvltools (0.4.0)
google-cloud-env (2.3.1)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-logging-utils (0.2.0)
googleauth (1.16.0)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
multi_json (~> 1.11)
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.1)
http-2 (1.1.1)
httpx (1.7.0)
http-2 (>= 1.0.0)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
@@ -369,6 +397,9 @@ GEM
mocha (3.0.2)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-http-persistent (4.0.8)
connection_pool (>= 2.2.4, < 4)
net-imap (0.6.3)
@@ -403,6 +434,7 @@ GEM
nokogiri (1.19.1-x86_64-linux-musl)
racc (~> 1.4)
openssl (4.0.0)
os (1.1.4)
ostruct (0.6.3)
parallel (1.27.0)
parser (3.3.10.0)
@@ -546,6 +578,11 @@ GEM
sentry-ruby (6.2.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
signet (0.21.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
multi_json (~> 1.10)
sniffer (0.5.0)
anyway_config (>= 1.0)
dry-initializer (~> 3)
@@ -664,6 +701,7 @@ PLATFORMS
DEPENDENCIES
actionpack-xml_parser
action_push_native
activeresource
audits1984!
autotuner
+7 -3
View File
@@ -12,18 +12,22 @@ class NotificationPusher
return unless should_push?
build_payload.tap do |payload|
push_to_user(payload)
push_to_web(payload)
end
end
private
def should_push?
notification.user.push_subscriptions.any? &&
push_destination? &&
!notification.creator.system? &&
notification.user.active? &&
notification.account.active?
end
def push_destination?
notification.user.push_subscriptions.any?
end
def build_payload
case notification.source_type
when "Event"
@@ -93,7 +97,7 @@ class NotificationPusher
}
end
def push_to_user(payload)
def push_to_web(payload)
subscriptions = notification.user.push_subscriptions
enqueue_payload_for_delivery(payload, subscriptions)
end
@@ -16,6 +16,7 @@
<div class="settings__panel panel shadow">
<%= render "notifications/settings/push_notifications" %>
<%= render "notifications/settings/native_devices" if Fizzy.saas? %>
<%= render "notifications/settings/email", settings: @settings %>
</div>
</section>
@@ -0,0 +1,15 @@
class CreateActionPushNativeDevices < ActiveRecord::Migration[8.0]
def change
create_table :action_push_native_devices do |t|
t.string :uuid, null: false
t.string :name
t.string :platform, null: false
t.string :token, null: false
t.belongs_to :owner, polymorphic: true
t.timestamps
end
add_index :action_push_native_devices, [ :owner_type, :owner_id, :uuid ], unique: true
end
end
+13
View File
@@ -69,6 +69,19 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
end
create_table "action_push_native_devices", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "name", limit: 255
t.integer "owner_id"
t.string "owner_type", limit: 255
t.string "platform", limit: 255, null: false
t.string "token", limit: 255, null: false
t.datetime "updated_at", null: false
t.string "uuid", limit: 255, null: false
t.index ["owner_type", "owner_id", "uuid"], name: "idx_on_owner_type_owner_id_uuid_a42e3920d5", unique: true
t.index ["owner_type", "owner_id"], name: "index_action_push_native_devices_on_owner"
end
create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.text "body", limit: 4294967295
@@ -0,0 +1,29 @@
class Users::DevicesController < ApplicationController
def index
@devices = Current.user.devices.order(created_at: :desc)
end
def create
attrs = device_params
device = Current.user.devices.find_or_create_by(uuid: attrs[:uuid])
device.update!(token: attrs[:token], name: attrs[:name], platform: attrs[:platform])
head :created
rescue ActiveRecord::RecordInvalid
head :unprocessable_entity
end
def destroy
Current.user.devices.find_by(id: params[:id])&.destroy
redirect_to users_devices_path, notice: "Device removed"
end
private
def device_params
params.permit(:uuid, :token, :platform, :name).tap do |p|
p[:platform] = p[:platform].to_s.downcase
raise ActionController::BadRequest unless p[:platform].in?(%w[apple google])
raise ActionController::BadRequest if p[:uuid].blank?
raise ActionController::BadRequest if p[:token].blank?
end
end
end
@@ -0,0 +1,2 @@
class ApplicationPushNotificationJob < ActionPushNative::NotificationJob
end
@@ -0,0 +1,4 @@
class ApplicationPushNotification < ActionPushNative::Notification
queue_as :default
self.enabled = !Rails.env.local?
end
@@ -0,0 +1,87 @@
module NotificationPusher::Native
extend ActiveSupport::Concern
def push
return unless should_push?
build_payload.tap do |payload|
push_to_web(payload) if notification.user.push_subscriptions.any?
push_to_native(payload)
end
end
private
def push_destination?
notification.user.push_subscriptions.any? || notification.user.devices.any?
end
def push_to_native(payload)
devices = notification.user.devices
return if devices.empty?
native_notification(payload).deliver_later_to(devices)
end
def native_notification(payload)
ApplicationPushNotification
.with_apple(
aps: {
category: notification_category,
"mutable-content": 1,
"interruption-level": interruption_level
}
)
.with_google(
android: { notification: nil }
)
.with_data(
path: payload[:path],
account_id: notification.account.external_account_id,
avatar_url: creator_avatar_url,
card_id: card&.id,
card_title: card&.title,
creator_name: notification.creator.name,
category: notification_category
)
.new(
title: payload[:title],
body: payload[:body],
badge: notification.user.notifications.unread.count,
sound: "default",
thread_id: card&.id,
high_priority: assignment_notification?
)
end
def notification_category
case notification.source
when Event
case notification.source.action
when "card_assigned" then "assignment"
when "comment_created" then "comment"
else "card"
end
when Mention
"mention"
else
"default"
end
end
def interruption_level
assignment_notification? ? "time-sensitive" : "active"
end
def assignment_notification?
notification.source.is_a?(Event) && notification.source.action == "card_assigned"
end
def creator_avatar_url
return unless notification.creator.respond_to?(:avatar) && notification.creator.avatar.attached?
Rails.application.routes.url_helpers.url_for(notification.creator.avatar)
end
def card
@card ||= notification.card
end
end
+7
View File
@@ -0,0 +1,7 @@
module User::Devices
extend ActiveSupport::Concern
included do
has_many :devices, class_name: "ActionPushNative::Device", as: :owner, dependent: :destroy
end
end
@@ -0,0 +1,14 @@
<div class="native-devices margin-block-start">
<h3 class="txt-small txt-uppercase divider">Mobile Devices</h3>
<% if Current.user.devices.any? %>
<p class="txt-small">
You have <%= pluralize(Current.user.devices.count, "mobile device") %> registered for push notifications.
</p>
<%= link_to "Manage devices", users_devices_path, class: "btn txt-small" %>
<% else %>
<p class="txt-small color-secondary">
No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone.
</p>
<% end %>
</div>
@@ -0,0 +1,16 @@
<h1>Registered Devices</h1>
<% if @devices.any? %>
<ul>
<% @devices.each do |device| %>
<li>
<strong><%= device.name || "Unnamed device" %></strong>
(<%= device.platform == "apple" ? "iOS" : "Android" %>)
<span>Added <%= time_ago_in_words(device.created_at) %> ago</span>
<%= button_to "Remove", users_device_path(device), method: :delete, data: { confirm: "Remove this device?" } %>
</li>
<% end %>
</ul>
<% else %>
<p>No devices registered. Install the mobile app to receive push notifications.</p>
<% end %>
+11
View File
@@ -0,0 +1,11 @@
shared:
apple:
key_id: <%= ENV["APNS_KEY_ID"] || Rails.application.credentials.dig(:action_push_native, :apns, :key_id) %>
encryption_key: <%= (ENV["APNS_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :apns, :key))&.dump %>
team_id: YOUR_TEAM_ID # Your 10-character Apple Developer Team ID (not secret)
topic: com.yourcompany.fizzy # Your app's bundle identifier (not secret)
# Uncomment for local development with Xcode builds (uses APNs sandbox):
# connect_to_development_server: true
google:
encryption_key: <%= (ENV["FCM_ENCRYPTION_KEY"] || Rails.application.credentials.dig(:action_push_native, :fcm, :key))&.dump %>
project_id: your-firebase-project # Your Firebase project ID (not secret)
+10
View File
@@ -23,6 +23,10 @@ module Fizzy
headers: app.config.public_file_server.headers
end
initializer "fizzy_saas.push_config", before: "action_push_native.config" do |app|
app.paths.add "config/push", with: root.join("config/push.yml")
end
initializer "fizzy.saas.routes", after: :add_routing_paths do |app|
# Routes that rely on the implicit account tenant should go here instead of in +routes.rb+.
app.routes.prepend do
@@ -39,6 +43,10 @@ module Fizzy
namespace :stripe do
resource :webhooks, only: :create
end
namespace :users do
resources :devices, only: [ :index, :create, :destroy ]
end
end
end
@@ -148,6 +156,8 @@ module Fizzy
config.to_prepare do
::Account.include Account::Billing, Account::Limited
::User.include User::NotifiesAccountOfEmailChange
::User.include User::Devices
::NotificationPusher.include NotificationPusher::Native
::Signup.prepend Fizzy::Saas::Signup
CardsController.include(Card::LimitedCreation)
Cards::PublishesController.include(Card::LimitedPublishing)
@@ -0,0 +1,234 @@
require "test_helper"
class Users::DevicesControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:david)
sign_in_as @user
end
# === Index (Web) ===
test "index shows user devices" do
@user.devices.create!(uuid: "test-uuid", token: "test_token_123", platform: "apple", name: "iPhone 15 Pro")
get users_devices_path
assert_response :success
assert_select "strong", "iPhone 15 Pro"
assert_select "li", /iOS/
end
test "index shows empty state when no devices" do
@user.devices.delete_all
get users_devices_path
assert_response :success
assert_select "p", /No devices registered/
end
test "index requires authentication" do
sign_out
get users_devices_path
assert_response :redirect
end
# === Create (API) ===
test "creates a new device via api" do
uuid = SecureRandom.uuid
token = SecureRandom.hex(32)
assert_difference "ActionPushNative::Device.count", 1 do
post users_devices_path, params: {
uuid: uuid,
token: token,
platform: "apple",
name: "iPhone 15 Pro"
}, as: :json
end
assert_response :created
device = ActionPushNative::Device.last
assert_equal uuid, device.uuid
assert_equal token, device.token
assert_equal "apple", device.platform
assert_equal "iPhone 15 Pro", device.name
assert_equal @user, device.owner
end
test "creates android device" do
post users_devices_path, params: {
uuid: SecureRandom.uuid,
token: SecureRandom.hex(32),
platform: "google",
name: "Pixel 8"
}, as: :json
assert_response :created
device = ActionPushNative::Device.last
assert_equal "google", device.platform
end
test "updates existing device with same uuid" do
existing_device = @user.devices.create!(
uuid: "my-device-uuid",
token: "old_token",
platform: "apple",
name: "Old iPhone"
)
assert_no_difference "ActionPushNative::Device.count" do
post users_devices_path, params: {
uuid: "my-device-uuid",
token: "new_token",
platform: "apple",
name: "New iPhone"
}, as: :json
end
assert_response :created
existing_device.reload
assert_equal "new_token", existing_device.token
assert_equal "New iPhone", existing_device.name
end
test "same token can be registered by multiple users" do
shared_token = "shared_push_token_123"
other_user = users(:kevin)
# Other user registers the token first
other_device = other_user.devices.create!(
uuid: "kevins-device-uuid",
token: shared_token,
platform: "apple",
name: "Kevin's iPhone"
)
# Current user registers the same token with their own device
assert_difference "ActionPushNative::Device.count", 1 do
post users_devices_path, params: {
uuid: "davids-device-uuid",
token: shared_token,
platform: "apple",
name: "David's iPhone"
}, as: :json
end
assert_response :created
# Both users have their own device records
assert_equal shared_token, other_device.reload.token
assert_equal other_user, other_device.owner
davids_device = @user.devices.find_by(uuid: "davids-device-uuid")
assert_equal shared_token, davids_device.token
assert_equal @user, davids_device.owner
end
test "rejects invalid platform" do
post users_devices_path, params: {
uuid: SecureRandom.uuid,
token: SecureRandom.hex(32),
platform: "windows",
name: "Surface"
}, as: :json
assert_response :bad_request
end
test "rejects missing uuid" do
post users_devices_path, params: {
token: SecureRandom.hex(32),
platform: "apple",
name: "iPhone"
}, as: :json
assert_response :bad_request
end
test "rejects missing token" do
post users_devices_path, params: {
uuid: SecureRandom.uuid,
platform: "apple",
name: "iPhone"
}, as: :json
assert_response :bad_request
end
test "create requires authentication" do
sign_out
post users_devices_path, params: {
uuid: SecureRandom.uuid,
token: SecureRandom.hex(32),
platform: "apple"
}, as: :json
assert_response :redirect
end
# === Destroy (Web) ===
test "destroys device" do
device = @user.devices.create!(
uuid: "device-to-delete",
token: "token_to_delete",
platform: "apple",
name: "iPhone"
)
assert_difference "ActionPushNative::Device.count", -1 do
delete users_device_path(device)
end
assert_redirected_to users_devices_path
assert_not ActionPushNative::Device.exists?(device.id)
end
test "does nothing when device not found" do
assert_no_difference "ActionPushNative::Device.count" do
delete users_device_path(id: "nonexistent")
end
assert_redirected_to users_devices_path
end
test "cannot destroy another user's device" do
other_user = users(:kevin)
device = other_user.devices.create!(
uuid: "other-users-device",
token: "other_users_token",
platform: "apple",
name: "Other iPhone"
)
assert_no_difference "ActionPushNative::Device.count" do
delete users_device_path(device)
end
assert_redirected_to users_devices_path
assert ActionPushNative::Device.exists?(device.id)
end
test "destroy requires authentication" do
device = @user.devices.create!(
uuid: "my-device",
token: "my_token",
platform: "apple",
name: "iPhone"
)
sign_out
delete users_device_path(device)
assert_response :redirect
assert ActionPushNative::Device.exists?(device.id)
end
end
+20
View File
@@ -0,0 +1,20 @@
davids_iphone:
uuid: device-uuid-davids-iphone
name: iPhone 15 Pro
token: abc123def456abc123def456abc123def456abc123def456abc123def456abcd
platform: apple
owner: david (User)
davids_pixel:
uuid: device-uuid-davids-pixel
name: Pixel 8
token: def456abc123def456abc123def456abc123def456abc123def456abc123defg
platform: google
owner: david (User)
kevins_iphone:
uuid: device-uuid-kevins-iphone
name: iPhone 14
token: 789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz789xyz7890
platform: apple
owner: kevin (User)
@@ -0,0 +1,221 @@
require "test_helper"
class NotificationPusherNativeTest < ActiveSupport::TestCase
setup do
@user = users(:kevin)
@notification = notifications(:logo_published_kevin)
@pusher = NotificationPusher.new(@notification)
# Ensure user has no web push subscriptions (we want to test native push independently)
@user.push_subscriptions.delete_all
end
# === Notification Category ===
test "notification_category returns assignment for card_assigned" do
notification = notifications(:logo_assignment_kevin)
pusher = NotificationPusher.new(notification)
assert_equal "assignment", pusher.send(:notification_category)
end
test "notification_category returns comment for comment_created" do
notification = notifications(:layout_commented_kevin)
pusher = NotificationPusher.new(notification)
assert_equal "comment", pusher.send(:notification_category)
end
test "notification_category returns mention for mentions" do
notification = notifications(:logo_card_david_mention_by_jz)
pusher = NotificationPusher.new(notification)
assert_equal "mention", pusher.send(:notification_category)
end
test "notification_category returns card for other card events" do
notification = notifications(:logo_published_kevin)
pusher = NotificationPusher.new(notification)
assert_equal "card", pusher.send(:notification_category)
end
# === Interruption Level ===
test "interruption_level is time-sensitive for assignments" do
notification = notifications(:logo_assignment_kevin)
pusher = NotificationPusher.new(notification)
assert_equal "time-sensitive", pusher.send(:interruption_level)
end
test "interruption_level is active for non-assignments" do
notification = notifications(:logo_published_kevin)
pusher = NotificationPusher.new(notification)
assert_equal "active", pusher.send(:interruption_level)
end
# === Has Any Push Destination ===
test "push_destination returns true when user has native devices" do
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
assert @pusher.send(:push_destination?)
end
test "push_destination returns true when user has web subscriptions" do
@user.push_subscriptions.create!(
endpoint: "https://example.com/push",
p256dh_key: "test_p256dh",
auth_key: "test_auth"
)
assert @pusher.send(:push_destination?)
end
test "push_destination returns false when user has neither" do
@user.devices.delete_all
@user.push_subscriptions.delete_all
assert_not @pusher.send(:push_destination?)
end
# === Push Delivery ===
test "push delivers to native devices when user has devices" do
stub_push_services
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
assert_native_push_delivery(count: 1) do
@pusher.push
end
end
test "push does not deliver to native when user has no devices" do
@user.devices.delete_all
assert_no_native_push_delivery do
@pusher.push
end
end
test "push does not deliver when creator is system user" do
stub_push_services
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
@notification.update!(creator: users(:system))
result = @pusher.push
assert_nil result
end
test "push delivers to multiple devices" do
stub_push_services
@user.devices.create!(uuid: SecureRandom.uuid, token: "token1", platform: "apple", name: "iPhone")
@user.devices.create!(uuid: SecureRandom.uuid, token: "token2", platform: "google", name: "Pixel")
assert_native_push_delivery(count: 1) do
@pusher.push
end
end
test "push delivers to both web and native when user has both" do
stub_push_services
# Set up web push subscription
@user.push_subscriptions.create!(
endpoint: "https://fcm.googleapis.com/fcm/send/test",
p256dh_key: "test_p256dh_key",
auth_key: "test_auth_key"
)
# Set up native device
@user.devices.create!(uuid: SecureRandom.uuid, token: "native_token", platform: "apple", name: "iPhone")
# Mock web push pool to verify it receives the payload
web_push_pool = mock("web_push_pool")
web_push_pool.expects(:queue).once.with do |payload, subscriptions|
payload.is_a?(Hash) && subscriptions.count == 1
end
Rails.configuration.x.stubs(:web_push_pool).returns(web_push_pool)
# Verify native push is also delivered
assert_native_push_delivery(count: 1) do
@pusher.push
end
end
# === Native Notification Building ===
test "native notification includes required fields" do
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
payload = @pusher.send(:build_payload)
native = @pusher.send(:native_notification, payload)
assert_not_nil native.title
assert_not_nil native.body
assert_equal "default", native.sound
end
test "native notification sets thread_id from card" do
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
payload = @pusher.send(:build_payload)
native = @pusher.send(:native_notification, payload)
assert_equal @notification.card.id, native.thread_id
end
test "native notification sets high_priority for assignments" do
notification = notifications(:logo_assignment_kevin)
notification.user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
pusher = NotificationPusher.new(notification)
payload = pusher.send(:build_payload)
native = pusher.send(:native_notification, payload)
assert native.high_priority
end
test "native notification sets normal priority for non-assignments" do
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
payload = @pusher.send(:build_payload)
native = @pusher.send(:native_notification, payload)
assert_not native.high_priority
end
# === Apple-specific Payload ===
test "native notification includes apple-specific fields" do
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
payload = @pusher.send(:build_payload)
native = @pusher.send(:native_notification, payload)
assert_equal 1, native.apple_data.dig(:aps, :"mutable-content")
assert_includes %w[active time-sensitive], native.apple_data.dig(:aps, :"interruption-level")
assert_not_nil native.apple_data.dig(:aps, :category)
end
# === Google-specific Payload ===
test "native notification sets android notification to nil for data-only" do
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
payload = @pusher.send(:build_payload)
native = @pusher.send(:native_notification, payload)
assert_nil native.google_data.dig(:android, :notification)
end
# === Data Payload ===
test "native notification includes data payload" do
@user.devices.create!(uuid: SecureRandom.uuid, token: "test123", platform: "apple", name: "Test iPhone")
payload = @pusher.send(:build_payload)
native = @pusher.send(:native_notification, payload)
assert_not_nil native.data[:path]
assert_equal @notification.account.external_account_id, native.data[:account_id]
assert_equal @notification.creator.name, native.data[:creator_name]
end
end
+21
View File
@@ -0,0 +1,21 @@
require "test_helper"
class PushConfigTest < ActiveSupport::TestCase
test "loads push config from the saas engine" do
skip unless Fizzy.saas?
config = Rails.application.config_for(:push)
apple_team_id = config.dig("apple", "team_id")
apple_topic = config.dig("apple", "topic")
google_project_id = config.dig("google", "project_id")
skip "Update test once APNS team_id is configured" if apple_team_id == "YOUR_TEAM_ID"
skip "Update test once APNS topic is configured" if apple_topic == "com.yourcompany.fizzy"
skip "Update test once FCM project_id is configured" if google_project_id == "your-firebase-project"
assert apple_team_id.present?
assert apple_topic.present?
assert google_project_id.present?
end
end
@@ -0,0 +1,33 @@
module PushNotificationTestHelper
# Assert native push notification is queued for delivery
def assert_native_push_delivery(count: 1, &block)
assert_enqueued_jobs count, only: ApplicationPushNotificationJob, &block
end
# Assert no native push notifications are queued
def assert_no_native_push_delivery(&block)
assert_native_push_delivery(count: 0, &block)
end
# Expect push notification to be delivered (using mocha)
def expect_native_push_delivery(count: 1)
ApplicationPushNotification.any_instance.expects(:deliver_later_to).times(count)
yield if block_given?
end
# Expect no push notification delivery
def expect_no_native_push_delivery(&block)
expect_native_push_delivery(count: 0, &block)
end
# Stub the push service to avoid actual API calls
def stub_push_services
ActionPushNative.stubs(:service_for).returns(stub(push: true))
end
# Stub push service to simulate token error (device should be deleted)
def stub_push_token_error
push_stub = stub.tap { |s| s.stubs(:push).raises(ActionPushNative::TokenError) }
ActionPushNative.stubs(:service_for).returns(push_stub)
end
end
+5
View File
@@ -37,6 +37,10 @@ VCR.configure do |config|
}
end
if Fizzy.saas?
require_relative "../saas/test/test_helpers/push_notification_test_helper"
end
module ActiveSupport
class TestCase
parallelize workers: :number_of_processors, work_stealing: ENV["WORK_STEALING"] != "false"
@@ -47,6 +51,7 @@ module ActiveSupport
include ActiveJob::TestHelper
include ActionTextTestHelper, CachingTestHelper, CardTestHelper, ChangeTestHelper, SessionTestHelper
include Turbo::Broadcastable::TestHelper
include PushNotificationTestHelper if Fizzy.saas?
setup do
Current.account = accounts("37s")