diff --git a/Gemfile.saas b/Gemfile.saas index c29eaeb79..39aa49a71 100644 --- a/Gemfile.saas +++ b/Gemfile.saas @@ -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" diff --git a/Gemfile.saas.lock b/Gemfile.saas.lock index b18d2fa8b..0b58ab02e 100644 --- a/Gemfile.saas.lock +++ b/Gemfile.saas.lock @@ -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 diff --git a/app/models/notification_pusher.rb b/app/models/notification_pusher.rb index d6425e561..1b50142bd 100644 --- a/app/models/notification_pusher.rb +++ b/app/models/notification_pusher.rb @@ -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 diff --git a/app/views/notifications/settings/show.html.erb b/app/views/notifications/settings/show.html.erb index 3e6b8b84d..dc43bafd8 100644 --- a/app/views/notifications/settings/show.html.erb +++ b/app/views/notifications/settings/show.html.erb @@ -16,6 +16,7 @@
<%= render "notifications/settings/push_notifications" %> + <%= render "notifications/settings/native_devices" if Fizzy.saas? %> <%= render "notifications/settings/email", settings: @settings %>
diff --git a/db/migrate/20260114203313_create_action_push_native_devices.rb b/db/migrate/20260114203313_create_action_push_native_devices.rb new file mode 100644 index 000000000..aa577ef76 --- /dev/null +++ b/db/migrate/20260114203313_create_action_push_native_devices.rb @@ -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 diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index c8ce19e07..ab087f4b2 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -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 diff --git a/saas/app/controllers/users/devices_controller.rb b/saas/app/controllers/users/devices_controller.rb new file mode 100644 index 000000000..a78bbe40f --- /dev/null +++ b/saas/app/controllers/users/devices_controller.rb @@ -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 diff --git a/saas/app/jobs/application_push_notification_job.rb b/saas/app/jobs/application_push_notification_job.rb new file mode 100644 index 000000000..5db7811c2 --- /dev/null +++ b/saas/app/jobs/application_push_notification_job.rb @@ -0,0 +1,2 @@ +class ApplicationPushNotificationJob < ActionPushNative::NotificationJob +end diff --git a/saas/app/models/application_push_notification.rb b/saas/app/models/application_push_notification.rb new file mode 100644 index 000000000..41fe881f7 --- /dev/null +++ b/saas/app/models/application_push_notification.rb @@ -0,0 +1,4 @@ +class ApplicationPushNotification < ActionPushNative::Notification + queue_as :default + self.enabled = !Rails.env.local? +end diff --git a/saas/app/models/notification_pusher/native.rb b/saas/app/models/notification_pusher/native.rb new file mode 100644 index 000000000..237697f92 --- /dev/null +++ b/saas/app/models/notification_pusher/native.rb @@ -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 diff --git a/saas/app/models/user/devices.rb b/saas/app/models/user/devices.rb new file mode 100644 index 000000000..25bd6e4b2 --- /dev/null +++ b/saas/app/models/user/devices.rb @@ -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 diff --git a/saas/app/views/notifications/settings/_native_devices.html.erb b/saas/app/views/notifications/settings/_native_devices.html.erb new file mode 100644 index 000000000..a3e95b392 --- /dev/null +++ b/saas/app/views/notifications/settings/_native_devices.html.erb @@ -0,0 +1,14 @@ +
+

Mobile Devices

+ + <% if Current.user.devices.any? %> +

+ You have <%= pluralize(Current.user.devices.count, "mobile device") %> registered for push notifications. +

+ <%= link_to "Manage devices", users_devices_path, class: "btn txt-small" %> + <% else %> +

+ No mobile devices registered. Install the iOS or Android app to receive push notifications on your phone. +

+ <% end %> +
diff --git a/saas/app/views/users/devices/index.html.erb b/saas/app/views/users/devices/index.html.erb new file mode 100644 index 000000000..4a9a02486 --- /dev/null +++ b/saas/app/views/users/devices/index.html.erb @@ -0,0 +1,16 @@ +

Registered Devices

+ +<% if @devices.any? %> + +<% else %> +

No devices registered. Install the mobile app to receive push notifications.

+<% end %> diff --git a/saas/config/push.yml b/saas/config/push.yml new file mode 100644 index 000000000..916277b30 --- /dev/null +++ b/saas/config/push.yml @@ -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) diff --git a/saas/lib/fizzy/saas/engine.rb b/saas/lib/fizzy/saas/engine.rb index 3d20f9df8..4acf30e57 100644 --- a/saas/lib/fizzy/saas/engine.rb +++ b/saas/lib/fizzy/saas/engine.rb @@ -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) diff --git a/saas/test/controllers/users/devices_controller_test.rb b/saas/test/controllers/users/devices_controller_test.rb new file mode 100644 index 000000000..46da8ce7f --- /dev/null +++ b/saas/test/controllers/users/devices_controller_test.rb @@ -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 diff --git a/saas/test/fixtures/action_push_native/devices.yml b/saas/test/fixtures/action_push_native/devices.yml new file mode 100644 index 000000000..0494d2a97 --- /dev/null +++ b/saas/test/fixtures/action_push_native/devices.yml @@ -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) diff --git a/saas/test/models/notification_pusher_test.rb b/saas/test/models/notification_pusher_test.rb new file mode 100644 index 000000000..2c628b416 --- /dev/null +++ b/saas/test/models/notification_pusher_test.rb @@ -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 diff --git a/saas/test/models/push_config_test.rb b/saas/test/models/push_config_test.rb new file mode 100644 index 000000000..979194b3f --- /dev/null +++ b/saas/test/models/push_config_test.rb @@ -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 diff --git a/saas/test/test_helpers/push_notification_test_helper.rb b/saas/test/test_helpers/push_notification_test_helper.rb new file mode 100644 index 000000000..24355edf7 --- /dev/null +++ b/saas/test/test_helpers/push_notification_test_helper.rb @@ -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 diff --git a/test/test_helper.rb b/test/test_helper.rb index b1f813761..b8e96bf97 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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")