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);