diff --git a/dbsetup.sh b/dbsetup.sh index 23c45aca..8d726b60 100755 --- a/dbsetup.sh +++ b/dbsetup.sh @@ -1,6 +1,10 @@ #!/bin/bash export PGPASSWORD=Password +# alter max connections +psql -h localhost -p 5432 -U platform_notifications_admin -d notificationsdb \ +-c "ALTER SYSTEM SET max_connections TO '200';" + # set up platform_notifications role psql -h localhost -p 5432 -U platform_notifications_admin -d notificationsdb \ -c "DO \$\$ diff --git a/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs b/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs index 757bb44f..645cecab 100644 --- a/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs +++ b/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs @@ -8,6 +8,7 @@ public enum OrderProcessingStatus { Registered, Processing, - Completed + Completed, + SendConditionNotMet } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member diff --git a/src/Altinn.Notifications.Core/Exceptions/OrderProcessingException.cs b/src/Altinn.Notifications.Core/Exceptions/OrderProcessingException.cs new file mode 100644 index 00000000..ff84773d --- /dev/null +++ b/src/Altinn.Notifications.Core/Exceptions/OrderProcessingException.cs @@ -0,0 +1,36 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Altinn.Notifications.Core.Exceptions; + +/// +/// Represents errors that occur during order processing operations. +/// +[ExcludeFromCodeCoverage] +public class OrderProcessingException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + public OrderProcessingException() : base() + { + } + + /// + /// Initializes a new instance of the class + /// with a specified error message. + /// + /// The message that describes the error. + public OrderProcessingException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class + /// with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception, or a null reference if no inner exception is specified. + public OrderProcessingException(string message, Exception inner) : base(message, inner) + { + } +} diff --git a/src/Altinn.Notifications.Core/Services/GetOrderService.cs b/src/Altinn.Notifications.Core/Services/GetOrderService.cs index 87bea858..69445850 100644 --- a/src/Altinn.Notifications.Core/Services/GetOrderService.cs +++ b/src/Altinn.Notifications.Core/Services/GetOrderService.cs @@ -17,6 +17,7 @@ public class GetOrderService : IGetOrderService { OrderProcessingStatus.Registered, "Order has been registered and is awaiting requested send time before processing." }, { OrderProcessingStatus.Processing, "Order processing is ongoing. Notifications are being generated." }, { OrderProcessingStatus.Completed, "Order processing is completed. All notifications have been generated." }, + { OrderProcessingStatus.SendConditionNotMet, "Order processing was stopped due to send condition not being met." } }; /// diff --git a/src/Altinn.Notifications.Core/Services/OrderProcessingService.cs b/src/Altinn.Notifications.Core/Services/OrderProcessingService.cs index 68888568..8df75812 100644 --- a/src/Altinn.Notifications.Core/Services/OrderProcessingService.cs +++ b/src/Altinn.Notifications.Core/Services/OrderProcessingService.cs @@ -2,11 +2,13 @@ using Altinn.Notifications.Core.Configuration; using Altinn.Notifications.Core.Enums; +using Altinn.Notifications.Core.Exceptions; using Altinn.Notifications.Core.Integrations; using Altinn.Notifications.Core.Models.Orders; using Altinn.Notifications.Core.Persistence; using Altinn.Notifications.Core.Services.Interfaces; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Altinn.Notifications.Core.Services; @@ -19,8 +21,10 @@ public class OrderProcessingService : IOrderProcessingService private readonly IOrderRepository _orderRepository; private readonly IEmailOrderProcessingService _emailProcessingService; private readonly ISmsOrderProcessingService _smsProcessingService; + private readonly IConditionClient _conditionClient; private readonly IKafkaProducer _producer; private readonly string _pastDueOrdersTopic; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -29,14 +33,18 @@ public OrderProcessingService( IOrderRepository orderRepository, IEmailOrderProcessingService emailProcessingService, ISmsOrderProcessingService smsProcessingService, + IConditionClient conditionClient, IKafkaProducer producer, - IOptions kafkaSettings) + IOptions kafkaSettings, + ILogger logger) { _orderRepository = orderRepository; _emailProcessingService = emailProcessingService; _smsProcessingService = smsProcessingService; + _conditionClient = conditionClient; _producer = producer; _pastDueOrdersTopic = kafkaSettings.Value.PastDueOrdersTopicName; + _logger = logger; } /// @@ -65,6 +73,12 @@ public async Task StartProcessingPastDueOrders() /// public async Task ProcessOrder(NotificationOrder order) { + if (!await IsSendConditionMet(order, isRetry: false)) + { + await _orderRepository.SetProcessingStatus(order.Id, OrderProcessingStatus.SendConditionNotMet); + return; + } + NotificationChannel ch = order.NotificationChannel; switch (ch) @@ -83,6 +97,12 @@ public async Task ProcessOrder(NotificationOrder order) /// public async Task ProcessOrderRetry(NotificationOrder order) { + if (!await IsSendConditionMet(order, isRetry: true)) + { + await _orderRepository.SetProcessingStatus(order.Id, OrderProcessingStatus.SendConditionNotMet); + return; + } + NotificationChannel ch = order.NotificationChannel; switch (ch) @@ -97,4 +117,39 @@ public async Task ProcessOrderRetry(NotificationOrder order) await _orderRepository.SetProcessingStatus(order.Id, OrderProcessingStatus.Completed); } + + /// + /// Checks the send condition provided by the order request to determine if condition is met + /// + /// The notification order to check + /// Boolean indicating if this is a retry attempt + /// True if condition is met and processing should continue + /// Throws an exception if failure on first attempt ot check condition + internal async Task IsSendConditionMet(NotificationOrder order, bool isRetry) + { + if (order.ConditionEndpoint == null) + { + return true; + } + + var conditionCheckResult = await _conditionClient.CheckSendCondition(order.ConditionEndpoint); + + return conditionCheckResult.Match( + successResult => + { + return successResult; + }, + errorResult => + { + if (!isRetry) + { + // Always send to retry on first error. Exception is caught by consumer and message is moved to retry topic. + throw new OrderProcessingException($"// OrderProcessingService // IsSendConditionMet // Condition check for order with ID '{order.Id}' failed with HTTP status code '{errorResult.StatusCode}' at endpoint '{order.ConditionEndpoint}'"); + } + + // notifications should always be created and sent if the condition check is not successful + _logger.LogInformation("// OrderProcessingService // IsSendConditionMet // Condition check for order with ID '{ID}' failed on retry. Processing regardless.", order.Id); + return true; + }); + } } diff --git a/src/Altinn.Notifications.Integrations/Kafka/Consumers/PastDueOrdersRetryConsumer.cs b/src/Altinn.Notifications.Integrations/Kafka/Consumers/PastDueOrdersRetryConsumer.cs index 09ec0212..6af88578 100644 --- a/src/Altinn.Notifications.Integrations/Kafka/Consumers/PastDueOrdersRetryConsumer.cs +++ b/src/Altinn.Notifications.Integrations/Kafka/Consumers/PastDueOrdersRetryConsumer.cs @@ -13,17 +13,22 @@ namespace Altinn.Notifications.Integrations.Kafka.Consumers; public class PastDueOrdersRetryConsumer : KafkaConsumerBase { private readonly IOrderProcessingService _orderProcessingService; + private readonly IDateTimeService _dateTime; + + private readonly int _processingDelayMins = 1; /// /// Initializes a new instance of the class. /// public PastDueOrdersRetryConsumer( IOrderProcessingService orderProcessingService, + IDateTimeService dateTimeService, IOptions settings, ILogger logger) : base(settings, logger, settings.Value.PastDueOrdersRetryTopicName) { _orderProcessingService = orderProcessingService; + _dateTime = dateTimeService; } /// @@ -41,6 +46,16 @@ private async Task ProcessOrder(string message) return; } + // adding a delay relative to send time to allow transient faults to be resolved + TimeSpan diff = _dateTime.UtcNow() - order.RequestedSendTime; + + TimeSpan delayForRetryAttempt = TimeSpan.FromMinutes(_processingDelayMins) - diff; + + if (delayForRetryAttempt > TimeSpan.Zero) + { + await Task.Delay(delayForRetryAttempt); + } + await _orderProcessingService.ProcessOrderRetry(order!); } diff --git a/src/Altinn.Notifications.Persistence/Migration/v0.34/00-alter-types.sql b/src/Altinn.Notifications.Persistence/Migration/v0.34/00-alter-types.sql new file mode 100644 index 00000000..0bb3a0d3 --- /dev/null +++ b/src/Altinn.Notifications.Persistence/Migration/v0.34/00-alter-types.sql @@ -0,0 +1 @@ +ALTER TYPE public.orderprocessingstate ADD VALUE IF NOT EXISTS 'SendConditionNotMet'; \ No newline at end of file diff --git a/src/Altinn.Notifications.Persistence/Migration/v0.34/01-functions-and-procedures.sql b/src/Altinn.Notifications.Persistence/Migration/v0.34/01-functions-and-procedures.sql new file mode 100644 index 00000000..f6298ea5 --- /dev/null +++ b/src/Altinn.Notifications.Persistence/Migration/v0.34/01-functions-and-procedures.sql @@ -0,0 +1,401 @@ +-- This script is autogenerated from the tool DbTools. Do not edit manually. + +-- getemailrecipients.sql: +CREATE OR REPLACE FUNCTION notifications.getemailrecipients_v2(_alternateid uuid) +RETURNS TABLE( + recipientorgno text, + recipientnin text, + toaddress text +) +LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE +__orderid BIGINT := (SELECT _id from notifications.orders + where alternateid = _alternateid); +BEGIN +RETURN query + SELECT e.recipientorgno, e.recipientnin, e.toaddress + FROM notifications.emailnotifications e + WHERE e._orderid = __orderid; +END; +$BODY$; + +-- getemailsstatusnewupdatestatus.sql: +CREATE OR REPLACE FUNCTION notifications.getemails_statusnew_updatestatus() + RETURNS TABLE(alternateid uuid, subject text, body text, fromaddress text, toaddress text, contenttype text) + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + latest_email_timeout TIMESTAMP WITH TIME ZONE; +BEGIN + SELECT emaillimittimeout INTO latest_email_timeout FROM notifications.resourcelimitlog WHERE id = (SELECT MAX(id) FROM notifications.resourcelimitlog); + IF latest_email_timeout IS NOT NULL THEN + IF latest_email_timeout >= NOW() THEN + RETURN QUERY SELECT NULL::uuid AS alternateid, NULL::text AS subject, NULL::text AS body, NULL::text AS fromaddress, NULL::text AS toaddress, NULL::text AS contenttype WHERE FALSE; + RETURN; + ELSE + UPDATE notifications.resourcelimitlog SET emaillimittimeout = NULL WHERE id = (SELECT MAX(id) FROM notifications.resourcelimitlog); + END IF; + END IF; + + RETURN query + WITH updated AS ( + UPDATE notifications.emailnotifications + SET result = 'Sending', resulttime = now() + WHERE result = 'New' + RETURNING notifications.emailnotifications.alternateid, _orderid, notifications.emailnotifications.toaddress) + SELECT u.alternateid, et.subject, et.body, et.fromaddress, u.toaddress, et.contenttype + FROM updated u, notifications.emailtexts et + WHERE u._orderid = et._orderid; +END; +$BODY$; + +-- getemailsummary.sql: +CREATE OR REPLACE FUNCTION notifications.getemailsummary_v2( + _alternateorderid uuid, + _creatorname text) + RETURNS TABLE( + sendersreference text, + alternateid uuid, + recipientorgno text, + recipientnin text, + toaddress text, + result emailnotificationresulttype, + resulttime timestamptz) + LANGUAGE 'plpgsql' +AS $BODY$ + + BEGIN + RETURN QUERY + SELECT o.sendersreference, n.alternateid, n.recipientorgno, n.recipientnin, n.toaddress, n.result, n.resulttime + FROM notifications.emailnotifications n + LEFT JOIN notifications.orders o ON n._orderid = o._id + WHERE o.alternateid = _alternateorderid + and o.creatorname = _creatorname; + IF NOT FOUND THEN + RETURN QUERY + SELECT o.sendersreference, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::emailnotificationresulttype, NULL::timestamptz + FROM notifications.orders o + WHERE o.alternateid = _alternateorderid + and o.creatorname = _creatorname; + END IF; + END; +$BODY$; + +-- getmetrics.sql: +CREATE OR REPLACE FUNCTION notifications.getmetrics( + month_input int, + year_input int +) +RETURNS TABLE ( + org text, + placed_orders bigint, + sent_emails bigint, + succeeded_emails bigint, + sent_sms bigint, + succeeded_sms bigint +) +AS $$ +BEGIN + RETURN QUERY + SELECT + o.creatorname, + COUNT(DISTINCT o._id) AS placed_orders, + SUM(CASE WHEN e._id IS NOT NULL THEN 1 ELSE 0 END) AS sent_emails, + SUM(CASE WHEN e.result IN ('Delivered', 'Succeeded') THEN 1 ELSE 0 END) AS succeeded_emails, + SUM(CASE WHEN s._id IS NOT NULL THEN s.smscount ELSE 0 END) AS sent_sms, + SUM(CASE WHEN s.result = 'Accepted' THEN 1 ELSE 0 END) AS succeeded_sms + FROM notifications.orders o + LEFT JOIN notifications.emailnotifications e ON o._id = e._orderid + LEFT JOIN notifications.smsnotifications s ON o._id = s._orderid + WHERE EXTRACT(MONTH FROM o.requestedsendtime) = month_input + AND EXTRACT(YEAR FROM o.requestedsendtime) = year_input + GROUP BY o.creatorname; +END; +$$ LANGUAGE plpgsql; + + +-- getorderincludestatus.sql: +CREATE OR REPLACE FUNCTION notifications.getorder_includestatus_v4( + _alternateid uuid, + _creatorname text +) +RETURNS TABLE( + alternateid uuid, + creatorname text, + sendersreference text, + created timestamp with time zone, + requestedsendtime timestamp with time zone, + processed timestamp with time zone, + processedstatus orderprocessingstate, + notificationchannel text, + ignorereservation boolean, + resourceid text, + conditionendpoint text, + generatedemailcount bigint, + succeededemailcount bigint, + generatedsmscount bigint, + succeededsmscount bigint +) +LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE + _target_orderid INTEGER; + _succeededEmailCount BIGINT; + _generatedEmailCount BIGINT; + _succeededSmsCount BIGINT; + _generatedSmsCount BIGINT; +BEGIN + SELECT _id INTO _target_orderid + FROM notifications.orders + WHERE orders.alternateid = _alternateid + AND orders.creatorname = _creatorname; + + SELECT + SUM(CASE WHEN result IN ('Delivered', 'Succeeded') THEN 1 ELSE 0 END), + COUNT(1) AS generatedEmailCount + INTO _succeededEmailCount, _generatedEmailCount + FROM notifications.emailnotifications + WHERE _orderid = _target_orderid; + + SELECT + SUM(CASE WHEN result = 'Accepted' THEN 1 ELSE 0 END), + COUNT(1) AS generatedSmsCount + INTO _succeededSmsCount, _generatedSmsCount + FROM notifications.smsnotifications + WHERE _orderid = _target_orderid; + + RETURN QUERY + SELECT + orders.alternateid, + orders.creatorname, + orders.sendersreference, + orders.created, + orders.requestedsendtime, + orders.processed, + orders.processedstatus, + orders.notificationorder->>'NotificationChannel', + CASE + WHEN orders.notificationorder->>'IgnoreReservation' IS NULL THEN NULL + ELSE (orders.notificationorder->>'IgnoreReservation')::BOOLEAN + END AS IgnoreReservation, + orders.notificationorder->>'ResourceId', + orders.notificationorder->>'ConditionEndpoint', + _generatedEmailCount, + _succeededEmailCount, + _generatedSmsCount, + _succeededSmsCount + FROM + notifications.orders AS orders + WHERE + orders.alternateid = _alternateid; +END; +$BODY$; + + +-- getorderspastsendtimeupdatestatus.sql: +CREATE OR REPLACE FUNCTION notifications.getorders_pastsendtime_updatestatus() + RETURNS TABLE(notificationorders jsonb) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN +RETURN QUERY + UPDATE notifications.orders + SET processedstatus = 'Processing' + WHERE _id IN (select _id + from notifications.orders + where processedstatus = 'Registered' + and requestedsendtime <= now() + INTERVAL '1 minute' + limit 50) + RETURNING notificationorder AS notificationorders; +END; +$BODY$; + +-- getsmsrecipients.sql: +CREATE OR REPLACE FUNCTION notifications.getsmsrecipients_v2(_orderid uuid) +RETURNS TABLE( + recipientorgno text, + recipientnin text, + mobilenumber text +) +LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE +__orderid BIGINT := (SELECT _id from notifications.orders + where alternateid = _orderid); +BEGIN +RETURN query + SELECT s.recipientorgno, s.recipientnin, s.mobilenumber + FROM notifications.smsnotifications s + WHERE s._orderid = __orderid; +END; +$BODY$; + +-- getsmsstatusnewupdatestatus.sql: +CREATE OR REPLACE FUNCTION notifications.getsms_statusnew_updatestatus() + RETURNS TABLE(alternateid uuid, sendernumber text, mobilenumber text, body text) + LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + + RETURN query + WITH updated AS ( + UPDATE notifications.smsnotifications + SET result = 'Sending', resulttime = now() + WHERE result = 'New' + RETURNING notifications.smsnotifications.alternateid, _orderid, notifications.smsnotifications.mobilenumber) + SELECT u.alternateid, st.sendernumber, u.mobilenumber, st.body + FROM updated u, notifications.smstexts st + WHERE u._orderid = st._orderid; +END; +$BODY$; + +-- getsmssummary.sql: +CREATE OR REPLACE FUNCTION notifications.getsmssummary_v2( + _alternateorderid uuid, + _creatorname text) + RETURNS TABLE( + sendersreference text, + alternateid uuid, + recipientorgno text, + recipientnin text, + mobilenumber text, + result smsnotificationresulttype, + resulttime timestamptz) + LANGUAGE 'plpgsql' +AS $BODY$ + + BEGIN + RETURN QUERY + SELECT o.sendersreference, n.alternateid, n.recipientorgno, n.recipientnin, n.mobilenumber, n.result, n.resulttime + FROM notifications.smsnotifications n + LEFT JOIN notifications.orders o ON n._orderid = o._id + WHERE o.alternateid = _alternateorderid + and o.creatorname = _creatorname; + IF NOT FOUND THEN + RETURN QUERY + SELECT o.sendersreference, NULL::uuid, NULL::text, NULL::text, NULL::text, NULL::smsnotificationresulttype, NULL::timestamptz + FROM notifications.orders o + WHERE o.alternateid = _alternateorderid + and o.creatorname = _creatorname; + END IF; + END; +$BODY$; + +-- insertemailnotification.sql: +CREATE OR REPLACE PROCEDURE notifications.insertemailnotification( +_orderid uuid, +_alternateid uuid, +_recipientorgno TEXT, +_recipientnin TEXT, +_toaddress TEXT, +_result text, +_resulttime timestamptz, +_expirytime timestamptz) +LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE +__orderid BIGINT := (SELECT _id from notifications.orders + where alternateid = _orderid); +BEGIN + +INSERT INTO notifications.emailnotifications( +_orderid, +alternateid, +recipientorgno, +recipientnin, +toaddress, result, +resulttime, +expirytime) +VALUES ( +__orderid, +_alternateid, +_recipientorgno, +_recipientnin, +_toaddress, +_result::emailnotificationresulttype, +_resulttime, +_expirytime); +END; +$BODY$; + +-- insertemailtext.sql: +CREATE OR REPLACE PROCEDURE notifications.insertemailtext(__orderid BIGINT, _fromaddress TEXT, _subject TEXT, _body TEXT, _contenttype TEXT) +LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN +INSERT INTO notifications.emailtexts(_orderid, fromaddress, subject, body, contenttype) + VALUES (__orderid, _fromaddress, _subject, _body, _contenttype); +END; +$BODY$; + + +-- insertorder.sql: +CREATE OR REPLACE FUNCTION notifications.insertorder(_alternateid UUID, _creatorname TEXT, _sendersreference TEXT, _created TIMESTAMPTZ, _requestedsendtime TIMESTAMPTZ, _notificationorder JSONB) +RETURNS BIGINT + LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE +_orderid BIGINT; +BEGIN + INSERT INTO notifications.orders(alternateid, creatorname, sendersreference, created, requestedsendtime, processed, notificationorder) + VALUES (_alternateid, _creatorname, _sendersreference, _created, _requestedsendtime, _created, _notificationorder) + RETURNING _id INTO _orderid; + + RETURN _orderid; +END; +$BODY$; + +-- insertsmsnotification.sql: +CREATE OR REPLACE PROCEDURE notifications.insertsmsnotification( +_orderid uuid, +_alternateid uuid, +_recipientorgno TEXT, +_recipientnin TEXT, +_mobilenumber TEXT, +_result text, +_smscount integer, +_resulttime timestamptz, +_expirytime timestamptz +) +LANGUAGE 'plpgsql' +AS $BODY$ +DECLARE +__orderid BIGINT := (SELECT _id from notifications.orders + where alternateid = _orderid); +BEGIN + +INSERT INTO notifications.smsnotifications( +_orderid, +alternateid, +recipientorgno, +recipientnin, +mobilenumber, +result, +smscount, +resulttime, +expirytime) +VALUES ( +__orderid, +_alternateid, +_recipientorgno, +_recipientnin, +_mobilenumber, +_result::smsnotificationresulttype, +_smscount, +_resulttime, +_expirytime); +END; +$BODY$; + +-- updateemailstatus.sql: +CREATE OR REPLACE PROCEDURE notifications.updateemailstatus(_alternateid UUID, _result text, _operationid text) +LANGUAGE 'plpgsql' +AS $BODY$ +BEGIN + UPDATE notifications.emailnotifications + SET result = _result::emailnotificationresulttype, resulttime = now(), operationid = _operationid + WHERE alternateid = _alternateid; +END; +$BODY$; + diff --git a/test/Altinn.Notifications.IntegrationTests/Notifications.Integrations/TestingConsumers/PastDueOrdersRetryConsumerTests.cs b/test/Altinn.Notifications.IntegrationTests/Notifications.Integrations/TestingConsumers/PastDueOrdersRetryConsumerTests.cs index e2b7bbd5..152b0d76 100644 --- a/test/Altinn.Notifications.IntegrationTests/Notifications.Integrations/TestingConsumers/PastDueOrdersRetryConsumerTests.cs +++ b/test/Altinn.Notifications.IntegrationTests/Notifications.Integrations/TestingConsumers/PastDueOrdersRetryConsumerTests.cs @@ -51,7 +51,7 @@ public async Task RunTask_ConfirmExpectedSideEffects() } /// - /// When a new order is picked up by the consumer and all email notifications are created befor, only processedstatus is changed. + /// When a new order is picked up by the consumer and all email notifications are created before processedstatus is changed. /// We measure the sucess of this test by confirming that the processedstatus is completed. /// [Fact] @@ -59,10 +59,10 @@ public async Task RunTask_ConfirmChangeOfStatus() { // Arrange Dictionary vars = new() - { - { "KafkaSettings__PastDueOrdersRetryTopicName", _retryTopicName }, - { "KafkaSettings__Admin__TopicList", $"[\"{_retryTopicName}\"]" } - }; + { + { "KafkaSettings__PastDueOrdersRetryTopicName", _retryTopicName }, + { "KafkaSettings__Admin__TopicList", $"[\"{_retryTopicName}\"]" } + }; using PastDueOrdersRetryConsumer consumerRetryService = (PastDueOrdersRetryConsumer)ServiceUtil .GetServices(new List() { typeof(IHostedService) }, vars) diff --git a/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs b/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs index d9db996f..821e3a81 100644 --- a/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs +++ b/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs @@ -102,5 +102,46 @@ public async Task GetOrderWithStatusById_ConfirmConditionEndpoint() // Assert Assert.Equal("https://vg.no/condition", actual?.ConditionEndpoint?.ToString()); } + + [Fact] + public async Task SetProcessingStatus_AllStatusesSupported() + { + // Arrange + OrderRepository repo = (OrderRepository)ServiceUtil + .GetServices(new List() { typeof(IOrderRepository) }) + .First(i => i.GetType() == typeof(OrderRepository)); + + NotificationOrder order = new() + { + Id = Guid.NewGuid(), + Created = DateTime.UtcNow, + Creator = new("test"), + Templates = new List() + { + new EmailTemplate("noreply@altinn.no", "Subject", "Body", EmailContentType.Plain), + new SmsTemplate("Altinn", "This is the body") + }, + ConditionEndpoint = new Uri("https://vg.no/condition") + }; + + _orderIdsToDelete.Add(order.Id); + await repo.Create(order); + + foreach (OrderProcessingStatus statusType in Enum.GetValues(typeof(OrderProcessingStatus))) + { + // Act + await repo.SetProcessingStatus(order.Id, statusType); + + // Assert + string sql = $@"SELECT count(1) + FROM notifications.orders + WHERE alternateid = '{order.Id}' + AND processedstatus = '{statusType}'"; + + int orderCount = await PostgreUtil.RunSqlReturnOutput(sql); + + Assert.Equal(1, orderCount); + } + } } } diff --git a/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs index 703b6172..30b5ed83 100644 --- a/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs +++ b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs @@ -135,6 +135,7 @@ await result.Match( [InlineData(OrderProcessingStatus.Registered, "Order has been registered and is awaiting requested send time before processing.")] [InlineData(OrderProcessingStatus.Processing, "Order processing is ongoing. Notifications are being generated.")] [InlineData(OrderProcessingStatus.Completed, "Order processing is completed. All notifications have been generated.")] + [InlineData(OrderProcessingStatus.SendConditionNotMet, "Order processing was stopped due to send condition not being met.")] public void GetStatusDescription_ExpectedDescription(OrderProcessingStatus status, string expected) { string actual = GetOrderService.GetStatusDescription(status); diff --git a/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/OrderProcessingServiceTests.cs b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/OrderProcessingServiceTests.cs index 7c252c3f..458c5167 100644 --- a/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/OrderProcessingServiceTests.cs +++ b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/OrderProcessingServiceTests.cs @@ -3,20 +3,21 @@ using System.Threading.Tasks; using Altinn.Notifications.Core.Enums; +using Altinn.Notifications.Core.Exceptions; using Altinn.Notifications.Core.Integrations; using Altinn.Notifications.Core.Models.Orders; +using Altinn.Notifications.Core.Models.SendCondition; using Altinn.Notifications.Core.Persistence; using Altinn.Notifications.Core.Services; using Altinn.Notifications.Core.Services.Interfaces; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using Xunit; -using static Altinn.Authorization.ABAC.Constants.XacmlConstants; - namespace Altinn.Notifications.Tests.Notifications.Core.TestingServices; public class OrderProcessingServiceTests @@ -96,6 +97,39 @@ public async Task ProcessOrder_SmsOrder_SmsServiceCalled() emailMockService.Verify(e => e.ProcessOrder(It.IsAny()), Times.Never); } + [Fact] + public async Task ProcessOrder_SendConditionNotMet_ProcessingStops() + { + // Arrange + NotificationOrder order = new() + { + Id = Guid.NewGuid(), + NotificationChannel = NotificationChannel.Sms, + ConditionEndpoint = new Uri("https://vg.no") + }; + + Mock clientMock = new(); + clientMock.Setup(c => c.CheckSendCondition(It.IsAny())).ReturnsAsync(false); + + Mock repoMock = new(); + repoMock.Setup(r => r.SetProcessingStatus( + It.Is(g => g.Equals(order.Id)), + It.Is(ops => ops == OrderProcessingStatus.SendConditionNotMet))); + var orderProcessingService = GetTestService(conditionClient: clientMock.Object, repo: repoMock.Object); + + // Act + await orderProcessingService.ProcessOrder(order); + + // Assert + clientMock.Verify( + c => c.CheckSendCondition(It.IsAny()), + Times.Once); + + repoMock.Verify( + r => r.SetProcessingStatus(It.Is(g => g.Equals(order.Id)), It.Is(ops => ops == OrderProcessingStatus.SendConditionNotMet)), + Times.Once); + } + [Fact] public async Task ProcessOrder_SerivceThrowsException_ProcessingStatusIsNotSet() { @@ -148,6 +182,39 @@ public async Task ProcessOrderRetry_SmsOrder_SmsServiceCalled() emailMockService.Verify(e => e.ProcessOrderRetry(It.IsAny()), Times.Never); } + [Fact] + public async Task ProcessOrderRetry_SendConditionNotMet_ProcessingStops() + { + // Arrange + NotificationOrder order = new() + { + Id = Guid.NewGuid(), + NotificationChannel = NotificationChannel.Sms, + ConditionEndpoint = new Uri("https://vg.no") + }; + + Mock clientMock = new(); + clientMock.Setup(c => c.CheckSendCondition(It.IsAny())).ReturnsAsync(false); + + Mock repoMock = new(); + repoMock.Setup(r => r.SetProcessingStatus( + It.Is(g => g.Equals(order.Id)), + It.Is(ops => ops == OrderProcessingStatus.SendConditionNotMet))); + var orderProcessingService = GetTestService(conditionClient: clientMock.Object, repo: repoMock.Object); + + // Act + await orderProcessingService.ProcessOrderRetry(order); + + // Assert + clientMock.Verify( + c => c.CheckSendCondition(It.IsAny()), + Times.Once); + + repoMock.Verify( + r => r.SetProcessingStatus(It.Is(g => g.Equals(order.Id)), It.Is(ops => ops == OrderProcessingStatus.SendConditionNotMet)), + Times.Once); + } + [Fact] public async Task ProcessOrderRetry_SerivceThrowsException_ProcessingStatusIsNotSet() { @@ -175,11 +242,78 @@ public async Task ProcessOrderRetry_SerivceThrowsException_ProcessingStatusIsNot Times.Never); } + [Fact] + public async Task IsSendConditionMet_NoConditionEndpoint_ReturnsTrue() + { + // Arrange + NotificationOrder order = new() { Id = Guid.NewGuid(), ConditionEndpoint = null }; + var orderProcessingService = GetTestService(); + + // Act + bool actual = await orderProcessingService.IsSendConditionMet(order, isRetry: false); + + // Assert + Assert.True(actual); + } + + [Theory] + [InlineData(true, false)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(false, true)] + public async Task IsSendConditionMet_SuccessResultFromClient_ReturnsSameAsClient(bool expectedConditionResult, bool isRetry) + { + // Arrange + NotificationOrder order = new() { Id = Guid.NewGuid(), ConditionEndpoint = new Uri("https://vg.no") }; + + Mock clientMock = new(); + clientMock.Setup(c => c.CheckSendCondition(It.IsAny())).ReturnsAsync(expectedConditionResult); + var orderProcessingService = GetTestService(conditionClient: clientMock.Object); + + // Act + bool actual = await orderProcessingService.IsSendConditionMet(order, isRetry: isRetry); + + // Assert + Assert.Equal(expectedConditionResult, actual); + } + + [Fact] + public async Task IsSendConditionMet_ErrorResultOnFirstAttempt_ThrowsException() + { + // Arrange + NotificationOrder order = new() { Id = Guid.NewGuid(), ConditionEndpoint = new Uri("https://vg.no") }; + + Mock clientMock = new(); + clientMock.Setup(c => c.CheckSendCondition(It.IsAny())).ReturnsAsync(new ConditionClientError()); + var orderProcessingService = GetTestService(conditionClient: clientMock.Object); + + // Act + await Assert.ThrowsAsync(async () => await orderProcessingService.IsSendConditionMet(order, isRetry: false)); + } + + [Fact] + public async Task IsSendConditionMet_ErrorResultOnRetry_ReturnsTrue() + { + // Arrange + NotificationOrder order = new() { Id = Guid.NewGuid(), ConditionEndpoint = new Uri("https://vg.no") }; + + Mock clientMock = new(); + clientMock.Setup(c => c.CheckSendCondition(It.IsAny())).ReturnsAsync(new ConditionClientError()); + var orderProcessingService = GetTestService(conditionClient: clientMock.Object); + + // Act + bool actual = await orderProcessingService.IsSendConditionMet(order, isRetry: true); + + // Assert + Assert.True(actual); + } + private static OrderProcessingService GetTestService( IOrderRepository? repo = null, IEmailOrderProcessingService? emailMock = null, ISmsOrderProcessingService? smsMock = null, - IKafkaProducer? producer = null) + IKafkaProducer? producer = null, + IConditionClient? conditionClient = null) { if (repo == null) { @@ -205,8 +339,14 @@ private static OrderProcessingService GetTestService( producer = producerMock.Object; } + if (conditionClient == null) + { + var conditionClientMock = new Mock(); + conditionClient = conditionClientMock.Object; + } + var kafkaSettings = new Altinn.Notifications.Core.Configuration.KafkaSettings() { PastDueOrdersTopicName = _pastDueTopicName }; - return new OrderProcessingService(repo, emailMock, smsMock, producer, Options.Create(kafkaSettings)); + return new OrderProcessingService(repo, emailMock, smsMock, conditionClient, producer, Options.Create(kafkaSettings), new LoggerFactory().CreateLogger()); } }