diff --git a/src/Altinn.Notifications.Sms.Core/Altinn.Notifications.Sms.Core.csproj b/src/Altinn.Notifications.Sms.Core/Altinn.Notifications.Sms.Core.csproj index c08abc7..d3a04b3 100644 --- a/src/Altinn.Notifications.Sms.Core/Altinn.Notifications.Sms.Core.csproj +++ b/src/Altinn.Notifications.Sms.Core/Altinn.Notifications.Sms.Core.csproj @@ -3,6 +3,7 @@ net8.0 enable + true enable {184897A2-F52D-400A-BD50-FEB6554845AF} diff --git a/src/Altinn.Notifications.Sms.Core/Configuration/ServiceCollectionExtensions.cs b/src/Altinn.Notifications.Sms.Core/Configuration/ServiceCollectionExtensions.cs index 97d9668..fa0ec8c 100644 --- a/src/Altinn.Notifications.Sms.Core/Configuration/ServiceCollectionExtensions.cs +++ b/src/Altinn.Notifications.Sms.Core/Configuration/ServiceCollectionExtensions.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.Configuration; +using Altinn.Notifications.Sms.Core.Sending; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; namespace Altinn.Notifications.Sms.Core.Configuration; @@ -16,6 +17,15 @@ public static class ServiceCollectionExtensions /// The given service collection. public static IServiceCollection AddCoreServices(this IServiceCollection services, IConfiguration config) { + TopicSettings topicSettings = config!.GetSection("KafkaSettings").Get()!; + + if (topicSettings == null) + { + throw new ArgumentNullException(nameof(config), "Required Kafka settings is missing from application configuration"); + } + + services.AddSingleton(); + services.AddSingleton(topicSettings); return services; } } diff --git a/src/Altinn.Notifications.Sms.Core/Configuration/TopicSettings.cs b/src/Altinn.Notifications.Sms.Core/Configuration/TopicSettings.cs new file mode 100644 index 0000000..0452f3a --- /dev/null +++ b/src/Altinn.Notifications.Sms.Core/Configuration/TopicSettings.cs @@ -0,0 +1,12 @@ +namespace Altinn.Notifications.Sms.Core.Configuration; + +/// +/// Configuration object used to hold topic names for core services to publish to in Kafka. +/// +public class TopicSettings +{ + /// + /// The name of the sms status updated topic + /// + public string SmsStatusUpdatedTopicName { get; set; } = string.Empty; +} diff --git a/src/Altinn.Notifications.Sms.Core/Sending/ISendingService.cs b/src/Altinn.Notifications.Sms.Core/Sending/ISendingService.cs new file mode 100644 index 0000000..a90faba --- /dev/null +++ b/src/Altinn.Notifications.Sms.Core/Sending/ISendingService.cs @@ -0,0 +1,14 @@ +namespace Altinn.Notifications.Sms.Core.Sending; + +/// +/// Describes the required public method of the sms service. +/// +public interface ISendingService +{ + /// + /// Send an sms + /// + /// The details for an sms to be sent. + /// A task representing the asynchronous operation + Task SendAsync(Sms sms); +} diff --git a/src/Altinn.Notifications.Sms.Core/Sending/SendingService.cs b/src/Altinn.Notifications.Sms.Core/Sending/SendingService.cs new file mode 100644 index 0000000..6c465bb --- /dev/null +++ b/src/Altinn.Notifications.Sms.Core/Sending/SendingService.cs @@ -0,0 +1,56 @@ +using Altinn.Notifications.Sms.Core.Configuration; +using Altinn.Notifications.Sms.Core.Dependencies; +using Altinn.Notifications.Sms.Core.Shared; +using Altinn.Notifications.Sms.Core.Status; + +namespace Altinn.Notifications.Sms.Core.Sending; + +/// +/// Service responsible for handling sms sending requests. +/// +public class SendingService : ISendingService +{ + private readonly ISmsClient _smsClient; + private readonly TopicSettings _settings; + private readonly ICommonProducer _producer; + + /// + /// Initializes a new instance of the class. + /// + /// A client that can perform actual sms sending. + /// A kafka producer. + /// The topic settings. + public SendingService(ISmsClient smsClient, ICommonProducer producer, TopicSettings settings) + { + _smsClient = smsClient; + _producer = producer; + _settings = settings; + } + + /// + public async Task SendAsync(Sms sms) + { + Result result = await _smsClient.SendAsync(sms); + + SendOperationResult operationResult = new SendOperationResult + { + NotificationId = sms.NotificationId, + }; + + await result.Match( + async gatewayReference => + { + operationResult.GatewayReference = gatewayReference; + operationResult.SendResult = SmsSendResult.Accepted; + + await _producer.ProduceAsync(_settings.SmsStatusUpdatedTopicName, operationResult.Serialize()); + }, + async smsSendFailResponse => + { + operationResult.GatewayReference = string.Empty; + operationResult.SendResult = smsSendFailResponse.SendResult; + + await _producer.ProduceAsync(_settings.SmsStatusUpdatedTopicName, operationResult.Serialize()); + }); + } +} diff --git a/src/Altinn.Notifications.Sms.Core/Sending/Sms.cs b/src/Altinn.Notifications.Sms.Core/Sending/Sms.cs index 114dd76..dc7051e 100644 --- a/src/Altinn.Notifications.Sms.Core/Sending/Sms.cs +++ b/src/Altinn.Notifications.Sms.Core/Sending/Sms.cs @@ -1,26 +1,83 @@ -namespace Altinn.Notifications.Sms.Core.Sending +using System.Text.Json; + +namespace Altinn.Notifications.Sms.Core.Sending; + +/// +/// Class representing an sms message +/// +public class Sms { /// - /// Class representing an sms message + /// Gets or sets the id of the sms. + /// + public Guid NotificationId { get; set; } + + /// + /// Gets or sets the recipient of the sms message + /// + public string Recipient { get; set; } = string.Empty; + + /// + /// Gets or sets the sender of the sms message + /// + /// + /// Can be a literal string or a phone number + /// + public string Sender { get; set; } = string.Empty; + + /// + /// Gets or sets the contents of the sms message + /// + public string Message { get; set; } = string.Empty; + + /// + /// Initializes a new instance of the class. /// - public class Sms + public Sms(Guid notificationId, string recipient, string sender, string message) { - /// - /// Gets or sets the contents of the sms message - /// - public string Message { get; set; } = string.Empty; - - /// - /// Gets or sets the recipient of the sms message - /// - public string Recipient { get; set; } = string.Empty; - - /// - /// Gets or sets the sender of the sms message - /// - /// - /// Can be a literal string or a phone number - /// - public string Sender { get; set; } = string.Empty; + NotificationId = notificationId; + Recipient = recipient; + Sender = sender; + Message = message; + } + + /// + /// Initializes a new instance of the class. + /// + public Sms() + { + } + + /// + /// Try to parse a json string into a + /// + public static bool TryParse(string input, out Sms value) + { + Sms? parsedOutput; + value = new Sms(); + + if (string.IsNullOrEmpty(input)) + { + return false; + } + + try + { + parsedOutput = JsonSerializer.Deserialize( + input!, + new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true + }); + + value = parsedOutput!; + return value.NotificationId != Guid.Empty; + } + catch + { + // try parse, we simply return false if fails + } + + return false; } } diff --git a/src/Altinn.Notifications.Sms.Core/Status/SendOperationResult.cs b/src/Altinn.Notifications.Sms.Core/Status/SendOperationResult.cs new file mode 100644 index 0000000..bd83596 --- /dev/null +++ b/src/Altinn.Notifications.Sms.Core/Status/SendOperationResult.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Altinn.Notifications.Sms.Core.Status; + +/// +/// A class representing a send operation update object +/// +public class SendOperationResult +{ + /// + /// The notification id + /// + public Guid NotificationId { get; set; } + + /// + /// The reference to the sending in sms gateway + /// + public string GatewayReference { get; set; } = string.Empty; + + /// + /// The sms send result + /// + public SmsSendResult? SendResult { get; set; } + + /// + /// Json serializes the + /// + public string Serialize() + { + return JsonSerializer.Serialize( + this, + new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new JsonStringEnumConverter() } + }); + } +} diff --git a/src/Altinn.Notifications.Sms.Core/Status/SmsSendResult.cs b/src/Altinn.Notifications.Sms.Core/Status/SmsSendResult.cs index ff4edbc..7e174b9 100644 --- a/src/Altinn.Notifications.Sms.Core/Status/SmsSendResult.cs +++ b/src/Altinn.Notifications.Sms.Core/Status/SmsSendResult.cs @@ -5,9 +5,24 @@ /// public enum SmsSendResult { + /// + /// Sms send operation running + /// Sending, + + /// + /// Sms send operation accepted + /// Accepted, + + /// + /// Sms send operation failed + /// Failed, + + /// + /// Sms send operation failed due to invalid receiver + /// Failed_InvalidReceiver } } diff --git a/src/Altinn.Notifications.Sms.Integrations/Altinn.Notifications.Sms.Integrations.csproj b/src/Altinn.Notifications.Sms.Integrations/Altinn.Notifications.Sms.Integrations.csproj index 5185758..9e94b77 100644 --- a/src/Altinn.Notifications.Sms.Integrations/Altinn.Notifications.Sms.Integrations.csproj +++ b/src/Altinn.Notifications.Sms.Integrations/Altinn.Notifications.Sms.Integrations.csproj @@ -3,6 +3,7 @@ net8.0 enable + true enable {4ED9641C-6C11-4F23-8CFB-5E0B445643C2} diff --git a/src/Altinn.Notifications.Sms.Integrations/Consumers/SendSmsQueueConsumer.cs b/src/Altinn.Notifications.Sms.Integrations/Consumers/SendSmsQueueConsumer.cs index 2c489c2..6df1440 100644 --- a/src/Altinn.Notifications.Sms.Integrations/Consumers/SendSmsQueueConsumer.cs +++ b/src/Altinn.Notifications.Sms.Integrations/Consumers/SendSmsQueueConsumer.cs @@ -1,5 +1,6 @@ using Altinn.Notifications.Integrations.Kafka.Consumers; using Altinn.Notifications.Sms.Core.Dependencies; +using Altinn.Notifications.Sms.Core.Sending; using Altinn.Notifications.Sms.Integrations.Configuration; using Microsoft.Extensions.Logging; @@ -10,6 +11,7 @@ namespace Altinn.Notifications.Sms.Integrations.Consumers; /// public sealed class SendSmsQueueConsumer : KafkaConsumerBase { + private readonly ISendingService _sendingService; private readonly ICommonProducer _producer; private readonly ILogger _logger; private readonly string _retryTopicName; @@ -19,10 +21,12 @@ public sealed class SendSmsQueueConsumer : KafkaConsumerBase /// public SendSmsQueueConsumer( KafkaSettings kafkaSettings, + ISendingService sendingService, ICommonProducer producer, ILogger logger) : base(kafkaSettings, logger, kafkaSettings.SendSmsQueueTopicName) { + _sendingService = sendingService; _producer = producer; _retryTopicName = kafkaSettings.SendSmsQueueRetryTopicName; _logger = logger; @@ -36,8 +40,16 @@ protected override Task ExecuteAsync(CancellationToken stoppingToken) private async Task ConsumeSms(string message) { - _logger.LogInformation($"// SendSmsQueueConsumer // ConsumeSms // Consuming message: {message}"); - await Task.CompletedTask; + bool succeeded = Core.Sending.Sms.TryParse(message, out Core.Sending.Sms sms); + + if (!succeeded) + { + _logger.LogError("// SendSmsQueueConsumer // ConsumeSms // Deserialization of message failed. {Message}", message); + + return; + } + + await _sendingService.SendAsync(sms); } private async Task RetrySms(string message) diff --git a/src/Altinn.Notifications.Sms.Integrations/LinkMobility/SmsClient.cs b/src/Altinn.Notifications.Sms.Integrations/LinkMobility/SmsClient.cs index 97b5697..49335d4 100644 --- a/src/Altinn.Notifications.Sms.Integrations/LinkMobility/SmsClient.cs +++ b/src/Altinn.Notifications.Sms.Integrations/LinkMobility/SmsClient.cs @@ -19,7 +19,7 @@ public class SmsClient : ISmsClient /// /// Initializes a new instance of the class. /// - /// The configuration for the sms gateway + /// Gateway Client public SmsClient(IAltinnGatewayClient client) { _client = client; diff --git a/src/Altinn.Notifications.Sms/Program.cs b/src/Altinn.Notifications.Sms/Program.cs index 6e657a0..2c27c6c 100644 --- a/src/Altinn.Notifications.Sms/Program.cs +++ b/src/Altinn.Notifications.Sms/Program.cs @@ -111,10 +111,4 @@ void Configure() app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health"); - - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(); - } } diff --git a/src/Altinn.Notifications.Sms/appsettings.json b/src/Altinn.Notifications.Sms/appsettings.json index 178b0b6..4cc0e44 100644 --- a/src/Altinn.Notifications.Sms/appsettings.json +++ b/src/Altinn.Notifications.Sms/appsettings.json @@ -20,11 +20,10 @@ "SaslUsername": null, "SaslPassword": null }, - "HealthCheckTopicName": "altinn.notifications.email.health.check", - "EmailSendingAcceptedTopicName": "altinn.notifications.email.sending.accepted", - "EmailStatusUpdatedTopicName": "altinn.notifications.email.status.updated", + "HealthCheckTopicName": "altinn.notifications.sms.health.check", + "SmsStatusUpdatedTopicName": "altinn.notifications.sms.status.updated", "SendSmsQueueTopicName": "altinn.notifications.sms.queue", - "SendEmailQueueRetryTopicName": "altinn.notifications.email.queue.retry", + "SendSmsQueueRetryTopicName": "altinn.notifications.sms.queue.retry", "AltinnServiceUpdateTopicName ": "altinn.platform.service.updated" }, "SmsGatewayConfiguration": { diff --git a/test/Altinn.Notifications.Sms.IntegrationTests/Altinn.Notifications.Sms.IntegrationTests.csproj b/test/Altinn.Notifications.Sms.IntegrationTests/Altinn.Notifications.Sms.IntegrationTests.csproj index 2342a11..48b9936 100644 --- a/test/Altinn.Notifications.Sms.IntegrationTests/Altinn.Notifications.Sms.IntegrationTests.csproj +++ b/test/Altinn.Notifications.Sms.IntegrationTests/Altinn.Notifications.Sms.IntegrationTests.csproj @@ -9,7 +9,9 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive @@ -22,8 +24,6 @@ - - diff --git a/test/Altinn.Notifications.Sms.IntegrationTests/Consumers/SendSmsQueueConsumerTests.cs b/test/Altinn.Notifications.Sms.IntegrationTests/Consumers/SendSmsQueueConsumerTests.cs new file mode 100644 index 0000000..79d11b3 --- /dev/null +++ b/test/Altinn.Notifications.Sms.IntegrationTests/Consumers/SendSmsQueueConsumerTests.cs @@ -0,0 +1,115 @@ +using System.Text.Json; + +using Altinn.Notifications.Sms.Core.Dependencies; +using Altinn.Notifications.Sms.Core.Sending; +using Altinn.Notifications.Sms.Integrations.Configuration; +using Altinn.Notifications.Sms.Integrations.Consumers; +using Altinn.Notifications.Sms.Integrations.Producers; +using Altinn.Notifications.Sms.IntegrationTests.Utils; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using Moq; + +namespace Altinn.Notifications.Sms.IntegrationTests.Consumers; + +public class SendSmsQueueConsumerTests : IAsyncLifetime +{ + private readonly string _sendSmsQueueTopicName = Guid.NewGuid().ToString(); + private readonly string _sendSmsQueueRetryTopicName = Guid.NewGuid().ToString(); + private readonly KafkaSettings _kafkaSettings; + private ServiceProvider? _serviceProvider; + + public SendSmsQueueConsumerTests() + { + _kafkaSettings = new KafkaSettings + { + BrokerAddress = "localhost:9092", + Consumer = new() + { + GroupId = "sms-sending-consumer" + }, + SendSmsQueueTopicName = _sendSmsQueueTopicName, + Admin = new() + { + TopicList = new List { _sendSmsQueueTopicName, _sendSmsQueueRetryTopicName } + } + }; + } + + public async Task InitializeAsync() + { + await Task.CompletedTask; + } + + public async Task DisposeAsync() + { + await KafkaUtil.DeleteTopicAsync(_sendSmsQueueTopicName); + await KafkaUtil.DeleteTopicAsync(_sendSmsQueueRetryTopicName); + } + + [Fact] + public async Task ConsumeSmsTest_Successfull_deserialization_of_message_Service_called_once() + { + // Arrange + var sendingServiceMock = new Mock(); + sendingServiceMock.Setup(s => s.SendAsync(It.IsAny())).Returns(Task.CompletedTask); + + Core.Sending.Sms sms = new(Guid.NewGuid(), "recipient", "sender", "message"); + + using SendSmsQueueConsumer queueConsumer = GetSmsSendingConsumer(sendingServiceMock.Object); + using CommonProducer commonProducer = KafkaUtil.GetKafkaProducer(_serviceProvider!); + + // Act + await commonProducer.ProduceAsync(_sendSmsQueueTopicName, JsonSerializer.Serialize(sms)); + + await queueConsumer.StartAsync(CancellationToken.None); + await Task.Delay(10000); + await queueConsumer.StopAsync(CancellationToken.None); + + // Assert + sendingServiceMock.Verify(s => s.SendAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task ConsumeSmsTest_Deserialization_of_message_fails_Never_calls_service() + { + // Arrange + var sendingServiceMock = new Mock(); + sendingServiceMock.Setup(s => s.SendAsync(It.IsAny())).Returns(Task.CompletedTask); + + using SendSmsQueueConsumer queueConsumer = GetSmsSendingConsumer(sendingServiceMock.Object); + using CommonProducer commonProducer = KafkaUtil.GetKafkaProducer(_serviceProvider!); + + // Act + await commonProducer.ProduceAsync(_sendSmsQueueTopicName, JsonSerializer.Serialize("Crap sms")); + + await queueConsumer.StartAsync(CancellationToken.None); + await Task.Delay(10000); + await queueConsumer.StopAsync(CancellationToken.None); + + // Assert + sendingServiceMock.Verify(s => s.SendAsync(It.IsAny()), Times.Never); + } + + private SendSmsQueueConsumer GetSmsSendingConsumer(ISendingService sendingService) + { + IServiceCollection services = new ServiceCollection() + .AddLogging() + .AddSingleton(_kafkaSettings) + .AddSingleton() + .AddSingleton(sendingService) + .AddHostedService(); + + _serviceProvider = services.BuildServiceProvider(); + + var smsSendingConsumer = _serviceProvider.GetService(typeof(IHostedService)) as SendSmsQueueConsumer; + + if (smsSendingConsumer == null) + { + Assert.Fail("Unable to create an instance of SmsSendingConsumer."); + } + + return smsSendingConsumer; + } +} diff --git a/test/Altinn.Notifications.Sms.IntegrationTests/Endpoints/HealthCheckTests.cs b/test/Altinn.Notifications.Sms.IntegrationTests/Endpoints/HealthCheckTests.cs new file mode 100644 index 0000000..ffe44b1 --- /dev/null +++ b/test/Altinn.Notifications.Sms.IntegrationTests/Endpoints/HealthCheckTests.cs @@ -0,0 +1,26 @@ +using Altinn.Notifications.Sms.Health; + +namespace Altinn.Notifications.Sms.IntegrationTests.Endpoints; + +public class HealthCheckTests : IClassFixture> +{ + private readonly IntegrationTestWebApplicationFactory _factory; + + public HealthCheckTests(IntegrationTestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task Health_Test() + { + // Arrange + HttpClient httpClient = _factory.CreateClient(); + + // Act + HttpResponseMessage actual = await httpClient.GetAsync("/health"); + + // Assert + Assert.Equal(200, (int)actual.StatusCode); + } +} diff --git a/test/Altinn.Notifications.Sms.IntegrationTests/IntegrationTestWebApplicationFactory.cs b/test/Altinn.Notifications.Sms.IntegrationTests/IntegrationTestWebApplicationFactory.cs new file mode 100644 index 0000000..e139455 --- /dev/null +++ b/test/Altinn.Notifications.Sms.IntegrationTests/IntegrationTestWebApplicationFactory.cs @@ -0,0 +1,31 @@ +using Altinn.Notifications.Sms.Integrations.Consumers; + +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; + +namespace Altinn.Notifications.Sms.IntegrationTests; + +public class IntegrationTestWebApplicationFactory : WebApplicationFactory + where TStartup : class +{ + /// + /// ConfigureWebHost for setup of configuration and test services + /// + /// IWebHostBuilder + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration(config => + { + config.AddConfiguration(new ConfigurationBuilder() + .Build()); + }); + + builder.ConfigureTestServices(services => + { + var descriptor = services.Single(s => s.ImplementationType == typeof(SendSmsQueueConsumer)); + services.Remove(descriptor); + }); + } +} diff --git a/test/Altinn.Notifications.Sms.IntegrationTests/UnitTest1.cs b/test/Altinn.Notifications.Sms.IntegrationTests/UnitTest1.cs deleted file mode 100644 index d47ec17..0000000 --- a/test/Altinn.Notifications.Sms.IntegrationTests/UnitTest1.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Altinn.Notifications.Sms.IntegrationTests -{ - /// - /// Unit test - /// - public class UnitTest1 - { - /// - /// Test1 - /// - [Fact] - public void Test1() - { - string actual = "test"; - - Assert.Equal("test", actual); - } - } -} diff --git a/test/Altinn.Notifications.Sms.IntegrationTests/Utils/KafkaUtil.cs b/test/Altinn.Notifications.Sms.IntegrationTests/Utils/KafkaUtil.cs new file mode 100644 index 0000000..87e2dd8 --- /dev/null +++ b/test/Altinn.Notifications.Sms.IntegrationTests/Utils/KafkaUtil.cs @@ -0,0 +1,48 @@ +using Altinn.Notifications.Sms.Core.Dependencies; +using Altinn.Notifications.Sms.Integrations.Producers; + +using Confluent.Kafka; +using Confluent.Kafka.Admin; + +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.Notifications.Sms.IntegrationTests.Utils; + +public static class KafkaUtil +{ + private const string _brokerAddress = "localhost:9092"; + + public static async Task DeleteTopicAsync(string topic) + { + using var adminClient = new AdminClientBuilder(new Dictionary() { { "bootstrap.servers", _brokerAddress } }).Build(); + await adminClient.DeleteTopicsAsync([topic]); + } + + public static async Task CreateTopicsAsync(string topic) + { + using var adminClient = new AdminClientBuilder(new Dictionary() { { "bootstrap.servers", _brokerAddress } }).Build(); + + await adminClient.CreateTopicsAsync( + [ + new TopicSpecification + { + Name = topic, + NumPartitions = 1, // Set the desired number of partitions + ReplicationFactor = 1 // Set the desired replication factor + } + + ]); + } + + public static CommonProducer GetKafkaProducer(ServiceProvider serviceProvider) + { + var kafkaProducer = serviceProvider.GetService(typeof(ICommonProducer)) as CommonProducer; + + if (kafkaProducer == null) + { + Assert.Fail("Unable to create an instance of KafkaProducer."); + } + + return kafkaProducer; + } +} diff --git a/test/Altinn.Notifications.Sms.IntegrationTests/xunit.runner.json b/test/Altinn.Notifications.Sms.IntegrationTests/xunit.runner.json new file mode 100644 index 0000000..015166b --- /dev/null +++ b/test/Altinn.Notifications.Sms.IntegrationTests/xunit.runner.json @@ -0,0 +1,4 @@ +{ + "parallelizeAssembly": false, + "parallelizeTestCollections": false +} \ No newline at end of file diff --git a/test/Altinn.Notifications.Sms.Tests/Altinn.Notifications.Sms.Tests.csproj b/test/Altinn.Notifications.Sms.Tests/Altinn.Notifications.Sms.Tests.csproj index 1b836dd..6358105 100644 --- a/test/Altinn.Notifications.Sms.Tests/Altinn.Notifications.Sms.Tests.csproj +++ b/test/Altinn.Notifications.Sms.Tests/Altinn.Notifications.Sms.Tests.csproj @@ -1,4 +1,4 @@ - + net8.0 diff --git a/test/Altinn.Notifications.Sms.Tests/Sms.Core/Sending/SendingServiceTests.cs b/test/Altinn.Notifications.Sms.Tests/Sms.Core/Sending/SendingServiceTests.cs new file mode 100644 index 0000000..8a2ea3a --- /dev/null +++ b/test/Altinn.Notifications.Sms.Tests/Sms.Core/Sending/SendingServiceTests.cs @@ -0,0 +1,74 @@ +using Altinn.Notifications.Sms.Core.Configuration; +using Altinn.Notifications.Sms.Core.Dependencies; +using Altinn.Notifications.Sms.Core.Sending; +using Altinn.Notifications.Sms.Core.Status; +using Moq; + +namespace Altinn.Notifications.Sms.Tests.Sms.Core.Sending; + +public class SendingServiceTests +{ + private readonly TopicSettings _topicSettings; + + public SendingServiceTests() + { + _topicSettings = new() + { + SmsStatusUpdatedTopicName = "SmsStatusUpdatedTopicName" + }; + } + + [Fact] + public async Task SendAsync_GatewayReferenceGenerated_PublishedToExpectedKafkaTopic() + { + // Arrange + Guid id = Guid.NewGuid(); + Notifications.Sms.Core.Sending.Sms sms = new(id, "recipient", "sender", "message"); + + Mock clientMock = new(); + clientMock.Setup(c => c.SendAsync(It.IsAny())) + .ReturnsAsync("gateway-reference"); + + Mock producerMock = new(); + producerMock.Setup(p => p.ProduceAsync( + It.Is(s => s.Equals(nameof(_topicSettings.SmsStatusUpdatedTopicName))), + It.Is(s => + s.Contains("\"gatewayReference\":\"gateway-reference\"") && + s.Contains($"\"notificationId\":\"{id}\"")))); + + var sut = new SendingService(clientMock.Object, producerMock.Object, _topicSettings); + + // Act + await sut.SendAsync(sms); + + // Assert + producerMock.VerifyAll(); + } + + [Fact] + public async Task SendAsync_InvalidReceiver_PublishedToExpectedKafkaTopic() + { + // Arrange + Guid id = Guid.NewGuid(); + Notifications.Sms.Core.Sending.Sms sms = new(id, "recipient", "sender", "message"); + + Mock clientMock = new(); + clientMock.Setup(c => c.SendAsync(It.IsAny())) + .ReturnsAsync(new SmsClientErrorResponse { SendResult = SmsSendResult.Failed_InvalidReceiver, ErrorMessage = "Receiver is wrong" }); + + Mock producerMock = new(); + producerMock.Setup(p => p.ProduceAsync( + It.Is(s => s.Equals(nameof(_topicSettings.SmsStatusUpdatedTopicName))), + It.Is(s => + s.Contains($"\"notificationId\":\"{id}\"") && + s.Contains("\"sendResult\":\"Failed_InvalidReceiver\"")))); + + var sut = new SendingService(clientMock.Object, producerMock.Object, _topicSettings); + + // Act + await sut.SendAsync(sms); + + // Assert + producerMock.VerifyAll(); + } +} diff --git a/test/Altinn.Notifications.Sms.Tests/Sms.Core/Sending/SmsTests.cs b/test/Altinn.Notifications.Sms.Tests/Sms.Core/Sending/SmsTests.cs new file mode 100644 index 0000000..fca7ee6 --- /dev/null +++ b/test/Altinn.Notifications.Sms.Tests/Sms.Core/Sending/SmsTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json.Nodes; + +namespace Altinn.Notifications.Sms.Tests.Sms.Core.Sending; + +public class SmsTests +{ + private readonly string _serializedSms; + private readonly Guid _id; + + public SmsTests() + { + _id = Guid.NewGuid(); + _serializedSms = new JsonObject() + { + { "notificationId", _id }, + { "recipient", "recipient" }, + { "sender", "sender" }, + { "message", "message" } + }.ToJsonString(); + } + + [Fact] + public void TryParse_ValidSms_True() + { + bool actualResult = Notifications.Sms.Core.Sending.Sms.TryParse(_serializedSms, out Notifications.Sms.Core.Sending.Sms actual); + + Assert.True(actualResult); + Assert.Equal(_id, actual.NotificationId); + Assert.Equal("message", actual.Message); + } + + [Fact] + public void TryParse_EmptyString_False() + { + bool actualResult = Notifications.Sms.Core.Sending.Sms.TryParse(string.Empty, out _); + + Assert.False(actualResult); + } + + [Fact] + public void TryParse_InvalidString_False() + { + bool actualResult = Notifications.Sms.Core.Sending.Sms.TryParse("{\"notificationId\":\"nothing\"}", out _); + + Assert.False(actualResult); + } + + [Fact] + public void TryParse_InvalidJsonExceptionThrown_False() + { + bool actualResult = Notifications.Sms.Core.Sending.Sms.TryParse("{\"fakefield\":\"nothing\"}", out _); + + Assert.False(actualResult); + } +} diff --git a/test/Altinn.Notifications.Sms.Tests/Sms.Core/ServiceCollectionExtensionsTests.cs b/test/Altinn.Notifications.Sms.Tests/Sms.Core/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000..0cb6d53 --- /dev/null +++ b/test/Altinn.Notifications.Sms.Tests/Sms.Core/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,42 @@ +using Altinn.Notifications.Sms.Core.Configuration; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.Notifications.Sms.Tests.Sms.Core; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddIntegrationServices_MissingKafkaConfig_ThrowsException() + { + // Arrange + string expectedExceptionMessage = "Required Kafka settings is missing from application configuration (Parameter 'config')"; + + var config = new ConfigurationBuilder().Build(); + + IServiceCollection services = new ServiceCollection(); + + // Act + var exception = Assert.Throws(() => services.AddCoreServices(config)); + + // Assert + Assert.Equal(expectedExceptionMessage, exception.Message); + } + + [Fact] + public void AddIntegrationServices_SmsGatewayConfigIncluded_NoException() + { + // Arrange + Environment.SetEnvironmentVariable("KafkaSettings__BrokerAddress", "localhost:9092", EnvironmentVariableTarget.Process); + var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); + + IServiceCollection services = new ServiceCollection(); + + // Act + var exception = Record.Exception(() => services.AddCoreServices(config)); + + // Assert + Assert.Null(exception); + } +} diff --git a/test/Altinn.Notifications.Sms.Tests/Sms.Core/Status/SendOperationResultTests.cs b/test/Altinn.Notifications.Sms.Tests/Sms.Core/Status/SendOperationResultTests.cs new file mode 100644 index 0000000..e9c9fa9 --- /dev/null +++ b/test/Altinn.Notifications.Sms.Tests/Sms.Core/Status/SendOperationResultTests.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Nodes; + +using Altinn.Notifications.Sms.Core.Status; + +namespace Altinn.Notifications.Sms.Tests.Sms.Core.Status; + +public class SendOperationResultTests +{ + private readonly SendOperationResult _operationResult; + private readonly string _serializedOperationResult; + + public SendOperationResultTests() + { + Guid id = Guid.NewGuid(); + _operationResult = new SendOperationResult() + { + NotificationId = id, + GatewayReference = "gateway-reference", + SendResult = SmsSendResult.Accepted + }; + + _serializedOperationResult = new JsonObject() + { + { "notificationId", id }, + { "gatewayReference", "gateway-reference" }, + { "sendResult", "Accepted" } + }.ToJsonString(); + } + + [Fact] + public void SerializeToJson() + { + // Arrange + string expected = _serializedOperationResult; + + // Act + var actual = _operationResult.Serialize(); + + // Assert + Assert.Equal(expected, actual); + } +} diff --git a/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/ServiceCollectionExtensionsTests.cs b/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/ServiceCollectionExtensionsTests.cs index f33ff5d..ce5dc96 100644 --- a/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/ServiceCollectionExtensionsTests.cs +++ b/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/ServiceCollectionExtensionsTests.cs @@ -3,7 +3,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -namespace Altinn.Notifications.Tests.Notifications.Integrations.TestingExtensions; +namespace Altinn.Notifications.Sms.Tests.Sms.Integrations; public class ServiceCollectionExtensionsTests { @@ -11,9 +11,29 @@ public class ServiceCollectionExtensionsTests public void AddIntegrationServices_MissingSmsGatewayConfig_ThrowsException() { // Arrange + Environment.SetEnvironmentVariable("KafkaSettings__BrokerAddress", "localhost:9092", EnvironmentVariableTarget.Process); string expectedExceptionMessage = "Required SmsGatewayConfiguration settings is missing from application configuration. (Parameter 'config')"; - var config = new ConfigurationBuilder().Build(); + var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); + + IServiceCollection services = new ServiceCollection(); + + // Act + var exception = Assert.Throws(() => services.AddIntegrationServices(config)); + + // Assert + Assert.Equal(expectedExceptionMessage, exception.Message); + } + + [Fact] + public void AddIntegrationServices_MissingKafkaConfig_ThrowsException() + { + // Arrange + Environment.SetEnvironmentVariable("KafkaSettings__BrokerAddress", null, EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("SmsGatewayConfiguration__Endpoint", "https://vg.no", EnvironmentVariableTarget.Process); + string expectedExceptionMessage = "Required Kafka settings is missing from application configuration (Parameter 'config')"; + + var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); IServiceCollection services = new ServiceCollection(); @@ -29,6 +49,7 @@ public void AddIntegrationServices_SmsGatewayConfigIncluded_NoException() { // Arrange Environment.SetEnvironmentVariable("SmsGatewayConfiguration__Endpoint", "https://vg.no", EnvironmentVariableTarget.Process); + Environment.SetEnvironmentVariable("KafkaSettings__BrokerAddress", "localhost:9092", EnvironmentVariableTarget.Process); var config = new ConfigurationBuilder().AddEnvironmentVariables().Build(); IServiceCollection services = new ServiceCollection(); diff --git a/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/SharedClientConfigTests.cs b/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/SharedClientConfigTests.cs new file mode 100644 index 0000000..7882d60 --- /dev/null +++ b/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/SharedClientConfigTests.cs @@ -0,0 +1,43 @@ +using Altinn.Notifications.Sms.Integrations.Configuration; + +using Xunit; + +namespace Altinn.Notifications.Sms.Tests.Sms.Integrations; + +public class SharedClientConfigTests +{ + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SharedClientConfig_ParamsSetBySASLProperties(bool includeUsernameAndPassword) + { + // Arrange + KafkaSettings settings = new() + { + BrokerAddress = "localhost:9092" + }; + + if (includeUsernameAndPassword) + { + settings.Admin.SaslUsername = "username"; + settings.Admin.SaslPassword = "password"; + } + + // Act + var config = new SharedClientConfig(settings); + + // Assert + if (includeUsernameAndPassword) + { + Assert.Equal(3, config.TopicSpecification.ReplicationFactor); + Assert.NotNull(config.AdminClientConfig.SaslMechanism); + Assert.Equal("password", config.AdminClientConfig.SaslPassword); + } + else + { + Assert.Null(config.AdminClientConfig.SaslMechanism); + Assert.True(string.IsNullOrEmpty(config.ProducerConfig.SaslUsername)); + Assert.Equal(1, config.TopicSpecification.NumPartitions); + } + } +} diff --git a/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/SmsClientTests.cs b/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/SmsClientTests.cs index 8276821..a59e2ee 100644 --- a/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/SmsClientTests.cs +++ b/test/Altinn.Notifications.Sms.Tests/Sms.Integrations/SmsClientTests.cs @@ -1,4 +1,5 @@ using Altinn.Notifications.Sms.Core.Status; + using Altinn.Notifications.Sms.Integrations.LinkMobility; using LinkMobility.PSWin.Client.Model; @@ -25,7 +26,7 @@ public async Task SendAsync_GatewayReturnsNonSuccess_UnknownError() SmsClient smsClient = new(_clientMock.Object); // Act - var result = await smsClient.SendAsync(new Core.Sending.Sms()); + var result = await smsClient.SendAsync(new Notifications.Sms.Core.Sending.Sms()); // Assert Assert.True(result.IsError); @@ -54,7 +55,7 @@ public async Task SendAsync_GatewayReturnsNonSuccess_InvalidReceiver() SmsClient smsClient = new(_clientMock.Object); // Act - var result = await smsClient.SendAsync(new Core.Sending.Sms()); + var result = await smsClient.SendAsync(new Notifications.Sms.Core.Sending.Sms()); // Assert Assert.True(result.IsError); @@ -86,7 +87,7 @@ public async Task SendAsync_GatewayReturnsSuccess_GatewayRefReturned() SmsClient smsClient = new(_clientMock.Object); // Act - var result = await smsClient.SendAsync(new Core.Sending.Sms()); + var result = await smsClient.SendAsync(new Notifications.Sms.Core.Sending.Sms()); // Assert Assert.True(result.IsSuccess); diff --git a/test/setup-kafka.yml b/test/setup-kafka.yml new file mode 100644 index 0000000..d01b4e5 --- /dev/null +++ b/test/setup-kafka.yml @@ -0,0 +1,38 @@ +--- +version: '3' +services: + kafdrop: + image: obsidiandynamics/kafdrop + restart: always + ports: + - "9000:9000" + environment: + KAFKA_BROKERCONNECT: "broker:29092" + JVM_OPTS: "-Xms16M -Xmx48M -XX:-TieredCompilation -XX:+UseStringDeduplication -noverify" + depends_on: + - "broker" + + zookeeper: + image: confluentinc/cp-zookeeper:7.0.1 + container_name: zookeeper + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + restart: always + + broker: + image: confluentinc/cp-kafka:7.0.1 + container_name: broker + ports: + - "9092:9092" + depends_on: + - zookeeper + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_INTERNAL:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092,PLAINTEXT_INTERNAL://broker:29092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + restart: always