diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bcc924..b60a827 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - ruby: ['2.7', '3.0', '3.1'] + ruby: ['2.7', '3.0', '3.1', '3.3'] steps: - uses: actions/checkout@master diff --git a/.gitignore b/.gitignore index a7f76bf..794e566 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ Gemfile.lock .rvmrc spec/reports *.gem +.env diff --git a/README.md b/README.md index e4e239b..964b41a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,6 @@ To use this gem, you need to instantiate a client with your firebase credentials ```ruby fcm = FCM.new( - API_TOKEN, GOOGLE_APPLICATION_CREDENTIALS_PATH, FIREBASE_PROJECT_ID ) @@ -40,7 +39,6 @@ The easiest way to provide them is to pass here an absolute path to a file with ```ruby fcm = FCM.new( - API_TOKEN, '/path/to/credentials.json', FIREBASE_PROJECT_ID ) @@ -50,7 +48,6 @@ As per their secret nature, you might not want to have them in your repository. ```ruby fcm = FCM.new( - API_TOKEN, StringIO.new(ENV.fetch('FIREBASE_CREDENTIALS')), FIREBASE_PROJECT_ID ) @@ -65,13 +62,13 @@ To migrate to HTTP v1 see: https://firebase.google.com/docs/cloud-messaging/migr ```ruby fcm = FCM.new( - API_TOKEN, GOOGLE_APPLICATION_CREDENTIALS_PATH, FIREBASE_PROJECT_ID ) message = { - 'topic': "89023", # OR token if you want to send to a specific device - # 'token': "000iddqd", + 'token': "000iddqd", # send to a specific device + # 'topic': "yourTopic", + # 'condition': "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)", 'data': { payload: { data: { @@ -97,58 +94,42 @@ message = { } } -fcm.send_v1(message) -``` - -## HTTP Legacy Version - -To migrate to HTTP v1 see: https://firebase.google.com/docs/cloud-messaging/migrate-v1 - -For your server to send a message to one or more devices, you must first initialise a new `FCM` class with your Firebase Cloud Messaging server key, and then call the `send` method on this and give it 1 or more (up to 1000) registration tokens as an array of strings. You can also optionally send further [HTTP message parameters](https://firebase.google.com/docs/cloud-messaging/http-server-ref) like `data` or `time_to_live` etc. as a hash via the second optional argument to `send`. - -Example sending notifications: - -```ruby -require 'fcm' - -fcm = FCM.new("my_server_key") - -registration_ids= ["12", "13"] # an array of one or more client registration tokens - -# See https://firebase.google.com/docs/cloud-messaging/http-server-ref for all available options. -options = { "notification": { - "title": "Portugal vs. Denmark", - "body": "5 to 1" - } -} -response = fcm.send(registration_ids, options) +fcm.send_v1(message) # or fcm.send_notification_v1(message) ``` -Currently `response` is just a hash containing the response `body`, `headers` and `status_code`. Check [here](https://firebase.google.com/docs/cloud-messaging/server#response) to see how to interpret the responses. - ## Device Group Messaging With [device group messaging](https://firebase.google.com/docs/cloud-messaging/notifications), you can send a single message to multiple instance of an app running on devices belonging to a group. Typically, "group" refers a set of different devices that belong to a single user. However, a group could also represent a set of devices where the app instance functions in a highly correlated manner. To use this feature, you will first need an initialised `FCM` class. +The maximum number of members allowed for a notification key is 20. +https://firebase.google.com/docs/cloud-messaging/android/device-group#managing_device_groups + ### Generate a Notification Key for device group Then you will need a notification key which you can create for a particular `key_name` which needs to be uniquely named per app in case you have multiple apps for the same `project_id`. This ensures that notifications only go to the intended target app. The `create` method will do this and return the token `notification_key`, that represents the device group, in the response: +`project_id` is the SENDER_ID in your cloud settings. +https://firebase.google.com/docs/cloud-messaging/concept-options#senderid + ```ruby -params = {key_name: "appUser-Chris", +params = { key_name: "appUser-Chris", project_id: "my_project_id", - registration_ids: ["4", "8", "15", "16", "23", "42"]} + registration_ids: ["4", "8", "15", "16", "23", "42"] } response = fcm.create(*params.values) ``` -### Send to Notification Key +### Send to Notification device group -Now you can send a message to a particular `notification_key` via the `send_with_notification_key` method. This allows the server to send a single [data](https://firebase.google.com/docs/cloud-messaging/concept-options#data_messages) payload or/and [notification](https://firebase.google.com/docs/cloud-messaging/concept-options#notifications) payload to multiple app instances (typically on multiple devices) owned by a single user (instead of sending to some registration tokens). Note: the maximum number of members allowed for a `notification_key` is 20. +To send messages to device groups, use the HTTP v1 API, +Sending messages to a device group is very similar to sending messages to an individual device, using the same method to authorize send requests. Set the token field to the group notification key ```ruby -response = fcm.send_with_notification_key("notification_key", - data: {score: "3x1"}, - collapse_key: "updated_score") +message = { + 'token': "NOTIFICATION_KEY", # send to a device group + # ...data +} + +fcm.send_v1(message) ``` ### Add/Remove Registration Tokens @@ -171,23 +152,51 @@ response = fcm.remove(*params.values) ## Send Messages to Topics -FCM [topic messaging](https://firebase.google.com/docs/cloud-messaging/topic-messaging) allows your app server to send a message to multiple devices that have opted in to a particular topic. Based on the publish/subscribe model, topic messaging supports unlimited subscriptions per app. Sending to a topic is very similar to sending to an individual device or to a user group, in the sense that you can use the `fcm.send_with_notification_key()` method where the `notification_key` matches the regular expression `"/topics/[a-zA-Z0-9-_.~%]+"`: +FCM [topic messaging](https://firebase.google.com/docs/cloud-messaging/topic-messaging) allows your app server to send a message to multiple devices that have opted in to a particular topic. Based on the publish/subscribe model, one app instance can be subscribed to no more than 2000 topics. Sending to a topic is very similar to sending to an individual device or to a user group, in the sense that you can use the `fcm.send_v1` method where the `topic` matches the regular expression `"/topics/[a-zA-Z0-9-_.~%]+"`: ```ruby -response = fcm.send_with_notification_key("/topics/yourTopic", - notification: {body: "This is a FCM Topic Message!"}) +message = { + 'topic': "yourTopic", # send to a device group + # ...data +} + +fcm.send_v1(message) ``` -Or you can use the helper: +Or you can use the `fcm.send_to_topic` helper: ```ruby response = fcm.send_to_topic("yourTopic", - notification: {body: "This is a FCM Topic Message!"}) + notification: { body: "This is a FCM Topic Message!"} ) +``` + +## Send Messages to Topics with Conditions + +FCM [topic condition messaging](https://firebase.google.com/docs/cloud-messaging/android/topic-messaging#build_send_requests) to send a message to a combination of topics, specify a condition, which is a boolean expression that specifies the target topics. + +```ruby +message = { + 'condition': "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)", # send to topic condition + # ...data +} + +fcm.send_v1(message) +``` + +Or you can use the `fcm.send_to_topic_condition` helper: + +```ruby +response = fcm.send_to_topic_condition( + "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)", + notification: { + body: "This is an FCM Topic Message sent to a condition!" + } +) ``` ### Sending to Multiple Topics -To send to combinations of multiple topics, the FCM [docs](https://firebase.google.com/docs/cloud-messaging/send-message#send_messages_to_topics_2) require that you set a **condition** key (instead of the `to:` key) to a boolean condition that specifies the target topics. For example, to send messages to devices that subscribed to _TopicA_ and either _TopicB_ or _TopicC_: +To send to combinations of multiple topics, require that you set a **condition** key to a boolean condition that specifies the target topics. For example, to send messages to devices that subscribed to _TopicA_ and either _TopicB_ or _TopicC_: ``` 'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics) @@ -223,18 +232,38 @@ Given a registration token and a topic name, you can add the token to the topic ```ruby topic = "YourTopic" -registration_id= "12" # a client registration tokens -response = fcm.topic_subscription(topic, registration_id) +registration_token= "12" # a client registration token +response = fcm.topic_subscription(topic, registration_token) +# or unsubscription +response = fcm.topic_unsubscription(topic, registration_token) ``` Or you can manage relationship maps for multiple app instances [Google Instance ID server API. Manage relationship](https://developers.google.com/instance-id/reference/server#manage_relationship_maps_for_multiple_app_instances) ```ruby topic = "YourTopic" -registration_ids= ["4", "8", "15", "16", "23", "42"] # an array of one or more client registration tokens -response = fcm.batch_topic_subscription(topic, registration_ids) +registration_tokens= ["4", "8", "15", "16", "23", "42"] # an array of one or more client registration tokens +response = fcm.batch_topic_subscription(topic, registration_tokens) # or unsubscription -response = fcm.batch_topic_unsubscription(topic, registration_ids) +response = fcm.batch_topic_unsubscription(topic, registration_tokens) +``` + +## Get Information about the Instance ID + +Given a registration token, you can retrieve information about the token using the [Google Instance ID server API](https://developers.google.com/instance-id/reference/server). + +```ruby +registration_token= "12" # a client registration token +response = fcm.get_instance_id_info(registration_token) +``` + +To get detailed information about the instance ID, you can pass an optional +`options` hash to the `get_instance_id_info` method: + +```ruby +registration_token= "12" # a client registration token +options = { "details" => true } +response = fcm.get_instance_id_info(registration_token, options) ``` ## Mobile Clients @@ -245,6 +274,20 @@ The guide to set up an iOS app to get notifications is here: [Setting up a FCM C ## ChangeLog +### 2.0.0 +#### Breaking Changes +- Remove deprecated `API_KEY` +- Remove deprecated `send` method +- Remove deprecated `send_with_notification_key` method +- Remove `subscribe_instance_id_to_topic` method +- Remove `unsubscribe_instance_id_from_topic` method +- Remove `batch_subscribe_instance_ids_to_topic` method +- Remove `batch_unsubscribe_instance_ids_from_topic` method + +#### Supported Features +- Add HTTP v1 API support for `send_to_topic_condition` method +- Add HTTP v1 API support for `send_to_topic` method + ### 1.0.8 - caches calls to `Google::Auth::ServiceAccountCredentials` #103 - Allow `faraday` versions from 1 up to 2 #101 diff --git a/lib/fcm.rb b/lib/fcm.rb index 08980ce..8c32219 100644 --- a/lib/fcm.rb +++ b/lib/fcm.rb @@ -12,9 +12,7 @@ class FCM INSTANCE_ID_API = "https://iid.googleapis.com" TOPIC_REGEX = /[a-zA-Z0-9\-_.~%]+/ - def initialize(api_key, json_key_path = "", project_name = "", client_options = {}) - @api_key = api_key - @client_options = client_options + def initialize(json_key_path = "", project_name = "") @json_key_path = json_key_path @project_name = project_name end @@ -43,7 +41,7 @@ def initialize(api_key, json_key_path = "", project_name = "", client_options = # } # } # } - # fcm = FCM.new(api_key, json_key_path, project_name) + # fcm = FCM.new(json_key_path, project_name) # fcm.send_v1( # { "token": "4sdsx",, "to" : "notification": {}.. } # ) @@ -51,10 +49,7 @@ def send_notification_v1(message) return if @project_name.empty? post_body = { 'message': message } - extra_headers = { - 'Authorization' => "Bearer #{jwt_token}" - } - for_uri(BASE_URI_V1, extra_headers) do |connection| + for_uri(BASE_URI_V1) do |connection| response = connection.post( "#{@project_name}/messages:send", post_body.to_json ) @@ -64,29 +59,6 @@ def send_notification_v1(message) alias send_v1 send_notification_v1 - # See https://developers.google.com/cloud-messaging/http for more details. - # { "notification": { - # "title": "Portugal vs. Denmark", - # "text": "5 to 1" - # }, - # "to" : "bk3RNwTe3H0:CI2k_HHwgIpoDKCIZvvDMExUdFQ3P1..." - # } - # fcm = FCM.new("API_KEY") - # fcm.send( - # ["4sdsx", "8sdsd"], # registration_ids - # { "notification": { "title": "Portugal vs. Denmark", "text": "5 to 1" }, "to" : "bk3RNwTe3HdFQ3P1..." } - # ) - def send_notification(registration_ids, options = {}) - post_body = build_post_body(registration_ids, options) - - for_uri(BASE_URI) do |connection| - response = connection.post("/fcm/send", post_body.to_json) - build_response(response, registration_ids) - end - end - - alias send send_notification - def create_notification_key(key_name, project_id, registration_ids = []) post_body = build_post_body(registration_ids, operation: "create", notification_key_name: key_name) @@ -150,28 +122,29 @@ def recover_notification_key(key_name, project_id) end end - def send_with_notification_key(notification_key, options = {}) - body = { to: notification_key }.merge(options) - execute_notification(body) - end - - def topic_subscription(topic, registration_id) + def topic_subscription(topic, registration_token) for_uri(INSTANCE_ID_API) do |connection| - response = connection.post("/iid/v1/#{registration_id}/rel/topics/#{topic}") + response = connection.post( + "/iid/v1/#{registration_token}/rel/topics/#{topic}" + ) build_response(response) end end - def batch_topic_subscription(topic, registration_ids) - manage_topics_relationship(topic, registration_ids, "Add") + def topic_unsubscription(topic, registration_token) + batch_topic_unsubscription(topic, [registration_token]) end - def batch_topic_unsubscription(topic, registration_ids) - manage_topics_relationship(topic, registration_ids, "Remove") + def batch_topic_subscription(topic, registration_tokens) + manage_topics_relationship(topic, registration_tokens, 'Add') end - def manage_topics_relationship(topic, registration_ids, action) - body = { to: "/topics/#{topic}", registration_tokens: registration_ids } + def batch_topic_unsubscription(topic, registration_tokens) + manage_topics_relationship(topic, registration_tokens, 'Remove') + end + + def manage_topics_relationship(topic, registration_tokens, action) + body = { to: "/topics/#{topic}", registration_tokens: registration_tokens } for_uri(INSTANCE_ID_API) do |connection| response = connection.post("/iid/v1:batch#{action}", body.to_json) @@ -179,41 +152,38 @@ def manage_topics_relationship(topic, registration_ids, action) end end - def send_to_topic(topic, options = {}) - if topic.gsub(TOPIC_REGEX, "").length == 0 - send_with_notification_key("/topics/" + topic, options) - end - end - def get_instance_id_info(iid_token, options = {}) params = options for_uri(INSTANCE_ID_API) do |connection| - response = connection.get("/iid/info/" + iid_token, params) + response = connection.get("/iid/info/#{iid_token}", params) build_response(response) end end - def subscribe_instance_id_to_topic(iid_token, topic_name) - batch_subscribe_instance_ids_to_topic([iid_token], topic_name) - end - - def unsubscribe_instance_id_from_topic(iid_token, topic_name) - batch_unsubscribe_instance_ids_from_topic([iid_token], topic_name) - end - - def batch_subscribe_instance_ids_to_topic(instance_ids, topic_name) - manage_topics_relationship(topic_name, instance_ids, "Add") - end - - def batch_unsubscribe_instance_ids_from_topic(instance_ids, topic_name) - manage_topics_relationship(topic_name, instance_ids, "Remove") + def send_to_topic(topic, options = {}) + if topic.gsub(TOPIC_REGEX, '').length.zero? + body = { 'message': { 'topic': topic }.merge(options) } + + for_uri(BASE_URI_V1) do |connection| + response = connection.post( + "#{@project_name}/messages:send", body.to_json + ) + build_response(response) + end + end end def send_to_topic_condition(condition, options = {}) if validate_condition?(condition) - body = { condition: condition }.merge(options) - execute_notification(body) + body = { 'message': { 'condition': condition }.merge(options) } + + for_uri(BASE_URI_V1) do |connection| + response = connection.post( + "#{@project_name}/messages:send", body.to_json + ) + build_response(response) + end end end @@ -226,7 +196,8 @@ def for_uri(uri, extra_headers = {}) ) do |faraday| faraday.adapter Faraday.default_adapter faraday.headers["Content-Type"] = "application/json" - faraday.headers['Authorization'] = "key=#{@api_key}" + faraday.headers["Authorization"] = "Bearer #{jwt_token}" + faraday.headers["access_token_auth"]= "true" extra_headers.each do |key, value| faraday.headers[key] = value end @@ -284,13 +255,6 @@ def build_not_registered_ids(body, registration_id) not_registered_ids end - def execute_notification(body) - for_uri(BASE_URI) do |connection| - response = connection.post("/fcm/send", body.to_json) - build_response(response) - end - end - def has_canonical_id?(result) !result["registration_id"].nil? end diff --git a/spec/fcm_spec.rb b/spec/fcm_spec.rb index e976433..a3f24e1 100644 --- a/spec/fcm_spec.rb +++ b/spec/fcm_spec.rb @@ -1,615 +1,438 @@ require "spec_helper" describe FCM do - let(:send_url) { "#{FCM::BASE_URI}/fcm/send" } - let(:group_notification_base_uri) { "#{FCM::GROUP_NOTIFICATION_BASE_URI}/gcm/notification" } - let(:api_key) { "AIzaSyB-1uEai2WiUapxCs2Q0GZYzPu7Udno5aA" } - let(:registration_id) { "42" } - let(:registration_ids) { ["42"] } - let(:key_name) { "appUser-Chris" } - let(:project_id) { "123456789" } # https://developers.google.com/cloud-messaging/gcm#senderid - let(:notification_key) { "APA91bGHXQBB...9QgnYOEURwm0I3lmyqzk2TXQ" } - let(:valid_topic) { "TopicA" } - let(:invalid_topic) { "TopicA$" } - let(:valid_condition) { "'TopicA' in topics && ('TopicB' in topics || 'TopicC' in topics)" } - let(:invalid_condition) { "'TopicA' in topics and some other text ('TopicB' in topics || 'TopicC' in topics)" } - let(:invalid_condition_topic) { "'TopicA$' in topics" } - - it "should raise an error if the api key is not provided" do - expect { FCM.new }.to raise_error(ArgumentError) + let(:project_name) { 'test-project' } + let(:json_key_path) { 'path/to/json/key.json' } + let(:client) { FCM.new(json_key_path) } + + let(:mock_token) { "access_token" } + let(:mock_headers) do + { + "Content-Type" => "application/json", + "Authorization" => "Bearer #{mock_token}", + } end - it "should raise error if time_to_live is given" do - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#ttl + before do + allow(client).to receive(:json_key) + + # Mock the Google::Auth::ServiceAccountCredentials + allow(Google::Auth::ServiceAccountCredentials).to receive(:make_creds). + and_return(double(fetch_access_token!: { 'access_token' => mock_token })) end - describe "#send_v1" do - let(:project_name) { "project_name" } - let(:send_v1_url) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } - let(:access_token) { "access_token" } - let(:valid_request_v1_headers) do - { - "Content-Type" => "application/json", - "Authorization" => "Bearer #{access_token}", - } - end + it "should initialize" do + expect { client }.not_to raise_error + end - let(:send_v1_params) do - { - 'token' => '4sdsx', - 'notification' => { - 'title' => 'Breaking News', - 'body' => 'New news story available.' - }, - 'data' => { - 'story_id' => 'story_12345' - }, - 'android' => { - 'notification' => { - 'click_action' => 'TOP_STORY_ACTIVITY', - 'body' => 'Check out the Top Story' - } - }, - 'apns' => { - 'payload' => { - 'aps' => { - 'category' => 'NEW_MESSAGE_CATEGORY' - } - } - } - } + describe "credentials path" do + it "can be a path to a file" do + fcm = FCM.new("README.md") + expect(fcm.__send__(:json_key).class).to eq(File) end - let(:valid_request_v1_body) do - { 'message' => send_v1_params } + it "can be an IO object" do + fcm = FCM.new(StringIO.new("hey")) + expect(fcm.__send__(:json_key).class).to eq(StringIO) end + end + + describe "#send_v1 or #send_notification_v1" do + let(:client) { FCM.new(json_key_path, project_name) } + + let(:uri) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } + let(:status_code) { 200 } let(:stub_fcm_send_v1_request) do - stub_request(:post, send_v1_url).with( - body: valid_request_v1_body.to_json, - headers: valid_request_v1_headers + stub_request(:post, uri).with( + body: { 'message' => send_v1_params }.to_json, + headers: mock_headers ).to_return( # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream body: "{}", headers: {}, - status: 200, + status: status_code, ) end - let(:authorizer_double) { double("token_fetcher") } - let(:json_key_path) { double("file alike object") } - before do - expect(json_key_path).to receive(:respond_to?).and_return(true) - expect(Google::Auth::ServiceAccountCredentials).to receive_message_chain(:make_creds).and_return(authorizer_double) - expect(authorizer_double).to receive(:fetch_access_token!).and_return({ "access_token" => access_token }) stub_fcm_send_v1_request end - it 'should send notification of HTTP V1 using POST to FCM server' do - fcm = FCM.new(api_key, json_key_path, project_name) - fcm.send_v1(send_v1_params).should eq( - response: 'success', body: '{}', headers: {}, status_code: 200 - ) - stub_fcm_send_v1_request.should have_been_made.times(1) - end - end - - describe "sending notification" do - let(:valid_request_body) do - { registration_ids: registration_ids } - end - let(:valid_request_body_with_string) do - { registration_ids: registration_id } - end - let(:valid_request_headers) do - { - "Content-Type" => "application/json", - "Authorization" => "key=#{api_key}", - } - end - - let(:stub_fcm_send_request) do - stub_request(:post, send_url).with( - body: valid_request_body.to_json, - headers: valid_request_headers, - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", - headers: {}, - status: 200, - ) - end - - let(:stub_fcm_send_request_with_string) do - stub_request(:post, send_url).with( - body: valid_request_body_with_string.to_json, - headers: valid_request_headers, - ).to_return( - body: "{}", - headers: {}, - status: 200, - ) - end - - let(:stub_fcm_send_request_with_basic_auth) do - uri = URI.parse(send_url) - uri.user = "a" - uri.password = "b" - stub_request(:post, uri.to_s).to_return(body: "{}", headers: {}, status: 200) - end - - before(:each) do - stub_fcm_send_request - stub_fcm_send_request_with_string - stub_fcm_send_request_with_basic_auth - end - - it "should send notification using POST to FCM server" do - fcm = FCM.new(api_key) - fcm.send(registration_ids).should eq(response: "success", body: "{}", headers: {}, status_code: 200, canonical_ids: [], not_registered_ids: []) - stub_fcm_send_request.should have_been_made.times(1) - end - - it "should send notification using POST to FCM if id provided as string" do - fcm = FCM.new(api_key) - fcm.send(registration_id).should eq(response: "success", body: "{}", headers: {}, status_code: 200, canonical_ids: [], not_registered_ids: []) - stub_fcm_send_request.should have_been_made.times(1) - end - - context "send notification with data" do - let!(:stub_with_data) do - stub_request(:post, send_url) - .with(body: '{"registration_ids":["42"],"data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) - end - before do - end - it "should send the data in a post request to fcm" do - fcm = FCM.new(api_key) - fcm.send(registration_ids, data: { score: "5x1", time: "15:10" }) - stub_with_data.should have_been_requested + shared_examples 'succesfuly send notification' do + it 'should send notification of HTTP V1 using POST to FCM server' do + client.send_v1(send_v1_params).should eq( + response: 'success', body: '{}', headers: {}, status_code: 200 + ) + stub_fcm_send_v1_request.should have_been_made.times(1) end end - context "sending notification to a topic" do - let!(:stub_with_valid_topic) do - stub_request(:post, send_url) - .with(body: '{"to":"/topics/TopicA","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) - end - let!(:stub_with_invalid_topic) do - stub_request(:post, send_url) - .with(body: '{"condition":"/topics/TopicA$","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) + describe 'send to token' do + let(:token) { '4sdsx' } + let(:send_v1_params) do + { + 'token' => token, + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + }, + 'data' => { + 'story_id' => 'story_12345' + }, + 'android' => { + 'notification' => { + 'click_action' => 'TOP_STORY_ACTIVITY', + 'body' => 'Check out the Top Story' + } + }, + 'apns' => { + 'payload' => { + 'aps' => { + 'category' => 'NEW_MESSAGE_CATEGORY' + } + } + } + } end - describe "#send_to_topic" do - it "should send the data in a post request to fcm" do - fcm = FCM.new(api_key) - fcm.send_to_topic(valid_topic, data: { score: "5x1", time: "15:10" }) - stub_with_valid_topic.should have_been_requested - end + include_examples 'succesfuly send notification' - it "should not send to invalid topics" do - fcm = FCM.new(api_key) - fcm.send_to_topic(invalid_topic, data: { score: "5x1", time: "15:10" }) - stub_with_invalid_topic.should_not have_been_requested - end + it 'includes all the response' do + response = client.send_v1(send_v1_params) + expect(response[:status_code]).to eq(status_code) + expect(response[:response]).to eq('success') + expect(response[:body]).to eq('{}') + expect(response[:headers]).to eq({}) + expect(response[:canonical_ids]).to be_nil + expect(response[:not_registered_ids]).to be_nil end end - context "sending notification to a topic condition" do - let!(:stub_with_valid_condition) do - stub_request(:post, send_url) - .with(body: '{"condition":"\'TopicA\' in topics && (\'TopicB\' in topics || \'TopicC\' in topics)","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) - end - let!(:stub_with_invalid_condition) do - stub_request(:post, send_url) - .with(body: '{"condition":"\'TopicA\' in topics and some other text (\'TopicB\' in topics || \'TopicC\' in topics)","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) - end - let!(:stub_with_invalid_condition_topic) do - stub_request(:post, send_url) - .with(body: '{"condition":"\'TopicA$\' in topics","data":{"score":"5x1","time":"15:10"}}', - headers: valid_request_headers) - .to_return(status: 200, body: "", headers: {}) + describe 'send to multiple tokens' do + let(:tokens) { ['4sdsx', '4sdsy'] } + let(:send_v1_params) do + { + 'token' => tokens, + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + } + } end - describe "#send_to_topic_condition" do - it "should send the data in a post request to fcm" do - fcm = FCM.new(api_key) - fcm.send_to_topic_condition(valid_condition, data: { score: "5x1", time: "15:10" }) - stub_with_valid_condition.should have_been_requested - end - - it "should not send to invalid conditions" do - fcm = FCM.new(api_key) - fcm.send_to_topic_condition(invalid_condition, data: { score: "5x1", time: "15:10" }) - stub_with_invalid_condition.should_not have_been_requested - end - - it "should not send to invalid topics in a condition" do - fcm = FCM.new(api_key) - fcm.send_to_topic_condition(invalid_condition_topic, data: { score: "5x1", time: "15:10" }) - stub_with_invalid_condition_topic.should_not have_been_requested - end - end + include_examples 'succesfuly send notification' end - context "when send_notification responds with failure" do - let(:mock_request_attributes) do + describe 'send to topic' do + let(:topic) { 'news' } + let(:send_v1_params) do { - body: valid_request_body.to_json, - headers: valid_request_headers, + 'topic' => topic, + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + } } end - subject { FCM.new(api_key) } - - context "on failure code 400" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", - headers: {}, - status: 400, - ) - end - it "should not send notification due to 400" do - subject.send(registration_ids).should eq(body: "{}", - headers: {}, - response: "Only applies for JSON requests. Indicates that the request could not be parsed as JSON, or it contained invalid fields.", - status_code: 400) - end - end + include_examples 'succesfuly send notification' - context "on failure code 401" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", - headers: {}, - status: 401, - ) - end + context 'when topic is invalid' do + let(:topic) { '/topics/news$' } - it "should not send notification due to 401" do - subject.send(registration_ids).should eq(body: "{}", - headers: {}, - response: "There was an error authenticating the sender account.", - status_code: 401) + it 'should raise error' do + stub_fcm_send_v1_request.should_not have_been_requested end end + end - context "on failure code 503" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: "{}", - headers: {}, - status: 503, - ) - end - - it "should not send notification due to 503" do - subject.send(registration_ids).should eq(body: "{}", - headers: {}, - response: "Server is temporarily unavailable.", - status_code: 503) - end + describe 'send to condition' do + let(:condition) { "'foo' in topics" } + let(:send_v1_params) do + { + 'condition' => condition, + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + }, + } end - context "on failure code 5xx" do - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: '{"body-key" => "Body value"}', - headers: { "header-key" => "Header value" }, - status: 599, - ) - end - - it "should not send notification due to 599" do - subject.send(registration_ids).should eq(body: '{"body-key" => "Body value"}', - headers: { "header-key" => "Header value" }, - response: "There was an internal error in the FCM server while trying to process the request.", - status_code: 599) - end - end + include_examples 'succesfuly send notification' end - context "when send_notification responds canonical_ids" do - let(:mock_request_attributes) do + describe 'send to notification_key' do + let(:notification_key) { 'notification_key' } + let(:send_v1_params) do { - body: valid_request_body.to_json, - headers: valid_request_headers, + 'notification_key' => notification_key, + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + } } end - let(:valid_response_body_with_canonical_ids) do + include_examples 'succesfuly send notification' + end + + context 'when project_name is empty' do + let(:project_name) { '' } + let(:send_v1_params) do { - failure: 0, canonical_ids: 1, results: [{ registration_id: "43", message_id: "0:1385025861956342%572c22801bb3" }], + 'token' => '4sdsx', + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + } } end - subject { FCM.new(api_key) } + it 'should not send notification' do + client.send_v1(send_v1_params) + stub_fcm_send_v1_request.should_not have_been_requested + end + end - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - # ref: https://firebase.google.com/docs/cloud-messaging/http-server-ref#interpret-downstream - body: valid_response_body_with_canonical_ids.to_json, - headers: {}, - status: 200, - ) + describe 'error handling' do + let(:send_v1_params) do + { + 'token' => '4sdsx', + 'notification' => { + 'title' => 'Breaking News', + 'body' => 'New news story available.' + } + } end - it "should contain canonical_ids" do - response = subject.send(registration_ids) + context 'when status_code is 400' do + let(:status_code) { 400 } - response.should eq(headers: {}, - canonical_ids: [{ old: "42", new: "43" }], - not_registered_ids: [], - status_code: 200, - response: "success", - body: '{"failure":0,"canonical_ids":1,"results":[{"registration_id":"43","message_id":"0:1385025861956342%572c22801bb3"}]}') + it 'should raise error' do + response = client.send_v1(send_v1_params) + expect(response[:status_code]).to eq(status_code) + expect(response[:response]).to include('Only applies for JSON requests') + end end - end - context "when send_notification responds with NotRegistered" do - subject { FCM.new(api_key) } + context 'when status_code is 401' do + let(:status_code) { 401 } - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } + it 'should raise error' do + response = client.send_v1(send_v1_params) + expect(response[:status_code]).to eq(status_code) + expect(response[:response]).to include('There was an error authenticating') + end end - let(:valid_response_body_with_not_registered_ids) do - { - canonical_ids: 0, failure: 1, results: [{ error: "NotRegistered" }], - } - end + context 'when status_code is 500' do + let(:status_code) { 500 } - before do - stub_request(:post, send_url).with( - mock_request_attributes - ).to_return( - body: valid_response_body_with_not_registered_ids.to_json, - headers: {}, - status: 200, - ) + it 'should raise error' do + response = client.send_v1(send_v1_params) + expect(response[:status_code]).to eq(status_code) + expect(response[:response]).to include('There was an internal error') + end end - it "should contain not_registered_ids" do - response = subject.send(registration_ids) - response.should eq( - headers: {}, - canonical_ids: [], - not_registered_ids: registration_ids, - status_code: 200, - response: "success", - body: '{"canonical_ids":0,"failure":1,"results":[{"error":"NotRegistered"}]}', - ) + context 'when status_code is 503' do + let(:status_code) { 503 } + + it 'should raise error' do + response = client.send_v1(send_v1_params) + expect(response[:status_code]).to eq(status_code) + expect(response[:response]).to include('Server is temporarily unavailable') + end end end end - describe "sending group notifications" do - # TODO: refactor to should_behave_like - let(:valid_request_headers) do + describe '#send_to_topic' do + let(:client) { FCM.new(json_key_path, project_name) } + + let(:uri) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } + + let(:topic) { 'news' } + let(:params) do { - "Authorization" => "key=#{api_key}", - "Content-Type" => "application/json", - "Project-Id" => project_id, - } - end - let(:valid_response_body) do - { notification_key: "APA91bGHXQBB...9QgnYOEURwm0I3lmyqzk2TXQ" } + 'topic' => topic + }.merge(options) end - - let(:default_valid_request_body) do + let(:options) do { - registration_ids: registration_ids, - operation: "create", - notification_key_name: key_name, + 'data' => { + 'story_id' => 'story_12345' + } } end - subject { FCM.new(api_key) } - - # ref: https://firebase.google.com/docs/cloud-messaging/notifications#managing-device-groups-on-the-app-server - context "create" do - let(:valid_request_body) do - default_valid_request_body.merge({ - operation: "create", - }) - end + let(:stub_fcm_send_to_topic_request) do + stub_request(:post, uri).with( + body: { 'message' => params }.to_json, + headers: mock_headers + ).to_return( + body: "{}", + headers: {}, + status: 200, + ) + end - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } - end + before do + stub_fcm_send_to_topic_request + end - before do - stub_request(:post, group_notification_base_uri).with( - mock_request_attributes - ).to_return( - body: valid_response_body.to_json, - headers: {}, - status: 200, - ) - end + it 'should send notification to topic using POST to FCM server' do + client.send_to_topic(topic, options).should eq( + response: 'success', body: '{}', headers: {}, status_code: 200 + ) + stub_fcm_send_to_topic_request.should have_been_made.times(1) + end - it "should send a post request" do - response = subject.create(key_name, project_id, registration_ids) - response.should eq( - headers: {}, - status_code: 200, - response: "success", - body: valid_response_body.to_json, - ) - end - end # create context - - context "add" do - let(:valid_request_body) do - default_valid_request_body.merge({ - operation: "add", - notification_key: notification_key, - }) - end + context 'when topic is invalid' do + let(:topic) { '/topics/news$' } - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, - } + it 'should raise error' do + client.send_to_topic(topic, options) + stub_fcm_send_to_topic_request.should_not have_been_requested end + end + end - before do - stub_request(:post, group_notification_base_uri).with( - mock_request_attributes - ).to_return( - body: valid_response_body.to_json, - headers: {}, - status: 200, - ) - end + describe "#send_to_topic_condition" do + let(:client) { FCM.new(json_key_path, project_name) } - it "should send a post request" do - response = subject.add(key_name, project_id, notification_key, registration_ids) - response.should eq( - headers: {}, - status_code: 200, - response: "success", - body: valid_response_body.to_json, - ) - end - end # add context - - context "remove" do - let(:valid_request_body) do - default_valid_request_body.merge({ - operation: "remove", - notification_key: notification_key, - }) - end + let(:uri) { "#{FCM::BASE_URI_V1}#{project_name}/messages:send" } - let(:mock_request_attributes) do - { - body: valid_request_body.to_json, - headers: valid_request_headers, + let(:topic_condition) { "'foo' in topics" } + let(:params) do + { + 'condition' => topic_condition + }.merge(options) + end + let(:options) do + { + 'data' => { + 'story_id' => 'story_12345' } - end + } + end - before do - stub_request(:post, group_notification_base_uri).with( - mock_request_attributes - ).to_return( - body: valid_response_body.to_json, - headers: {}, - status: 200, - ) - end + let(:stub_fcm_send_to_topic_condition_request) do + stub_request(:post, uri).with( + body: { 'message' => params }.to_json, + headers: mock_headers + ).to_return( + body: "{}", + headers: {}, + status: 200, + ) + end - it "should send a post request" do - response = subject.remove(key_name, project_id, notification_key, registration_ids) - response.should eq( - headers: {}, - status_code: 200, - response: "success", - body: valid_response_body.to_json, - ) - end - end # remove context - end + before do + stub_fcm_send_to_topic_condition_request + end - describe "#recover_notification_key" do - it "sends a 'retrieve notification key' request" do - uri = "#{FCM::GROUP_NOTIFICATION_BASE_URI}/gcm/notification" - endpoint = stub_request(:get, uri).with( - headers: { - "Content-Type" => "application/json", - "Authorization" => "key=TEST_SERVER_KEY", - "project_id" => "TEST_PROJECT_ID", - }, - query: { notification_key_name: "TEST_KEY_NAME" }, + it 'should send notification to topic_condition using POST to FCM server' do + client.send_to_topic_condition(topic_condition, options).should eq( + response: 'success', body: '{}', headers: {}, status_code: 200 ) - client = FCM.new("TEST_SERVER_KEY") + stub_fcm_send_to_topic_condition_request.should have_been_made.times(1) + end - client.recover_notification_key("TEST_KEY_NAME", "TEST_PROJECT_ID") + context 'when topic_condition is invalid' do + let(:topic_condition) { "'foo' in topics$" } - expect(endpoint).to have_been_requested + it 'should raise error' do + client.send_to_topic_condition(topic_condition, options) + stub_fcm_send_to_topic_condition_request.should_not have_been_requested + end end end - describe "subscribing to a topic" do - # TODO - end - - describe 'getting instance info' do - subject(:get_info) { client.get_instance_id_info(registration_id, options) } + describe "#get_instance_id_info" do + subject(:get_info) { client.get_instance_id_info(registration_token, options) } let(:options) { nil } - let(:client) { FCM.new('TEST_SERVER_KEY') } let(:base_uri) { "#{FCM::INSTANCE_ID_API}/iid/info" } - let(:uri) { "#{base_uri}/#{registration_id}" } - let(:mock_request_attributes) do - { headers: { - 'Authorization' => 'key=TEST_SERVER_KEY', - 'Content-Type' => 'application/json' - } } - end + let(:uri) { "#{base_uri}/#{registration_token}" } + let(:registration_token) { "42" } context 'without options' do it 'calls info endpoint' do - endpoint = stub_request(:get, uri).with(mock_request_attributes) + endpoint = stub_request(:get, uri).with(headers: mock_headers) get_info expect(endpoint).to have_been_requested end end context 'with detail option' do - let(:uri) { "#{base_uri}/#{registration_id}?details=true" } + let(:uri) { "#{base_uri}/#{registration_token}?details=true" } let(:options) { { details: true } } it 'calls info endpoint' do - endpoint = stub_request(:get, uri).with(mock_request_attributes) + endpoint = stub_request(:get, uri).with(headers: mock_headers) get_info expect(endpoint).to have_been_requested end end end - describe "credentials path" do - it "can be a path to a file" do - fcm = FCM.new("test", "README.md") - expect(fcm.__send__(:json_key).class).to eq(File) + describe "topic subscriptions" do + let(:topic) { 'news' } + let(:registration_token) { "42" } + let(:registration_token_2) { "43" } + let(:registration_tokens) { [registration_token, registration_token_2] } + + describe "#topic_subscription" do + subject(:subscribe) { client.topic_subscription(topic, registration_token) } + + let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1/#{registration_token}/rel/topics/#{topic}" } + + it 'subscribes to a topic' do + endpoint = stub_request(:post, uri).with(headers: mock_headers) + subscribe + expect(endpoint).to have_been_requested + end end - it "can be an IO object" do - fcm = FCM.new("test", StringIO.new("hey")) - expect(fcm.__send__(:json_key).class).to eq(StringIO) + describe "#topic_unsubscription" do + subject(:unsubscribe) { client.topic_unsubscription(topic, registration_token) } + + let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1:batchRemove" } + let(:params) { { to: "/topics/#{topic}", registration_tokens: [registration_token] } } + + it 'unsubscribes from a topic' do + endpoint = stub_request(:post, uri).with(body: params.to_json, headers: mock_headers) + unsubscribe + expect(endpoint).to have_been_requested + end + end + + describe "#batch_topic_subscription" do + subject(:batch_subscribe) { client.batch_topic_subscription(topic, registration_tokens) } + + let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1:batchAdd" } + let(:params) { { to: "/topics/#{topic}", registration_tokens: registration_tokens } } + + it 'subscribes to a topic' do + endpoint = stub_request(:post, uri).with(body: params.to_json, headers: mock_headers) + batch_subscribe + expect(endpoint).to have_been_requested + end + end + + describe "#batch_topic_unsubscription" do + subject(:batch_unsubscribe) { client.batch_topic_unsubscription(topic, registration_tokens) } + + let(:uri) { "#{FCM::INSTANCE_ID_API}/iid/v1:batchRemove" } + let(:params) { { to: "/topics/#{topic}", registration_tokens: registration_tokens } } + + it 'unsubscribes from a topic' do + endpoint = stub_request(:post, uri).with(body: params.to_json, headers: mock_headers) + batch_unsubscribe + expect(endpoint).to have_been_requested + end end end end