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:
committed by
Rosa Gutierrez
parent
628a43ef61
commit
3c54cd84fc
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user