diff --git a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs index c3c411ce..bad66fdf 100644 --- a/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.IntegrationTests/FirebaseMessagingTest.cs @@ -52,6 +52,78 @@ public async Task Send() Assert.Matches(new Regex("^projects/.*/messages/.*$"), id); } + [Fact] + public async Task SendEach() + { + var message1 = new Message() + { + Topic = "foo-bar", + Notification = new Notification() + { + Title = "Title", + Body = "Body", + ImageUrl = "https://example.com/image.png", + }, + Android = new AndroidConfig() + { + Priority = Priority.Normal, + TimeToLive = TimeSpan.FromHours(1), + RestrictedPackageName = "com.google.firebase.testing", + }, + }; + var message2 = new Message() + { + Topic = "fiz-buz", + Notification = new Notification() + { + Title = "Title", + Body = "Body", + }, + Android = new AndroidConfig() + { + Priority = Priority.Normal, + TimeToLive = TimeSpan.FromHours(1), + RestrictedPackageName = "com.google.firebase.testing", + }, + }; + var response = await FirebaseMessaging.DefaultInstance.SendEachAsync(new[] { message1, message2 }, dryRun: true); + Assert.NotNull(response); + Assert.Equal(2, response.SuccessCount); + Assert.True(!string.IsNullOrEmpty(response.Responses[0].MessageId)); + Assert.Matches(new Regex("^projects/.*/messages/.*$"), response.Responses[0].MessageId); + Assert.True(!string.IsNullOrEmpty(response.Responses[1].MessageId)); + Assert.Matches(new Regex("^projects/.*/messages/.*$"), response.Responses[1].MessageId); + } + + [Fact] + public async Task SendEachForMulticast() + { + var multicastMessage = new MulticastMessage + { + Notification = new Notification() + { + Title = "Title", + Body = "Body", + }, + Android = new AndroidConfig() + { + Priority = Priority.Normal, + TimeToLive = TimeSpan.FromHours(1), + RestrictedPackageName = "com.google.firebase.testing", + }, + Tokens = new[] + { + "token1", + "token2", + }, + }; + var response = await FirebaseMessaging.DefaultInstance.SendEachForMulticastAsync(multicastMessage, dryRun: true); + Assert.NotNull(response); + Assert.Equal(2, response.FailureCount); + Assert.NotNull(response.Responses[0].Exception); + Assert.NotNull(response.Responses[1].Exception); + } + [Fact] public async Task SendAll() { diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs index 749a64a7..7ca8e114 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/Messaging/FirebaseMessagingClientTest.cs @@ -133,6 +133,212 @@ public async Task SendDryRunAsync() this.CheckHeaders(handler.LastRequestHeaders); } + [Fact] + public async Task SendEachAsync() + { + var handler = new MockMessageHandler() + { + GenerateResponse = (incomingRequest) => + { + string name; + if (incomingRequest.Body.Contains("test-token1")) + { + name = "projects/fir-adminintegrationtests/messages/8580920590356323124"; + } + else + { + name = "projects/fir-adminintegrationtests/messages/5903525881088369386"; + } + + return new FirebaseMessagingClient.SingleMessageResponse() + { + Name = name, + }; + }, + }; + var factory = new MockHttpClientFactory(handler); + var client = this.CreateMessagingClient(factory); + var message1 = new Message() + { + Token = "test-token1", + }; + var message2 = new Message() + { + Token = "test-token2", + }; + + var response = await client.SendEachAsync(new[] { message1, message2 }); + + Assert.Equal(2, response.SuccessCount); + Assert.Equal("projects/fir-adminintegrationtests/messages/8580920590356323124", response.Responses[0].MessageId); + Assert.Equal("projects/fir-adminintegrationtests/messages/5903525881088369386", response.Responses[1].MessageId); + Assert.Equal(2, handler.Calls); + } + + [Fact] + public async Task SendEachAsyncWithError() + { + // Return a success for `message1` and an error for `message2` + var handler = new MockMessageHandler() + { + GenerateResponse = (incomingRequest) => + { + string name; + if (incomingRequest.Body.Contains("test-token1")) + { + name = "projects/fir-adminintegrationtests/messages/8580920590356323124"; + return new FirebaseMessagingClient.SingleMessageResponse() + { + Name = name, + }; + } + else + { + return @"{ + ""error"": { + ""status"": ""INVALID_ARGUMENT"", + ""message"": ""The registration token is not a valid FCM registration token"", + ""details"": [ + { + ""@type"": ""type.googleapis.com/google.firebase.fcm.v1.FcmError"", + ""errorCode"": ""UNREGISTERED"" + } + ] + } + }"; + } + }, + GenerateStatusCode = (incomingRequest) => + { + if (incomingRequest.Body.Contains("test-token1")) + { + return HttpStatusCode.OK; + } + else + { + return HttpStatusCode.InternalServerError; + } + }, + }; + var factory = new MockHttpClientFactory(handler); + var client = this.CreateMessagingClient(factory); + var message1 = new Message() + { + Token = "test-token1", + }; + var message2 = new Message() + { + Token = "test-token2", + }; + + var response = await client.SendEachAsync(new[] { message1, message2 }); + + Assert.Equal(1, response.SuccessCount); + Assert.Equal(1, response.FailureCount); + Assert.Equal("projects/fir-adminintegrationtests/messages/8580920590356323124", response.Responses[0].MessageId); + + var exception = response.Responses[1].Exception; + Assert.NotNull(exception); + Assert.Equal(ErrorCode.InvalidArgument, exception.ErrorCode); + Assert.Equal("The registration token is not a valid FCM registration token", exception.Message); + Assert.Equal(MessagingErrorCode.Unregistered, exception.MessagingErrorCode); + Assert.NotNull(exception.HttpResponse); + + Assert.Equal(2, handler.Calls); + } + + [Fact] + public async Task SendEachAsyncWithErrorNoDetail() + { + // Return a success for `message1` and an error for `message2` + var handler = new MockMessageHandler() + { + GenerateResponse = (incomingRequest) => + { + string name; + if (incomingRequest.Body.Contains("test-token1")) + { + name = "projects/fir-adminintegrationtests/messages/8580920590356323124"; + return new FirebaseMessagingClient.SingleMessageResponse() + { + Name = name, + }; + } + else + { + return @"{ + ""error"": { + ""status"": ""INVALID_ARGUMENT"", + ""message"": ""The registration token is not a valid FCM registration token"", + } + }"; + } + }, + GenerateStatusCode = (incomingRequest) => + { + if (incomingRequest.Body.Contains("test-token1")) + { + return HttpStatusCode.OK; + } + else + { + return HttpStatusCode.InternalServerError; + } + }, + }; + var factory = new MockHttpClientFactory(handler); + var client = this.CreateMessagingClient(factory); + var message1 = new Message() + { + Token = "test-token1", + }; + var message2 = new Message() + { + Token = "test-token2", + }; + + var response = await client.SendEachAsync(new[] { message1, message2 }); + + Assert.Equal(1, response.SuccessCount); + Assert.Equal(1, response.FailureCount); + Assert.Equal("projects/fir-adminintegrationtests/messages/8580920590356323124", response.Responses[0].MessageId); + + var exception = response.Responses[1].Exception; + Assert.NotNull(exception); + Assert.Equal(ErrorCode.InvalidArgument, exception.ErrorCode); + Assert.Equal("The registration token is not a valid FCM registration token", exception.Message); + Assert.Null(exception.MessagingErrorCode); + Assert.NotNull(exception.HttpResponse); + + Assert.Equal(2, handler.Calls); + } + + [Fact] + public async Task SendEachAsyncNullList() + { + var factory = new MockHttpClientFactory(new MockMessageHandler()); + var client = this.CreateMessagingClient(factory); + + await Assert.ThrowsAsync(() => client.SendEachAsync(null)); + } + + [Fact] + public async Task SendEachAsyncWithNoMessages() + { + var factory = new MockHttpClientFactory(new MockMessageHandler()); + var client = this.CreateMessagingClient(factory); + await Assert.ThrowsAsync(() => client.SendEachAsync(Enumerable.Empty())); + } + + [Fact] + public async Task SendEachAsyncWithTooManyMessages() + { + var factory = new MockHttpClientFactory(new MockMessageHandler()); + var client = this.CreateMessagingClient(factory); + var messages = Enumerable.Range(0, 501).Select(_ => new Message { Topic = "test-topic" }); + await Assert.ThrowsAsync(() => client.SendEachAsync(messages)); + } + [Fact] public async Task SendAllAsync() { diff --git a/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs b/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs index d2a65962..32c3f307 100644 --- a/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs +++ b/FirebaseAdmin/FirebaseAdmin.Tests/MockMessageHandler.cs @@ -40,6 +40,10 @@ public MockMessageHandler() public delegate void SetHeaders(HttpResponseHeaders respHeaders, HttpContentHeaders contentHeaders); + public delegate object GetResponse(IncomingRequest request); + + public delegate HttpStatusCode GetStatusCode(IncomingRequest request); + /// /// Gets the list of request bodies processed by this handler. /// @@ -89,6 +93,16 @@ public HttpRequestHeaders LastRequestHeaders /// public SetHeaders ApplyHeaders { get; set; } + /// + /// Gets or sets the function for generating the response based on the incoming request. + /// + public GetResponse GenerateResponse { get; set; } + + /// + /// Gets or sets the function for generating the status code based on the incoming request. + /// + public GetStatusCode GenerateStatusCode { get; set; } + protected override async Task DoSendAsync( HttpRequestMessage request, int count, CancellationToken cancellationToken) { @@ -102,6 +116,16 @@ protected override async Task DoSendAsync( return await tcs.Task; } + if (this.GenerateResponse != null) + { + this.Response = this.GenerateResponse(incomingRequest); + } + + if (this.GenerateStatusCode != null) + { + this.StatusCode = this.GenerateStatusCode(incomingRequest); + } + string json; if (this.Response is byte[]) { diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessaging.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessaging.cs index 6271fd33..41d78bd1 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessaging.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessaging.cs @@ -171,6 +171,168 @@ public async Task SendAsync( message, dryRun, cancellationToken).ConfigureAwait(false); } + /// + /// Sends each message in the given list via Firebase Cloud Messaging. Unlike + /// , this method makes an HTTP call + /// for each message in the given list. + /// + /// If an error occurs while sending the + /// messages. + /// Up to 500 messages to send in the batch. Cannot be null or + /// empty. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachAsync(IEnumerable messages) + { + return await this.SendEachAsync(messages, false) + .ConfigureAwait(false); + } + + /// + /// Sends each message in the given list via Firebase Cloud Messaging. Unlike + /// , this method makes + /// an HTTP call for each message in the given list. + /// + /// If an error occurs while sending the + /// messages. + /// Up to 500 messages to send in the batch. Cannot be null or + /// empty. + /// A cancellation token to monitor the asynchronous + /// operation. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachAsync(IEnumerable messages, CancellationToken cancellationToken) + { + return await this.SendEachAsync(messages, false, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Sends each message in the given list via Firebase Cloud Messaging. Unlike + /// , this method makes an HTTP + /// call for each message in the given list. + /// + /// If an error occurs while sending the + /// messages. + /// Up to 500 messages to send in the batch. Cannot be null or + /// empty. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the message will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachAsync(IEnumerable messages, bool dryRun) + { + return await this.SendEachAsync(messages, dryRun, default) + .ConfigureAwait(false); + } + + /// + /// Sends each message in the given list via Firebase Cloud Messaging. Unlike + /// , this method + /// makes an HTTP call for each message in the given list. + /// + /// If an error occurs while sending the + /// messages. + /// Up to 500 messages to send in the batch. Cannot be null or + /// empty. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the message will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + /// A cancellation token to monitor the asynchronous + /// operation. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachAsync(IEnumerable messages, bool dryRun, CancellationToken cancellationToken) + { + return await this.messagingClient.SendEachAsync(messages, dryRun, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Sends the given multicast message to all the FCM registration tokens specified in it. + /// Unlike , this method makes an + /// HTTP call for each token in the given multicast message. + /// + /// If an error occurs while sending the + /// messages. + /// The message to be sent. Must not be null or empty. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachForMulticastAsync(MulticastMessage message) + { + return await this.SendEachForMulticastAsync(message, false) + .ConfigureAwait(false); + } + + /// + /// Sends the given multicast message to all the FCM registration tokens specified in it. + /// Unlike , this + /// method makes an HTTP call for each token in the given multicast message. + /// + /// If an error occurs while sending the + /// messages. + /// The message to be sent. Must not be null or empty. + /// A cancellation token to monitor the asynchronous + /// operation. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachForMulticastAsync(MulticastMessage message, CancellationToken cancellationToken) + { + return await this.SendEachForMulticastAsync(message, false, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// Sends the given multicast message to all the FCM registration tokens specified in it. + /// Unlike , this method makes an + /// HTTP call for each token in the given multicast message. + /// If the option is set to true, the message will not be + /// actually sent to the recipients. Instead, the FCM service performs all the necessary + /// validations, and emulates the send operation. This is a good way to check if a + /// certain message will be accepted by FCM for delivery. + /// + /// If an error occurs while sending the + /// messages. + /// The message to be sent. Must not be null or empty. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the message will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachForMulticastAsync(MulticastMessage message, bool dryRun) + { + return await this.SendEachForMulticastAsync(message, dryRun, default) + .ConfigureAwait(false); + } + + /// + /// Sends the given multicast message to all the FCM registration tokens specified in it. + /// Unlike , + /// this method makes an HTTP call for each token in the given multicast message. + /// If the option is set to true, the message will not be + /// actually sent to the recipients. Instead, the FCM service performs all the necessary + /// validations, and emulates the send operation. This is a good way to check if a + /// certain message will be accepted by FCM for delivery. + /// + /// If an error occurs while sending the + /// messages. + /// The message to be sent. Must not be null or empty. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the message will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + /// A cancellation token to monitor the asynchronous + /// operation. + /// A containing details of the batch operation's + /// outcome. + public async Task SendEachForMulticastAsync( + MulticastMessage message, bool dryRun, CancellationToken cancellationToken) + { + return await this.SendEachAsync( + message.GetMessageList(), dryRun, cancellationToken) + .ConfigureAwait(false); + } + /// /// Sends all the messages in the given list via Firebase Cloud Messaging. Employs batching to /// send the entire list as a single RPC call. Compared to the @@ -178,9 +340,10 @@ public async Task SendAsync( /// /// If an error occurs while sending the /// messages. - /// Up to 100 messages to send in the batch. Cannot be null. + /// Up to 500 messages to send in the batch. Cannot be null. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendAllAsync(IEnumerable messages) { return await this.SendAllAsync(messages, false) @@ -194,11 +357,12 @@ public async Task SendAllAsync(IEnumerable messages) /// /// If an error occurs while sending the /// messages. - /// Up to 100 messages to send in the batch. Cannot be null. + /// Up to 500 messages to send in the batch. Cannot be null. /// A cancellation token to monitor the asynchronous /// operation. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendAllAsync(IEnumerable messages, CancellationToken cancellationToken) { return await this.SendAllAsync(messages, false, cancellationToken) @@ -212,12 +376,13 @@ public async Task SendAllAsync(IEnumerable messages, Can /// /// If an error occurs while sending the /// messages. - /// Up to 100 messages to send in the batch. Cannot be null. + /// Up to 500 messages to send in the batch. Cannot be null. /// A boolean indicating whether to perform a dry run (validation /// only) of the send. If set to true, the message will be sent to the FCM backend service, /// but it will not be delivered to any actual recipients. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendAllAsync(IEnumerable messages, bool dryRun) { return await this.SendAllAsync(messages, dryRun, default) @@ -231,7 +396,7 @@ public async Task SendAllAsync(IEnumerable messages, boo /// /// If an error occurs while sending the /// messages. - /// Up to 100 messages to send in the batch. Cannot be null. + /// Up to 500 messages to send in the batch. Cannot be null. /// A boolean indicating whether to perform a dry run (validation /// only) of the send. If set to true, the message will be sent to the FCM backend service, /// but it will not be delivered to any actual recipients. @@ -239,6 +404,7 @@ public async Task SendAllAsync(IEnumerable messages, boo /// operation. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendAllAsync(IEnumerable messages, bool dryRun, CancellationToken cancellationToken) { return await this.messagingClient.SendAllAsync(messages, dryRun, cancellationToken) @@ -253,6 +419,7 @@ public async Task SendAllAsync(IEnumerable messages, boo /// The message to be sent. Must not be null. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendMulticastAsync(MulticastMessage message) { return await this.SendMulticastAsync(message, false) @@ -269,6 +436,7 @@ public async Task SendMulticastAsync(MulticastMessage message) /// operation. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendMulticastAsync(MulticastMessage message, CancellationToken cancellationToken) { return await this.SendMulticastAsync(message, false, cancellationToken) @@ -290,6 +458,7 @@ public async Task SendMulticastAsync(MulticastMessage message, Ca /// but it will not be delivered to any actual recipients. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendMulticastAsync(MulticastMessage message, bool dryRun) { return await this.SendMulticastAsync(message, dryRun, default) @@ -313,6 +482,7 @@ public async Task SendMulticastAsync(MulticastMessage message, bo /// operation. /// A containing details of the batch operation's /// outcome. + /// [Obsolete(Use instead)] public async Task SendMulticastAsync( MulticastMessage message, bool dryRun, CancellationToken cancellationToken) { diff --git a/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessagingClient.cs b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessagingClient.cs index 3fe26092..f75981ea 100644 --- a/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessagingClient.cs +++ b/FirebaseAdmin/FirebaseAdmin/Messaging/FirebaseMessagingClient.cs @@ -126,6 +126,49 @@ public async Task SendAsync( return response.Result.Name; } + /// + /// Sends each message in a single batch. + /// + /// The messages to be sent. Must not be null. + /// A boolean indicating whether to perform a dry run (validation + /// only) of the send. If set to true, the messages will be sent to the FCM backend service, + /// but it will not be delivered to any actual recipients. + /// A cancellation token to monitor the asynchronous + /// operation. + /// A task that completes with a , giving details about + /// the batch operation. + public async Task SendEachAsync( + IEnumerable messages, + bool dryRun = false, + CancellationToken cancellationToken = default(CancellationToken)) + { + var copyOfMessages = messages.ThrowIfNull(nameof(messages)) + .Select((message) => message.CopyAndValidate()) + .ToList(); + + if (copyOfMessages.Count < 1) + { + throw new ArgumentException("At least one message is required."); + } + + if (copyOfMessages.Count > 500) + { + throw new ArgumentException("At most 500 messages are allowed."); + } + + var tasks = new List>(); + + for (int i = 0; i < copyOfMessages.Count; i++) + { + tasks.Add(this.SendAsyncForSendResponse(copyOfMessages[i], dryRun, cancellationToken)); + } + + // No task should throw an exception because any exception should be caught + // within `SendAsyncForSendResponse` and turned into a `SendResponse` + var responses = await Task.WhenAll(tasks).ConfigureAwait(false); + return new BatchResponse(responses); + } + /// /// Sends all messages in a single batch. /// @@ -251,6 +294,31 @@ private BatchRequest CreateBatchRequest( return batch; } + private async Task SendAsyncForSendResponse( + Message message, + bool dryRun = false, + CancellationToken cancellationToken = default(CancellationToken)) + { + try + { + var messageId = await this.SendAsync(message, dryRun, cancellationToken).ConfigureAwait(false); + return SendResponse.FromMessageId(messageId); + } + catch (FirebaseMessagingException e) + { + return SendResponse.FromException(e); + } + catch (HttpRequestException e) + { + return SendResponse.FromException(MessagingErrorHandler.Instance.HandleHttpRequestException(e)); + } + catch (Exception e) + { + var exception = new FirebaseMessagingException(ErrorCode.Unknown, $"{e}"); + return SendResponse.FromException(exception); + } + } + /// /// Represents the envelope message accepted by the FCM backend service, including the message /// payload and other options like validate_only.