diff --git a/.github/workflows/build-and-analyze.yml b/.github/workflows/build-and-analyze.yml index 964019d3..6cd5d189 100644 --- a/.github/workflows/build-and-analyze.yml +++ b/.github/workflows/build-and-analyze.yml @@ -81,9 +81,9 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" - - name: Process .NET test result - if: always() - uses: NasAmin/trx-parser@v0.6.0 - with: - TRX_PATH: ${{ github.workspace }}/TestResults - REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# - name: Process .NET test result +# if: always() +# uses: NasAmin/trx-parser@v0.6.0 +# with: +# TRX_PATH: ${{ github.workspace }}/TestResults +# REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src/Altinn.Notifications.Core/Enums/CancellationError.cs b/src/Altinn.Notifications.Core/Enums/CancellationError.cs new file mode 100644 index 00000000..b2532a6c --- /dev/null +++ b/src/Altinn.Notifications.Core/Enums/CancellationError.cs @@ -0,0 +1,18 @@ +namespace Altinn.Notifications.Core.Enums +{ + /// + /// Enum for the different types of errors that can occur when cancelling an order + /// + public enum CancellationError + { + /// + /// Order was not found + /// + OrderNotFound, + + /// + /// Order was found but processing had already started + /// + CancellationProhibited + } +} diff --git a/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs b/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs index 645cecab..73f64e89 100644 --- a/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs +++ b/src/Altinn.Notifications.Core/Enums/OrderProcessingStatus.cs @@ -9,6 +9,7 @@ public enum OrderProcessingStatus Registered, Processing, Completed, - SendConditionNotMet + SendConditionNotMet, + Cancelled } #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member diff --git a/src/Altinn.Notifications.Core/Extensions/ServiceCollectionExtensions.cs b/src/Altinn.Notifications.Core/Extensions/ServiceCollectionExtensions.cs index 47bf5f31..70d932ca 100644 --- a/src/Altinn.Notifications.Core/Extensions/ServiceCollectionExtensions.cs +++ b/src/Altinn.Notifications.Core/Extensions/ServiceCollectionExtensions.cs @@ -36,6 +36,7 @@ public static void AddCoreServices(this IServiceCollection services, IConfigurat .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() diff --git a/src/Altinn.Notifications.Core/Persistence/IOrderRepository.cs b/src/Altinn.Notifications.Core/Persistence/IOrderRepository.cs index 0b317c79..f6a7730f 100644 --- a/src/Altinn.Notifications.Core/Persistence/IOrderRepository.cs +++ b/src/Altinn.Notifications.Core/Persistence/IOrderRepository.cs @@ -1,5 +1,6 @@ using Altinn.Notifications.Core.Enums; using Altinn.Notifications.Core.Models.Orders; +using Altinn.Notifications.Core.Shared; namespace Altinn.Notifications.Core.Persistence; @@ -49,4 +50,12 @@ public interface IOrderRepository /// The short name of the order creator /// A list of notification orders public Task> GetOrdersBySendersReference(string sendersReference, string creator); + + /// + /// Cancels the order corresponding to the provided id within the provided creator scope if processing has not started yet + /// + /// The order id + /// The short name of the order creator + /// If successful the cancelled notification order with status info. If error a cancellation error type. + public Task> CancelOrder(Guid id, string creator); } diff --git a/src/Altinn.Notifications.Core/Services/CancelOrderService.cs b/src/Altinn.Notifications.Core/Services/CancelOrderService.cs new file mode 100644 index 00000000..1448f1cc --- /dev/null +++ b/src/Altinn.Notifications.Core/Services/CancelOrderService.cs @@ -0,0 +1,42 @@ +using Altinn.Notifications.Core.Enums; +using Altinn.Notifications.Core.Models.Orders; +using Altinn.Notifications.Core.Persistence; +using Altinn.Notifications.Core.Services.Interfaces; +using Altinn.Notifications.Core.Shared; + +namespace Altinn.Notifications.Core.Services +{ + /// + /// Implementation of the interface. + /// + public class CancelOrderService : ICancelOrderService + { + private readonly IOrderRepository _repository; + + /// + /// Initializes a new instance of the class. + /// + /// The repository + public CancelOrderService(IOrderRepository repository) + { + _repository = repository; + } + + /// + public async Task> CancelOrder(Guid id, string creator) + { + var result = await _repository.CancelOrder(id, creator); + + return result.Match>( + order => + { + order.ProcessingStatus.StatusDescription = GetOrderService.GetStatusDescription(order.ProcessingStatus.Status); + return order; + }, + error => + { + return error; + }); + } + } +} diff --git a/src/Altinn.Notifications.Core/Services/GetOrderService.cs b/src/Altinn.Notifications.Core/Services/GetOrderService.cs index 69445850..ac3aebee 100644 --- a/src/Altinn.Notifications.Core/Services/GetOrderService.cs +++ b/src/Altinn.Notifications.Core/Services/GetOrderService.cs @@ -17,7 +17,8 @@ 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." } + { OrderProcessingStatus.SendConditionNotMet, "Order processing was stopped due to send condition not being met." }, + { OrderProcessingStatus.Cancelled, "Order processing was stopped due to order being cancelled." } }; /// diff --git a/src/Altinn.Notifications.Core/Services/Interfaces/ICancelOrderService.cs b/src/Altinn.Notifications.Core/Services/Interfaces/ICancelOrderService.cs new file mode 100644 index 00000000..3306ae62 --- /dev/null +++ b/src/Altinn.Notifications.Core/Services/Interfaces/ICancelOrderService.cs @@ -0,0 +1,20 @@ +using Altinn.Notifications.Core.Enums; +using Altinn.Notifications.Core.Models.Orders; +using Altinn.Notifications.Core.Shared; + +namespace Altinn.Notifications.Core.Services.Interfaces +{ + /// + /// Interface for operations related to cancelling notification orders + /// + public interface ICancelOrderService + { + /// + /// Cancels an order if it has not been processed yet + /// + /// The order id + /// The creator of the orders + /// The cancelled order or a + public Task> CancelOrder(Guid id, string creator); + } +} diff --git a/src/Altinn.Notifications.Persistence/Altinn.Notifications.Persistence.csproj b/src/Altinn.Notifications.Persistence/Altinn.Notifications.Persistence.csproj index b3ad1888..1721f7c3 100644 --- a/src/Altinn.Notifications.Persistence/Altinn.Notifications.Persistence.csproj +++ b/src/Altinn.Notifications.Persistence/Altinn.Notifications.Persistence.csproj @@ -54,7 +54,6 @@ true - + diff --git a/src/Altinn.Notifications.Persistence/Migration/FunctionsAndProcedures/cancelorder.sql b/src/Altinn.Notifications.Persistence/Migration/FunctionsAndProcedures/cancelorder.sql new file mode 100644 index 00000000..7a34bb5a --- /dev/null +++ b/src/Altinn.Notifications.Persistence/Migration/FunctionsAndProcedures/cancelorder.sql @@ -0,0 +1,61 @@ +CREATE OR REPLACE FUNCTION notifications.cancelorder( + _alternateid uuid, + _creatorname text +) +RETURNS TABLE( + cancelallowed boolean, + 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 $$ +DECLARE + order_record RECORD; +BEGIN + -- Retrieve the order and its status + SELECT o.requestedsendtime, o.processedstatus + INTO order_record + FROM notifications.orders o + WHERE o.alternateid = _alternateid AND o.creatorname = _creatorname; + + -- If no order is found, return an empty result set + IF NOT FOUND THEN + RETURN; + END IF; + + -- Check if order is already cancelled + IF order_record.processedstatus = 'Cancelled' THEN + RETURN QUERY + SELECT TRUE AS cancelallowed, + order_details.* + FROM notifications.getorder_includestatus_v4(_alternateid, _creatorname) AS order_details; + ELSEIF (order_record.requestedsendtime <= NOW() + INTERVAL '5 minutes' or order_record.processedstatus != 'Registered') THEN + RETURN QUERY + SELECT FALSE AS cancelallowed, NULL::uuid, NULL::text, NULL::text, NULL::timestamp with time zone, NULL::timestamp with time zone, NULL::timestamp with time zone, NULL::orderprocessingstate, NULL::text, NULL::boolean, NULL::text, NULL::text, NULL::bigint, NULL::bigint, NULL::bigint, NULL::bigint; + ELSE + -- Cancel the order by updating its status + UPDATE notifications.orders + SET processedstatus = 'Cancelled', processed = NOW() + WHERE notifications.orders.alternateid = _alternateid; + + -- Retrieve the updated order details + RETURN QUERY + SELECT TRUE AS cancelallowed, + order_details.* + FROM notifications.getorder_includestatus_v4(_alternateid, _creatorname) AS order_details; + END IF; +END; +$$; diff --git a/src/Altinn.Notifications.Persistence/Migration/v0.35/00-alter-types.sql b/src/Altinn.Notifications.Persistence/Migration/v0.35/00-alter-types.sql new file mode 100644 index 00000000..6128da3e --- /dev/null +++ b/src/Altinn.Notifications.Persistence/Migration/v0.35/00-alter-types.sql @@ -0,0 +1 @@ +ALTER TYPE public.orderprocessingstate ADD VALUE IF NOT EXISTS 'Cancelled'; \ No newline at end of file diff --git a/src/Altinn.Notifications.Persistence/Migration/v0.35/01-functions-and-procedures.sql b/src/Altinn.Notifications.Persistence/Migration/v0.35/01-functions-and-procedures.sql new file mode 100644 index 00000000..69591b9c --- /dev/null +++ b/src/Altinn.Notifications.Persistence/Migration/v0.35/01-functions-and-procedures.sql @@ -0,0 +1,465 @@ +-- This script is autogenerated from the tool DbTools. Do not edit manually. + +-- cancelorder.sql: +CREATE OR REPLACE FUNCTION notifications.cancelorder( + _alternateid uuid, + _creatorname text +) +RETURNS TABLE( + cancelallowed boolean, + 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 $$ +DECLARE + order_record RECORD; +BEGIN + -- Retrieve the order and its status + SELECT o.requestedsendtime, o.processedstatus + INTO order_record + FROM notifications.orders o + WHERE o.alternateid = _alternateid AND o.creatorname = _creatorname; + + -- If no order is found, return an empty result set + IF NOT FOUND THEN + RETURN; + END IF; + + -- Check if order is already cancelled + IF order_record.processedstatus = 'Cancelled' THEN + RETURN QUERY + SELECT TRUE AS cancelallowed, + order_details.* + FROM notifications.getorder_includestatus_v4(_alternateid, _creatorname) AS order_details; + ELSEIF (order_record.requestedsendtime <= NOW() + INTERVAL '5 minutes' or order_record.processedstatus != 'Registered') THEN + RETURN QUERY + SELECT FALSE AS cancelallowed, NULL::uuid, NULL::text, NULL::text, NULL::timestamp with time zone, NULL::timestamp with time zone, NULL::timestamp with time zone, NULL::orderprocessingstate, NULL::text, NULL::boolean, NULL::text, NULL::text, NULL::bigint, NULL::bigint, NULL::bigint, NULL::bigint; + ELSE + -- Cancel the order by updating its status + UPDATE notifications.orders + SET processedstatus = 'Cancelled', processed = NOW() + WHERE notifications.orders.alternateid = _alternateid; + + -- Retrieve the updated order details + RETURN QUERY + SELECT TRUE AS cancelallowed, + order_details.* + FROM notifications.getorder_includestatus_v4(_alternateid, _creatorname) AS order_details; + END IF; +END; +$$; + + +-- 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/src/Altinn.Notifications.Persistence/Repository/OrderRepository.cs b/src/Altinn.Notifications.Persistence/Repository/OrderRepository.cs index 68569e28..bfcd31e1 100644 --- a/src/Altinn.Notifications.Persistence/Repository/OrderRepository.cs +++ b/src/Altinn.Notifications.Persistence/Repository/OrderRepository.cs @@ -5,6 +5,7 @@ using Altinn.Notifications.Core.Models.NotificationTemplate; using Altinn.Notifications.Core.Models.Orders; using Altinn.Notifications.Core.Persistence; +using Altinn.Notifications.Core.Shared; using Altinn.Notifications.Persistence.Extensions; using Microsoft.ApplicationInsights; @@ -31,6 +32,7 @@ public class OrderRepository : IOrderRepository private const string _setProcessCompleted = "update notifications.orders set processedstatus =$1::orderprocessingstate where alternateid=$2"; private const string _getOrdersPastSendTimeUpdateStatus = "select notifications.getorders_pastsendtime_updatestatus()"; private const string _getOrderIncludeStatus = "select * from notifications.getorder_includestatus_v4($1, $2)"; // _alternateid, creator + private const string _cancelAndReturnOrder = "select * from notifications.cancelorder($1, $2)"; // _alternateid, creator /// /// Initializes a new instance of the class. @@ -157,44 +159,84 @@ public async Task> GetPastDueOrdersAndSetProcessingState await using (NpgsqlDataReader reader = await pgcom.ExecuteReaderAsync()) { - while (await reader.ReadAsync()) + if (!reader.HasRows) { - string? conditionEndpointString = reader.GetValue("conditionendpoint"); - Uri? conditionEndpoint = conditionEndpointString == null ? null : new Uri(conditionEndpointString); - - order = new( - reader.GetValue("alternateid"), - reader.GetValue("sendersreference"), - reader.GetValue("requestedsendtime"), // all decimals are not included - new Creator(reader.GetValue("creatorname")), - reader.GetValue("created"), - reader.GetValue("notificationchannel"), - reader.GetValue("ignorereservation"), - reader.GetValue("resourceid"), - conditionEndpoint, - new ProcessingStatus( - reader.GetValue("processedstatus"), - reader.GetValue("processed"))); - - int generatedEmail = (int)reader.GetValue("generatedEmailCount"); - int succeededEmail = (int)reader.GetValue("succeededEmailCount"); - - int generatedSms = (int)reader.GetValue("generatedSmsCount"); - int succeededSms = (int)reader.GetValue("succeededSmsCount"); - - if (generatedEmail > 0) - { - order.SetNotificationStatuses(NotificationTemplateType.Email, generatedEmail, succeededEmail); - } - - if (generatedSms > 0) - { - order.SetNotificationStatuses(NotificationTemplateType.Sms, generatedSms, succeededSms); - } + tracker.Track(); + return null; } + + await reader.ReadAsync(); + order = ReadNotificationOrderWithStatus(reader); + } + + tracker.Track(); + return order; + } + + /// + public async Task> CancelOrder(Guid id, string creator) + { + await using NpgsqlCommand pgcom = _dataSource.CreateCommand(_cancelAndReturnOrder); + using TelemetryTracker tracker = new(_telemetryClient, pgcom); + pgcom.Parameters.AddWithValue(NpgsqlDbType.Uuid, id); + pgcom.Parameters.AddWithValue(NpgsqlDbType.Text, creator); + + await using NpgsqlDataReader reader = await pgcom.ExecuteReaderAsync(); + if (!reader.HasRows) + { + tracker.Track(); + return CancellationError.OrderNotFound; + } + + await reader.ReadAsync(); + bool canCancel = reader.GetValue("cancelallowed"); + + if (!canCancel) + { + tracker.Track(); + return CancellationError.CancellationProhibited; } + NotificationOrderWithStatus? order = ReadNotificationOrderWithStatus(reader); tracker.Track(); + return order!; + } + + private static NotificationOrderWithStatus? ReadNotificationOrderWithStatus(NpgsqlDataReader reader) + { + string? conditionEndpointString = reader.GetValue("conditionendpoint"); + Uri? conditionEndpoint = conditionEndpointString == null ? null : new Uri(conditionEndpointString); + + NotificationOrderWithStatus order = new( + reader.GetValue("alternateid"), + reader.GetValue("sendersreference"), + reader.GetValue("requestedsendtime"), // all decimals are not included + new Creator(reader.GetValue("creatorname")), + reader.GetValue("created"), + reader.GetValue("notificationchannel"), + reader.GetValue("ignorereservation"), + reader.GetValue("resourceid"), + conditionEndpoint, + new ProcessingStatus( + reader.GetValue("processedstatus"), + reader.GetValue("processed"))); + + int generatedEmail = (int)reader.GetValue("generatedEmailCount"); + int succeededEmail = (int)reader.GetValue("succeededEmailCount"); + + int generatedSms = (int)reader.GetValue("generatedSmsCount"); + int succeededSms = (int)reader.GetValue("succeededSmsCount"); + + if (generatedEmail > 0) + { + order.SetNotificationStatuses(NotificationTemplateType.Email, generatedEmail, succeededEmail); + } + + if (generatedSms > 0) + { + order.SetNotificationStatuses(NotificationTemplateType.Sms, generatedSms, succeededSms); + } + return order; } diff --git a/src/Altinn.Notifications/Controllers/OrdersController.cs b/src/Altinn.Notifications/Controllers/OrdersController.cs index 964c3e49..31fce9d2 100644 --- a/src/Altinn.Notifications/Controllers/OrdersController.cs +++ b/src/Altinn.Notifications/Controllers/OrdersController.cs @@ -1,4 +1,5 @@ using Altinn.Notifications.Configuration; +using Altinn.Notifications.Core.Enums; using Altinn.Notifications.Core.Models.Orders; using Altinn.Notifications.Core.Services.Interfaces; using Altinn.Notifications.Core.Shared; @@ -31,15 +32,17 @@ public class OrdersController : ControllerBase private readonly IValidator _validator; private readonly IGetOrderService _getOrderService; private readonly IOrderRequestService _orderRequestService; + private readonly ICancelOrderService _cancelOrderService; /// /// Initializes a new instance of the class. /// - public OrdersController(IValidator validator, IGetOrderService getOrderService, IOrderRequestService orderRequestService) + public OrdersController(IValidator validator, IGetOrderService getOrderService, IOrderRequestService orderRequestService, ICancelOrderService cancelOrderService) { _validator = validator; _getOrderService = getOrderService; _orderRequestService = orderRequestService; + _cancelOrderService = cancelOrderService; } /// @@ -160,4 +163,42 @@ public async Task> Post(Notifi return Accepted(result.OrderId!.GetSelfLinkFromOrderId(), result.MapToExternal()); } + + /// + /// Cancel a notification order. + /// + /// The id of the order to cancel. + /// The cancelled notification order + [HttpPut] + [Route("{id}/cancel")] + [Produces("application/json")] + [SwaggerResponse(200, "The notification order was cancelled. No notifications will be sent.", typeof(NotificationOrderWithStatusExt))] + [SwaggerResponse(409, "The order cannot be cancelled due to current processing status")] + [SwaggerResponse(404, "No order with the provided id was found")] + public async Task> CancelOrder([FromRoute] Guid id) + { + string? expectedCreator = HttpContext.GetOrg(); + + if (expectedCreator == null) + { + return Forbid(); + } + + Result result = await _cancelOrderService.CancelOrder(id, expectedCreator); + + return result.Match( + order => + { + return order.MapToNotificationOrderWithStatusExt(); + }, + error => + { + return error switch + { + CancellationError.OrderNotFound => (ActionResult)NotFound(), + CancellationError.CancellationProhibited => (ActionResult)Conflict(), + _ => (ActionResult)StatusCode(500), + }; + }); + } } diff --git a/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs b/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs index 821e3a81..04373013 100644 --- a/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs +++ b/test/Altinn.Notifications.IntegrationTests/Notifications.Persistence/OrderRepositoryTests.cs @@ -2,6 +2,7 @@ using Altinn.Notifications.Core.Models.NotificationTemplate; using Altinn.Notifications.Core.Models.Orders; using Altinn.Notifications.Core.Persistence; +using Altinn.Notifications.Core.Shared; using Altinn.Notifications.IntegrationTests.Utils; using Altinn.Notifications.Persistence.Repository; @@ -25,8 +26,11 @@ public async Task InitializeAsync() public async Task DisposeAsync() { - string deleteSql = $@"DELETE from notifications.orders o where o.alternateid in ('{string.Join("','", _orderIdsToDelete)}')"; - await PostgreUtil.RunSql(deleteSql); + if (_orderIdsToDelete.Count != 0) + { + string deleteSql = $@"DELETE from notifications.orders o where o.alternateid in ('{string.Join("','", _orderIdsToDelete)}')"; + await PostgreUtil.RunSql(deleteSql); + } } [Fact] @@ -143,5 +147,100 @@ FROM notifications.orders Assert.Equal(1, orderCount); } } + + [Fact] + public async Task CancelOrder_OrderDoesNotExits_ReturnsCancellationError() + { + // Arrange + OrderRepository repo = (OrderRepository)ServiceUtil + .GetServices(new List() { typeof(IOrderRepository) }) + .First(i => i.GetType() == typeof(OrderRepository)); + + // Act + Result result = await repo.CancelOrder(Guid.NewGuid(), "non-exitent-org"); + + // Assert + result.Match( + success => + throw new Exception("No success value should be returned if order is not found in database."), + error => + { + Assert.Equal(CancellationError.OrderNotFound, error); + return true; + }); + } + + [Fact] + public async Task CancelOrder_SendTimePassed_ReturnsError() + { + // 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 SmsTemplate("Altinn", "This is the body") + }, + RequestedSendTime = DateTime.UtcNow.AddMinutes(-1) + }; + + _orderIdsToDelete.Add(order.Id); + await repo.Create(order); + + // Act + Result result = await repo.CancelOrder(order.Id, order.Creator.ShortName); + + // Assert + result.Match( + success => + throw new Exception("No success value should be returned if order is not found in database."), + error => + { + Assert.Equal(CancellationError.CancellationProhibited, error); + return true; + }); + } + + [Fact] + public async Task CancelOrder_CancellationConditionSatisfied_ReturnsOrder() + { + // 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 SmsTemplate("Altinn", "This is the body") + }, + RequestedSendTime = DateTime.UtcNow.AddMinutes(20) + }; + + _orderIdsToDelete.Add(order.Id); + await repo.Create(order); + + // Act + Result result = await repo.CancelOrder(order.Id, order.Creator.ShortName); + + // Assert + result.Match( + success => + { + Assert.Equal(OrderProcessingStatus.Cancelled, success.ProcessingStatus.Status); + return true; + }, + error => throw new Exception("No error value should be returned if order satisfies cancellation conditions.")); + } } } diff --git a/test/Altinn.Notifications.IntegrationTests/Notifications/OrdersController/OrdersControllerTests.cs b/test/Altinn.Notifications.IntegrationTests/Notifications/OrdersController/OrdersControllerTests.cs index a0e2d09c..eee29f13 100644 --- a/test/Altinn.Notifications.IntegrationTests/Notifications/OrdersController/OrdersControllerTests.cs +++ b/test/Altinn.Notifications.IntegrationTests/Notifications/OrdersController/OrdersControllerTests.cs @@ -8,6 +8,7 @@ using Altinn.Notifications.Core.Enums; using Altinn.Notifications.Core.Models; using Altinn.Notifications.Core.Models.Orders; +using Altinn.Notifications.Core.Services; using Altinn.Notifications.Core.Services.Interfaces; using Altinn.Notifications.Core.Shared; using Altinn.Notifications.Models; @@ -567,7 +568,158 @@ public async Task Post_InvalidOrderRequest_BadRequest() Assert.Equal("One or more validation errors occurred.", actual?.Title); } - private HttpClient GetTestClient(IGetOrderService? getOrderService = null, IOrderRequestService? orderRequestService = null) + [Fact] + public async Task CancelOrder_MissingBearer_ReturnsUnauthorized() + { + // Arrange + HttpClient client = GetTestClient(); + string url = _basePath + "/" + Guid.NewGuid() + "/cancel"; + HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, url); + + // Act + HttpResponseMessage response = await client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task CancelOrder_CalledByUser_ReturnsForbidden() + { + // Arrange + HttpClient client = GetTestClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetUserToken(1337)); + + string url = _basePath + "/" + Guid.NewGuid() + "/cancel"; + HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, url); + + // Act + HttpResponseMessage response = await client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task CancelOrder_CalledWithInvalidScope_ReturnsForbidden() + { + // Arrange + HttpClient client = GetTestClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetOrgToken("ttd", scope: "dummy:scope")); + + string url = _basePath + "/" + Guid.NewGuid() + "/cancel"; + HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, url); + + // Act + HttpResponseMessage response = await client.SendAsync(httpRequestMessage); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + } + + [Fact] + public async Task CancelOrder_ValidBearerToken_CorrespondingServiceMethodCalled() + { + // Arrange + Guid orderId = Guid.NewGuid(); + + Mock orderService = new(); + orderService + .Setup(o => o.CancelOrder(It.Is(g => g.Equals(orderId)), It.Is(s => s.Equals("ttd")))) + .ReturnsAsync(_orderWithStatus); + + HttpClient client = GetTestClient(cancelOrderService: orderService.Object); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetOrgToken("ttd", scope: "altinn:serviceowner/notifications.create")); + + string url = _basePath + "/" + orderId + "/cancel"; + HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, url); + + // Act + HttpResponseMessage response = await client.SendAsync(httpRequestMessage); + + // Assert + orderService.VerifyAll(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task CancelOrder_ValidPlatformAccessToken_CorrespondingServiceMethodCalled() + { + // Arrange + Guid orderId = Guid.NewGuid(); + + Mock orderService = new(); + orderService + .Setup(o => o.CancelOrder(It.Is(g => g.Equals(orderId)), It.Is(s => s.Equals("ttd")))) + .ReturnsAsync(_orderWithStatus); + + HttpClient client = GetTestClient(cancelOrderService: orderService.Object); + + string url = _basePath + "/" + orderId + "/cancel"; + HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, url); + httpRequestMessage.Headers.Add("PlatformAccessToken", PrincipalUtil.GetAccessToken("ttd", "apps-test")); + + // Act + HttpResponseMessage response = await client.SendAsync(httpRequestMessage); + + // Assert + orderService.VerifyAll(); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task CancelOrder_ServiceReturnsOrderNotFound_ReturnsNotFound() + { + // Arrange + Guid orderId = Guid.NewGuid(); + + Mock orderService = new(); + orderService + .Setup(o => o.CancelOrder(It.Is(g => g.Equals(orderId)), It.Is(s => s.Equals("ttd")))) + .ReturnsAsync(CancellationError.OrderNotFound); + + HttpClient client = GetTestClient(cancelOrderService: orderService.Object); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetOrgToken("ttd", scope: "altinn:serviceowner/notifications.create")); + + string url = _basePath + "/" + orderId + "/cancel"; + HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, url); + + // Act + HttpResponseMessage response = await client.SendAsync(httpRequestMessage); + + // Assert + orderService.VerifyAll(); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task CancelOrder_ServiceReturnsCancellationProhibited_ReturnsConflict() + { + // Arrange + Guid orderId = Guid.NewGuid(); + + Mock orderService = new(); + orderService + .Setup(o => o.CancelOrder(It.Is(g => g.Equals(orderId)), It.IsAny())) + .ReturnsAsync(CancellationError.CancellationProhibited); + + HttpClient client = GetTestClient(cancelOrderService: orderService.Object); + + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", PrincipalUtil.GetOrgToken("ttd", scope: "altinn:serviceowner/notifications.create")); + + string url = _basePath + "/" + orderId + "/cancel"; + HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, url); + + // Act + HttpResponseMessage response = await client.SendAsync(httpRequestMessage); + + // Assert + orderService.VerifyAll(); + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + } + + private HttpClient GetTestClient(IGetOrderService? getOrderService = null, IOrderRequestService? orderRequestService = null, ICancelOrderService? cancelOrderService = null) { if (getOrderService == null) { @@ -593,6 +745,15 @@ private HttpClient GetTestClient(IGetOrderService? getOrderService = null, IOrde orderRequestService = orderRequestServiceMock.Object; } + if (cancelOrderService == null) + { + Mock cancelOrderMock = new(); + cancelOrderMock + .Setup(o => o.CancelOrder(It.IsAny(), It.IsAny())) + .ReturnsAsync(_orderWithStatus); + cancelOrderService = cancelOrderMock.Object; + } + HttpClient client = _factory.WithWebHostBuilder(builder => { IdentityModelEventSource.ShowPII = true; @@ -601,6 +762,7 @@ private HttpClient GetTestClient(IGetOrderService? getOrderService = null, IOrde { services.AddSingleton(getOrderService); services.AddSingleton(orderRequestService); + services.AddSingleton(cancelOrderService); // Set up mock authentication and authorization services.AddSingleton, JwtCookiePostConfigureOptionsStub>(); diff --git a/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/CancelOrderServiceTests.cs b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/CancelOrderServiceTests.cs new file mode 100644 index 00000000..24f8e9b0 --- /dev/null +++ b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/CancelOrderServiceTests.cs @@ -0,0 +1,101 @@ +using System; +using System.Threading.Tasks; + +using Altinn.Notifications.Core.Enums; +using Altinn.Notifications.Core.Models.Orders; +using Altinn.Notifications.Core.Persistence; +using Altinn.Notifications.Core.Services; +using Altinn.Notifications.Core.Shared; + +using Moq; + +using Xunit; + +namespace Altinn.Notifications.Tests.Services +{ + public class CancelOrderServiceTests + { + private readonly Mock _repositoryMock; + private readonly CancelOrderService _cancelOrderService; + + public CancelOrderServiceTests() + { + _repositoryMock = new Mock(); + _cancelOrderService = new CancelOrderService(_repositoryMock.Object); + } + + [Fact] + public async Task CancelOrder_SuccessfullyCancelled_ReturnsOrderWithStatus() + { + // Arrange + Guid orderId = Guid.NewGuid(); + + _repositoryMock.Setup(r => r.CancelOrder(It.IsAny(), It.IsAny())) + .ReturnsAsync(new NotificationOrderWithStatus() + { + Id = orderId, + ProcessingStatus = new() + { + Status = OrderProcessingStatus.Cancelled + } + }); + + // Act + var result = await _cancelOrderService.CancelOrder(orderId, "ttd"); + + // Assert + result.Match( + success => + { + Assert.Equal(OrderProcessingStatus.Cancelled, success.ProcessingStatus.Status); + Assert.False(string.IsNullOrEmpty(success.ProcessingStatus.StatusDescription)); + return true; + }, + error => throw new Exception("No error value should be returned if order successfully cancelled.")); + } + + [Fact] + public async Task CancelOrder_OrderDoesNotExist_ReturnsCancellationError() + { + // Arrange + Guid orderId = Guid.NewGuid(); + + _repositoryMock.Setup(r => r.CancelOrder(It.IsAny(), It.IsAny())) + .ReturnsAsync(CancellationError.OrderNotFound); + + // Act + var result = await _cancelOrderService.CancelOrder(orderId, "ttd"); + + // Assert + result.Match( + success => throw new Exception("No success value should be returned if order is not found."), + error => + { + Assert.Equal(CancellationError.OrderNotFound, error); + return true; + }); + } + + [Fact] + public async Task CancelOrder_OrderNotCancelled_ReturnsCancellationError() + { + // Arrange + Guid orderId = Guid.NewGuid(); + + _repositoryMock.Setup(r => r.CancelOrder(It.IsAny(), It.IsAny())) + .ReturnsAsync(CancellationError.CancellationProhibited); + + // Act + var result = await _cancelOrderService.CancelOrder(orderId, "ttd"); + + // Assert + result.Match( + success => throw new Exception("No success value should be returned if order is not found."), + error => + { + Assert.Equal(CancellationError.CancellationProhibited, error); + return true; + }); + } + } +} diff --git a/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs index 30b5ed83..c7eebca3 100644 --- a/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs +++ b/test/Altinn.Notifications.Tests/Notifications.Core/TestingServices/GetOrderServiceTests.cs @@ -136,6 +136,7 @@ await result.Match( [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.")] + [InlineData(OrderProcessingStatus.Cancelled, "Order processing was stopped due to order being cancelled.")] public void GetStatusDescription_ExpectedDescription(OrderProcessingStatus status, string expected) { string actual = GetOrderService.GetStatusDescription(status);