diff --git a/docs/design-principles/0030-recording.md b/docs/design-principles/0030-recording.md index 0d28cbdb..64484512 100644 --- a/docs/design-principles/0030-recording.md +++ b/docs/design-principles/0030-recording.md @@ -131,33 +131,37 @@ These are generally non-technical or system performance-related events but more This is simply achieved by leveraging the infrastructure of the `ILoggerFactory` of .NET. -## Recorder +## The Recorder The `HostRecorder` is used in all running hosts (e.g., ApiHosts, WebsiteHost, FunctionHosts and TestingHosts). -The adapters that are used by the `HostRecorder` to send telemetry to its various infrastructure components are configurable via `RecordingOptions,` which are tailored for each host and to the specific cloud environment the `HostRecorder` is running in. +The adapters that are used by the `HostRecorder` to send telemetry to its various infrastructure components are configurable via `RecordingOptions,` which are tailored for each host and to the specific cloud environment the `HostRecorder` is running in (i.e. Azure or AWS). For example, this is the default infrastructure that is used by the `HostRecorder` when deployed to Azure. -> Similar components are used, by default, when deployed to AWS, except that Application Insights is replaced by Cloud Watch, the queues are replaced by SQS queues, and the Audits are stored in an RDS store. +![Azure Recording](../images/Recorder-Azure.png) -![](../images/Recorder-Azure.png) +When deployed to AWS, Application Insights is replaced by Cloud Watch, the queues are replaced by SQS queues, and the Audits are stored in an RDS database. + +![AWS Recording](../images/Recorder-AWS.png) No matter what the host actually is, there are two main flows of data that are handled by the `HostRecorder`. Both are high in reliability and both are asynchronous. ### Traces, Crashes, Measures -By default, all Traces, Crashes, and Measures are offloaded to Application Insights directly using the Application Insights `TelemetryClient` SDK. +When deployed to Azure, all Traces, Crashes, and Measures are offloaded to Application Insights directly using the Application Insights `TelemetryClient` SDK. + +> The client SDK for Application Insights manages buffering and store-and-forward of this data asynchronously so that individual HTTP requests can return immediately without paying the tax of uploading the data to the cloud. The SDK supports buffering, retries, and other strategies to deliver the data reliably (using the `ServerTelemetryChannel`). See https://learn.microsoft.com/en-us/azure/azure-monitor/app/telemetry-channels for more details. -> This SDK manages buffering and store-and-forward of this data asynchronously so that individual HTTP requests can return immediately without paying the tax of uploading the data to the cloud. The SDK supports buffering, retries, and other strategies to deliver the data reliably (using the `ServerTelemetryChannel`). See https://learn.microsoft.com/en-us/azure/azure-monitor/app/telemetry-channels for more details. +When deployed to AWS, all Traces, Crashes and Measures are offloaded to CloudWatch X-Ray using the client SDK. ### Audits and Usages -Audits are ultimately destined to be stored permanently in a database. Usages are typically relayed to a remote 3rd party system. +Audits are ultimately destined to be stored permanently in a database. Usages are typically relayed to a remote 3rd party system (i.e. Google Analytics, MixPanel, Amplitude etc). -In both cases, to avoid tying up the client's HTTP request, the telemetry for these types is first stored on a reliable queue, and then later, an API call to the Ancillary API is issued that delivers the telemetry to its respective destination. +In both cases, to avoid tying up the client's HTTP request, the telemetry for these types is first "scheduled" on a reliable queue, and then later, an API call to the Ancillary API is issued that "delivers" the telemetry to its final destination. -> In Azure, an Azure Function is triggered when the telemetry arrives on a queue, and the Azure Function simply calls an API in the Ancillary API to deliver the telemetry to its destination. The Azure Function Trigger is a reliable means to handle this process, since if the API call fails, the message will return to the queue, and subsequent failures will result in the message moving to the poison queue, to be dealt with manually. +> On Azure, an Azure Function is triggered when the telemetry arrives on a queue, and the Azure Function simply calls an API in the Ancillary API to deliver the telemetry to its destination. The Azure Function Trigger is a reliable means to handle this process, since if the API call fails, the message will return to the queue, and subsequent failures will result in the message moving to the poison queue, to be dealt with manually. > In AWS, a Lambda does the same job as the Azure Function above, and the SQS adapter is configured with poison queues (a.k.a a dead-letter queue) to operate in exactly the same way. diff --git a/docs/design-principles/0090-authentication-authorization.md b/docs/design-principles/0090-authentication-authorization.md new file mode 100644 index 00000000..2ccaa28e --- /dev/null +++ b/docs/design-principles/0090-authentication-authorization.md @@ -0,0 +1,26 @@ +# Authentication & Authorization + +## Design Principles + +## Implementation + +### Password Credential Authentication + +### Token Authorization + +### HMAC Authentication/Authorization + +### APIKey Authorization + +### Cookie Authentication + +### Cookie Authorization + +* Usually performed by a BackendForFrontend component, reverse-proxies the token hidden in the cookie, into a token passed to the backend + +### Declarative Authorization Syntax + +### Role Based Authorization + +### Feature Based Authorization + \ No newline at end of file diff --git a/docs/design-principles/0090-authentication-authorization.md.md b/docs/design-principles/0090-authentication-authorization.md.md deleted file mode 100644 index 20818abb..00000000 --- a/docs/design-principles/0090-authentication-authorization.md.md +++ /dev/null @@ -1,15 +0,0 @@ -# Authentication & Authorization - -## Design Principles - -## Implementation - -Cookie Authentication - -Usually performed by a BackendForFrontend component, reverse-proxies the token hidden in the cookie, into a token passed to the backend - -Authorization - -For marked endpoints, verifies that the cookie exists. - - \ No newline at end of file diff --git a/docs/design-principles/0100-email-delivery.md b/docs/design-principles/0100-email-delivery.md new file mode 100644 index 00000000..0c2dcc47 --- /dev/null +++ b/docs/design-principles/0100-email-delivery.md @@ -0,0 +1,18 @@ +# Email Delivery + +## Design Principles + +Many processes in the backend of a SaaS product aim to alert the user to activities or processes in a SaaS product, that warrant their attention, and most of these notifications/alerts are ultimately delivered by email (albeit some are delivered by other means too, i.e. in-app, SMS texts etc). + +Sending emails is often done via 3rd party systems like: SendGrid, MailGun, PostMark etc., over HTTP. Due to its nature, this mechanism isn't very reliable, especially with systems under load. + +* We need to "broker" between the sending of emails and the delivering of them to make the entire process more reliable, and we need to provide observability when things go wrong. +* Since an inbound API request to any API backend can yield of the order ~10 emails per API call, delivering them reliably across HTTP can require minutes of time. If you consider the possibility of retries and back-offs etc. We simply could not afford to keep API clients blocked and waiting while email delivery takes place, let alone the risk of timing out their connection to the inbound API call in the first place. + +Fortunately, an individual email arriving in a person inbox is not a time-critical and synchronous usability function to begin with. Some delay is anticipated. + +Thus we need to take advantage of all these facts and engineer a reliable mechanism. + +## Implementation + +![Email Delivery](../../docs/images/Email-Delivery.png) \ No newline at end of file diff --git a/docs/images/Email-Delivery.png b/docs/images/Email-Delivery.png new file mode 100644 index 00000000..272e9f23 Binary files /dev/null and b/docs/images/Email-Delivery.png differ diff --git a/docs/images/Sources.pptx b/docs/images/Sources.pptx index 8e3a1b73..f622b503 100644 Binary files a/docs/images/Sources.pptx and b/docs/images/Sources.pptx differ diff --git a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs index 0701fc54..1ad11e88 100644 --- a/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs +++ b/src/AWSLambdas.Api.WorkerHost/HostExtensions.cs @@ -35,7 +35,7 @@ public static void AddDependencies(this IServiceCollection services, IConfigurat #endif services.AddSingleton(c => - new CrashTraceOnlyRecorder("Azure API Lambdas", c.Resolve(), + new CrashTraceOnlyRecorder("AWS API Lambdas", c.Resolve(), c.Resolve())); services.AddSingleton(c => new InterHostServiceClient(c.Resolve(), @@ -43,5 +43,6 @@ public static void AddDependencies(this IServiceCollection services, IConfigurat c.Resolve().GetAncillaryApiHostBaseUrl())); services.AddSingleton, DeliverUsageRelayWorker>(); services.AddSingleton, DeliverAuditRelayWorker>(); + services.AddSingleton, DeliverEmailRelayWorker>(); } } \ No newline at end of file diff --git a/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverEmail.cs b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverEmail.cs new file mode 100644 index 00000000..4d6a917e --- /dev/null +++ b/src/AWSLambdas.Api.WorkerHost/Lambdas/DeliverEmail.cs @@ -0,0 +1,24 @@ +using Amazon.Lambda.Annotations; +using Amazon.Lambda.Core; +using Amazon.Lambda.SQSEvents; +using Application.Persistence.Shared.ReadModels; +using AWSLambdas.Api.WorkerHost.Extensions; +using Infrastructure.Workers.Api; + +namespace AWSLambdas.Api.WorkerHost.Lambdas; + +public class DeliverEmail +{ + private readonly IQueueMonitoringApiRelayWorker _worker; + + public DeliverEmail(IQueueMonitoringApiRelayWorker worker) + { + _worker = worker; + } + + [LambdaFunction] + public async Task Run(SQSEvent sqsEvent, ILambdaContext context) + { + return await sqsEvent.RelayRecordsAsync(_worker, CancellationToken.None); + } +} \ No newline at end of file diff --git a/src/AWSLambdas.Api.WorkerHost/serverless.template b/src/AWSLambdas.Api.WorkerHost/serverless.template index 193ed72a..edd49ab4 100644 --- a/src/AWSLambdas.Api.WorkerHost/serverless.template +++ b/src/AWSLambdas.Api.WorkerHost/serverless.template @@ -36,6 +36,23 @@ "PackageType": "Zip", "Handler": "AWSLambdas.Api.WorkerHost::AWSLambdas.Api.WorkerHost.Lambdas.DeliverAudit_Run_Generated::Run" } + }, + "AWSLambdasApiWorkerHostLambdasDeliverEmailRunGenerated": { + "Type": "AWS::Serverless::Function", + "Metadata": { + "Tool": "Amazon.Lambda.Annotations" + }, + "Properties": { + "Runtime": "dotnet6", + "CodeUri": ".", + "MemorySize": 256, + "Timeout": 30, + "Policies": [ + "AWSLambdaBasicExecutionRole" + ], + "PackageType": "Zip", + "Handler": "AWSLambdas.Api.WorkerHost::AWSLambdas.Api.WorkerHost.Lambdas.DeliverEmail_Run_Generated::Run" + } } } } \ No newline at end of file diff --git a/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs b/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs index c5c7a477..50654b9a 100644 --- a/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs +++ b/src/AncillaryApplication.UnitTests/AncillaryApplicationSpec.cs @@ -22,8 +22,10 @@ public class AncillaryApplicationSpec private readonly Mock _auditMessageRepository; private readonly Mock _auditRepository; private readonly Mock _caller; - private readonly Mock _usageMessageRepository; - private readonly Mock _usageReportingService; + private readonly Mock _emailDeliveryService; + private readonly Mock _emailMessageQueue; + private readonly Mock _usageDeliveryService; + private readonly Mock _usageMessageQueue; public AncillaryApplicationSpec() { @@ -32,15 +34,18 @@ public AncillaryApplicationSpec() idFactory.Setup(idf => idf.Create(It.IsAny())) .Returns(new Result("anid".ToId())); _caller = new Mock(); - _usageMessageRepository = new Mock(); - _usageReportingService = new Mock(); + _usageMessageQueue = new Mock(); + _usageDeliveryService = new Mock(); _auditMessageRepository = new Mock(); _auditRepository = new Mock(); _auditRepository.Setup(ar => ar.SaveAsync(It.IsAny(), It.IsAny())) .Returns((AuditRoot root, CancellationToken _) => Task.FromResult>(root)); + _emailMessageQueue = new Mock(); + _emailDeliveryService = new Mock(); - _application = new AncillaryApplication(recorder.Object, idFactory.Object, _usageMessageRepository.Object, - _usageReportingService.Object, _auditMessageRepository.Object, _auditRepository.Object); + _application = new AncillaryApplication(recorder.Object, idFactory.Object, _usageMessageQueue.Object, + _usageDeliveryService.Object, _auditMessageRepository.Object, _auditRepository.Object, + _emailMessageQueue.Object, _emailDeliveryService.Object); } [Fact] @@ -50,8 +55,8 @@ public async Task WhenDeliverUsageAsyncAndMessageIsNotRehydratable_ThenReturnsEr result.Should().BeError(ErrorCode.RuleViolation, Resources.AncillaryApplication_InvalidQueuedMessage.Format(nameof(UsageMessage), "anunknownmessage")); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } @@ -68,8 +73,8 @@ public async Task WhenDeliverUsageAsyncAndMessageHasNoForId_ThenReturnsError() result.Should().BeError(ErrorCode.RuleViolation, Resources.AncillaryApplication_MissingUsageForId); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } @@ -86,8 +91,8 @@ public async Task WhenDeliverUsageAsyncAndMessageHasNoEventName_ThenReturnsError result.Should().BeError(ErrorCode.RuleViolation, Resources.AncillaryApplication_MissingUsageEventName); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } @@ -107,8 +112,8 @@ public async Task WhenDeliverUsageAsync_ThenDelivers() var result = await _application.DeliverUsageAsync(_caller.Object, messageAsJson, CancellationToken.None); result.Should().BeSuccess(); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), "aforid", "aneventname", + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "aforid", "aneventname", It.Is>(dic => dic.Count == 1 && dic["aname"] == "avalue" @@ -169,11 +174,157 @@ public async Task WhenDeliverAuditAsync_ThenDelivers() ), It.IsAny())); } + [Fact] + public async Task WhenDeliverEmailAsyncAndMessageIsNotRehydratable_ThenReturnsError() + { + var result = await _application.DeliverEmailAsync(_caller.Object, "anunknownmessage", CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_InvalidQueuedMessage.Format(nameof(EmailMessage), "anunknownmessage")); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverEmailAsyncAndMessageHasNoHtml_ThenReturnsError() + { + var messageAsJson = new EmailMessage + { + Html = null + }.ToJson()!; + + var result = await _application.DeliverEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_MissingEmailHtml); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverEmailAsyncAndMessageHasNoSubject_ThenReturnsError() + { + var messageAsJson = new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = null + } + }.ToJson()!; + + var result = await _application.DeliverEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_MissingEmailSubject); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverEmailAsyncAndMessageHasNoBody_ThenReturnsError() + { + var messageAsJson = new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = "asubject", + HtmlBody = null + } + }.ToJson()!; + + var result = await _application.DeliverEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_MissingEmailBody); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverEmailAsyncAndMessageHasNoRecipient_ThenReturnsError() + { + var messageAsJson = new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = "asubject", + HtmlBody = "abody", + ToEmailAddress = null + } + }.ToJson()!; + + var result = await _application.DeliverEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_MissingEmailRecipient); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverEmailAsyncAndMessageHasNoSender_ThenReturnsError() + { + var messageAsJson = new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = "asubject", + HtmlBody = "abody", + ToEmailAddress = "arecipientemailaddress", + FromEmailAddress = null + } + }.ToJson()!; + + var result = await _application.DeliverEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeError(ErrorCode.RuleViolation, + Resources.AncillaryApplication_MissingEmailSender); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDeliverEmailAsync_ThenDelivers() + { + var messageAsJson = new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = "asubject", + HtmlBody = "abody", + ToEmailAddress = "arecipientemailaddress", + ToDisplayName = "arecipient", + FromEmailAddress = "asenderemailaddress", + FromDisplayName = "asender" + } + }.ToJson()!; + + var result = await _application.DeliverEmailAsync(_caller.Object, messageAsJson, CancellationToken.None); + + result.Should().BeSuccess(); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "asubject", "abody", "arecipientemailaddress", + "arecipient", "asenderemailaddress", "asender", + It.IsAny())); + } + #if TESTINGONLY [Fact] public async Task WhenDrainAllUsagesAsyncAndNoneOnQueue_ThenDoesNotDeliver() { - _usageMessageRepository.Setup(umr => + _usageMessageQueue.Setup(umr => umr.PopSingleAsync(It.IsAny>>>(), It.IsAny())) .Returns(Task.FromResult>(false)); @@ -181,11 +332,11 @@ public async Task WhenDrainAllUsagesAsyncAndNoneOnQueue_ThenDoesNotDeliver() var result = await _application.DrainAllUsagesAsync(_caller.Object, CancellationToken.None); result.Should().BeSuccess(); - _usageMessageRepository.Verify( + _usageMessageQueue.Verify( urs => urs.PopSingleAsync(It.IsAny>>>(), It.IsAny())); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Never); } @@ -203,7 +354,7 @@ public async Task WhenDrainAllUsagesAsyncAndSomeOnQueue_ThenDeliversAll() EventName = "aneventname2" }; var callbackCount = 1; - _usageMessageRepository.Setup(umr => + _usageMessageQueue.Setup(umr => umr.PopSingleAsync(It.IsAny>>>(), It.IsAny())) .Callback((Func>> action, CancellationToken _) => @@ -227,17 +378,17 @@ public async Task WhenDrainAllUsagesAsyncAndSomeOnQueue_ThenDeliversAll() var result = await _application.DrainAllUsagesAsync(_caller.Object, CancellationToken.None); result.Should().BeSuccess(); - _usageMessageRepository.Verify( + _usageMessageQueue.Verify( urs => urs.PopSingleAsync(It.IsAny>>>(), It.IsAny()), Times.Exactly(2)); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), "aforid1", "aneventname1", + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "aforid1", "aneventname1", It.IsAny>(), It.IsAny())); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), "aforid2", "aneventname2", + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "aforid2", "aneventname2", It.IsAny>(), It.IsAny())); - _usageReportingService.Verify( - urs => urs.TrackAsync(It.IsAny(), It.IsAny(), It.IsAny(), + _usageDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny()), Times.Exactly(2)); } @@ -308,5 +459,95 @@ public async Task WhenDrainAllAuditsAsyncAndSomeOnQueue_ThenDeliversAll() _auditRepository.Verify(ar => ar.SaveAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } + + [Fact] + public async Task WhenDrainAllEmailsAsyncAndNoneOnQueue_ThenDoesNotDeliver() + { + _emailMessageQueue.Setup(umr => + umr.PopSingleAsync(It.IsAny>>>(), + It.IsAny())) + .Returns(Task.FromResult>(false)); + + var result = await _application.DrainAllEmailsAsync(_caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + _emailMessageQueue.Verify( + urs => urs.PopSingleAsync(It.IsAny>>>(), + It.IsAny())); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Fact] + public async Task WhenDrainAllEmailsAsyncAndSomeOnQueue_ThenDeliversAll() + { + var message1 = new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = "asubject1", + HtmlBody = "abody1", + ToEmailAddress = "arecipientemailaddress1", + ToDisplayName = "arecipient1", + FromEmailAddress = "asenderemailaddress1", + FromDisplayName = "asender1" + } + }; + var message2 = new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = "asubject2", + HtmlBody = "abody2", + ToEmailAddress = "arecipientemailaddress2", + ToDisplayName = "arecipient2", + FromEmailAddress = "asenderemailaddress2", + FromDisplayName = "asender2" + } + }; + var callbackCount = 1; + _emailMessageQueue.Setup(umr => + umr.PopSingleAsync(It.IsAny>>>(), + It.IsAny())) + .Callback((Func>> action, CancellationToken _) => + { + if (callbackCount == 1) + { + action(message1, CancellationToken.None); + } + + if (callbackCount == 2) + { + action(message2, CancellationToken.None); + } + }) + .Returns((Func>> _, CancellationToken _) => + { + callbackCount++; + return Task.FromResult>(callbackCount is 1 or 2); + }); + + var result = await _application.DrainAllEmailsAsync(_caller.Object, CancellationToken.None); + + result.Should().BeSuccess(); + _emailMessageQueue.Verify( + urs => urs.PopSingleAsync(It.IsAny>>>(), + It.IsAny()), Times.Exactly(2)); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "asubject1", "abody1", "arecipientemailaddress1", + "arecipient1", "asenderemailaddress1", "asender1", + It.IsAny())); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), "asubject2", "abody2", "arecipientemailaddress2", + "arecipient2", "asenderemailaddress2", "asender2", + It.IsAny())); + _emailDeliveryService.Verify( + urs => urs.DeliverAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } #endif } \ No newline at end of file diff --git a/src/AncillaryApplication/AncillaryApplication.cs b/src/AncillaryApplication/AncillaryApplication.cs index f9d08151..1ce9bdf4 100644 --- a/src/AncillaryApplication/AncillaryApplication.cs +++ b/src/AncillaryApplication/AncillaryApplication.cs @@ -18,21 +18,45 @@ public class AncillaryApplication : IAncillaryApplication { private readonly IAuditMessageQueueRepository _auditMessageQueueRepository; private readonly IAuditRepository _auditRepository; + private readonly IEmailDeliveryService _emailDeliveryService; + private readonly IEmailMessageQueue _emailMessageQueue; private readonly IIdentifierFactory _idFactory; private readonly IRecorder _recorder; - private readonly IUsageMessageQueueRepository _usageMessageQueueRepository; - private readonly IUsageReportingService _usageReportingService; + private readonly IUsageDeliveryService _usageDeliveryService; + private readonly IUsageMessageQueue _usageMessageQueue; public AncillaryApplication(IRecorder recorder, IIdentifierFactory idFactory, - IUsageMessageQueueRepository usageMessageQueueRepository, IUsageReportingService usageReportingService, - IAuditMessageQueueRepository auditMessageQueueRepository, IAuditRepository auditRepository) + IUsageMessageQueue usageMessageQueue, IUsageDeliveryService usageDeliveryService, + IAuditMessageQueueRepository auditMessageQueueRepository, IAuditRepository auditRepository, + IEmailMessageQueue emailMessageQueue, IEmailDeliveryService emailDeliveryService) { _recorder = recorder; _idFactory = idFactory; - _usageMessageQueueRepository = usageMessageQueueRepository; - _usageReportingService = usageReportingService; + _usageMessageQueue = usageMessageQueue; + _usageDeliveryService = usageDeliveryService; _auditMessageQueueRepository = auditMessageQueueRepository; _auditRepository = auditRepository; + _emailMessageQueue = emailMessageQueue; + _emailDeliveryService = emailDeliveryService; + } + + public async Task> DeliverEmailAsync(ICallerContext context, string messageAsJson, + CancellationToken cancellationToken) + { + var rehydrated = RehydrateMessage(messageAsJson); + if (!rehydrated.IsSuccessful) + { + return rehydrated.Error; + } + + var delivered = await DeliverEmailAsync(context, rehydrated.Value, cancellationToken); + if (!delivered.IsSuccessful) + { + return delivered.Error; + } + + _recorder.TraceInformation(context.ToCall(), "Delivered email message: {Message}", messageAsJson); + return true; } public async Task> DeliverUsageAsync(ICallerContext context, string messageAsJson, @@ -89,13 +113,25 @@ public async Task> DeliverAuditAsync(ICallerContext context, return true; } +#if TESTINGONLY + public async Task> DrainAllEmailsAsync(ICallerContext context, CancellationToken cancellationToken) + { + await DrainAllAsync(_emailMessageQueue, + message => DeliverEmailAsync(context, message, cancellationToken), cancellationToken); + + _recorder.TraceInformation(context.ToCall(), "Drained all email messages"); + + return Result.Ok; + } +#endif + #if TESTINGONLY public async Task> DrainAllUsagesAsync(ICallerContext context, CancellationToken cancellationToken) { - await DrainAllAsync(_usageMessageQueueRepository, + await DrainAllAsync(_usageMessageQueue, message => DeliverUsageAsync(context, message, cancellationToken), cancellationToken); - _recorder.TraceInformation(context.ToCall(), "Drained all usages"); + _recorder.TraceInformation(context.ToCall(), "Drained all usage messages"); return Result.Ok; } @@ -107,12 +143,52 @@ public async Task> DrainAllAuditsAsync(ICallerContext context, Can await DrainAllAsync(_auditMessageQueueRepository, message => DeliverAuditAsync(context, message, cancellationToken), cancellationToken); - _recorder.TraceInformation(context.ToCall(), "Drained all audits"); + _recorder.TraceInformation(context.ToCall(), "Drained all audit messages"); return Result.Ok; } #endif + private async Task> DeliverEmailAsync(ICallerContext context, EmailMessage message, + CancellationToken cancellationToken) + { + if (message.Html.IsInvalidParameter(x => x.Exists(), nameof(EmailMessage.Html), out _)) + { + return Error.RuleViolation(Resources.AncillaryApplication_MissingEmailHtml); + } + + if (message.Html!.Subject.IsInvalidParameter(x => x.HasValue(), nameof(EmailMessage.Html.Subject), out _)) + { + return Error.RuleViolation(Resources.AncillaryApplication_MissingEmailSubject); + } + + if (message.Html.HtmlBody.IsInvalidParameter(x => x.HasValue(), nameof(EmailMessage.Html.HtmlBody), out _)) + { + return Error.RuleViolation(Resources.AncillaryApplication_MissingEmailBody); + } + + if (message.Html.ToEmailAddress.IsInvalidParameter(x => x.HasValue(), nameof(EmailMessage.Html.ToEmailAddress), + out _)) + { + return Error.RuleViolation(Resources.AncillaryApplication_MissingEmailRecipient); + } + + if (message.Html.FromEmailAddress.IsInvalidParameter(x => x.HasValue(), + nameof(EmailMessage.Html.FromEmailAddress), out _)) + { + return Error.RuleViolation(Resources.AncillaryApplication_MissingEmailSender); + } + + await _emailDeliveryService.DeliverAsync(context, message.Html.Subject!, message.Html.HtmlBody!, + message.Html.ToEmailAddress!, message.Html.ToDisplayName, message.Html.FromEmailAddress!, + message.Html.FromDisplayName, + cancellationToken); + + _recorder.TraceInformation(context.ToCall(), "Delivered email to {For}", message.Html.ToEmailAddress!); + + return true; + } + private async Task> DeliverUsageAsync(ICallerContext context, UsageMessage message, CancellationToken cancellationToken) { @@ -126,7 +202,7 @@ private async Task> DeliverUsageAsync(ICallerContext context return Error.RuleViolation(Resources.AncillaryApplication_MissingUsageEventName); } - await _usageReportingService.TrackAsync(context, message.ForId!, message.EventName!, message.Additional, + await _usageDeliveryService.DeliverAsync(context, message.ForId!, message.EventName!, message.Additional, cancellationToken); _recorder.TraceInformation(context.ToCall(), "Delivered usage for {For}", message.ForId!); diff --git a/src/AncillaryApplication/IAncillaryApplication.cs b/src/AncillaryApplication/IAncillaryApplication.cs index fdfca6fd..fd992c03 100644 --- a/src/AncillaryApplication/IAncillaryApplication.cs +++ b/src/AncillaryApplication/IAncillaryApplication.cs @@ -9,6 +9,9 @@ public interface IAncillaryApplication Task> DeliverAuditAsync(ICallerContext context, string messageAsJson, CancellationToken cancellationToken); + Task> DeliverEmailAsync(ICallerContext context, string messageAsJson, + CancellationToken cancellationToken); + Task> DeliverUsageAsync(ICallerContext context, string messageAsJson, CancellationToken cancellationToken); @@ -16,6 +19,10 @@ Task> DeliverUsageAsync(ICallerContext context, string messa Task> DrainAllAuditsAsync(ICallerContext context, CancellationToken cancellationToken); #endif +#if TESTINGONLY + Task> DrainAllEmailsAsync(ICallerContext context, CancellationToken cancellationToken); +#endif + #if TESTINGONLY Task> DrainAllUsagesAsync(ICallerContext context, CancellationToken cancellationToken); #endif diff --git a/src/AncillaryApplication/Resources.Designer.cs b/src/AncillaryApplication/Resources.Designer.cs index d451b0c0..1152100b 100644 --- a/src/AncillaryApplication/Resources.Designer.cs +++ b/src/AncillaryApplication/Resources.Designer.cs @@ -77,6 +77,51 @@ internal static string AncillaryApplication_MissingAuditCode { } } + /// + /// Looks up a localized string similar to The email message is missing a 'HtmlBody'. + /// + internal static string AncillaryApplication_MissingEmailBody { + get { + return ResourceManager.GetString("AncillaryApplication_MissingEmailBody", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email message is missing the 'HTML' email. + /// + internal static string AncillaryApplication_MissingEmailHtml { + get { + return ResourceManager.GetString("AncillaryApplication_MissingEmailHtml", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email message is missing a 'ToEmailAddress' recipient. + /// + internal static string AncillaryApplication_MissingEmailRecipient { + get { + return ResourceManager.GetString("AncillaryApplication_MissingEmailRecipient", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email message is missing a 'FromEmailAddress' sender. + /// + internal static string AncillaryApplication_MissingEmailSender { + get { + return ResourceManager.GetString("AncillaryApplication_MissingEmailSender", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The email message is missing a 'Subject'. + /// + internal static string AncillaryApplication_MissingEmailSubject { + get { + return ResourceManager.GetString("AncillaryApplication_MissingEmailSubject", resourceCulture); + } + } + /// /// Looks up a localized string similar to The audit message is missing a 'TenantId'. /// diff --git a/src/AncillaryApplication/Resources.resx b/src/AncillaryApplication/Resources.resx index cd36ccca..b070821a 100644 --- a/src/AncillaryApplication/Resources.resx +++ b/src/AncillaryApplication/Resources.resx @@ -39,4 +39,19 @@ The audit message is missing a 'TenantId' + + The email message is missing the 'HTML' email + + + The email message is missing a 'Subject' + + + The email message is missing a 'HtmlBody' + + + The email message is missing a 'ToEmailAddress' recipient + + + The email message is missing a 'FromEmailAddress' sender + \ No newline at end of file diff --git a/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs index ea5b1cc5..b589ce0d 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/AuditsApiSpec.cs @@ -34,12 +34,12 @@ public async Task WhenDeliverAudit_ThenDelivers() { Message = new AuditMessage { + MessageId = "amessageid", + TenantId = "atenantid", CallId = "acallid", CallerId = "acallerid", - TenantId = "atenantid", AuditCode = "anauditcode", AgainstId = "anagainstid", - MessageId = "amessageid", MessageTemplate = "amessagetemplate", Arguments = new List { "anarg1", "anarg2" } }.ToJson()! @@ -128,6 +128,6 @@ public async Task WhenDrainAllAuditsAndSome_ThenDrains() private static void OverrideDependencies(IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); } } \ No newline at end of file diff --git a/src/AncillaryInfrastructure.IntegrationTests/EmailsApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/EmailsApiSpec.cs new file mode 100644 index 00000000..4b8cc19d --- /dev/null +++ b/src/AncillaryInfrastructure.IntegrationTests/EmailsApiSpec.cs @@ -0,0 +1,129 @@ +using AncillaryInfrastructure.IntegrationTests.Stubs; +using ApiHost1; +using Application.Persistence.Shared; +using Application.Persistence.Shared.ReadModels; +using Common; +using Common.Extensions; +using FluentAssertions; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using IntegrationTesting.WebApi.Common; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common; +using Xunit; +using Task = System.Threading.Tasks.Task; + +namespace AncillaryInfrastructure.IntegrationTests; + +[Trait("Category", "Integration.Web")] +[Collection("API")] +public class EmailsApiSpec : WebApiSpec +{ + private readonly StubEmailDeliveryService _emailDeliveryService; + private readonly IEmailMessageQueue _emailMessageQueue; + + public EmailsApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) + { + EmptyAllRepositories(setup); + _emailDeliveryService = setup.GetRequiredService().As(); + _emailDeliveryService.Reset(); + _emailMessageQueue = setup.GetRequiredService(); + _emailMessageQueue.DestroyAllAsync(CancellationToken.None).GetAwaiter().GetResult(); + } + + [Fact] + public async Task WhenDeliverEmail_ThenDelivers() + { + var request = new DeliverEmailRequest + { + Message = new EmailMessage + { + MessageId = "amessageid", + CallId = "acallid", + CallerId = "acallerid", + Html = new QueuedEmailHtmlMessage + { + FromDisplayName = "afromdisplayname", + FromEmailAddress = "afromemail", + HtmlBody = "anhtmlbody", + Subject = "asubject", + ToDisplayName = "atodisplayname", + ToEmailAddress = "atoemail" + } + }.ToJson()! + }; + var result = await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); + + result.Content.Value.IsDelivered.Should().BeTrue(); + _emailDeliveryService.LastSubject.Should().Be("asubject"); + } + +#if TESTINGONLY + [Fact] + public async Task WhenDrainAllEmailsAndNone_ThenDoesNotDrainAny() + { + var request = new DrainAllEmailsRequest(); + await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); + + _emailDeliveryService.LastSubject.Should().BeNone(); + } +#endif + +#if TESTINGONLY + [Fact] + public async Task WhenDrainAllEmailsAndSome_ThenDrains() + { + var call = CallContext.CreateCustom("acallid", "acallerid", "atenantid"); + await _emailMessageQueue.PushAsync(call, new EmailMessage + { + MessageId = "amessageid1", + Html = new QueuedEmailHtmlMessage + { + FromDisplayName = "afromdisplayname", + FromEmailAddress = "afromemail", + HtmlBody = "anhtmlbody", + Subject = "asubject1", + ToDisplayName = "atodisplayname", + ToEmailAddress = "atoemail" + } + }, CancellationToken.None); + await _emailMessageQueue.PushAsync(call, new EmailMessage + { + MessageId = "amessageid2", + Html = new QueuedEmailHtmlMessage + { + FromDisplayName = "afromdisplayname", + FromEmailAddress = "afromemail", + HtmlBody = "anhtmlbody", + Subject = "asubject2", + ToDisplayName = "atodisplayname", + ToEmailAddress = "atoemail" + } + }, CancellationToken.None); + await _emailMessageQueue.PushAsync(call, new EmailMessage + { + MessageId = "amessageid3", + Html = new QueuedEmailHtmlMessage + { + FromDisplayName = "afromdisplayname", + FromEmailAddress = "afromemail", + HtmlBody = "anhtmlbody", + Subject = "asubject3", + ToDisplayName = "atodisplayname", + ToEmailAddress = "atoemail" + } + }, CancellationToken.None); + + var request = new DrainAllEmailsRequest(); + await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); + + _emailDeliveryService.AllSubjects.Count.Should().Be(3); + _emailDeliveryService.AllSubjects.Should().ContainInOrder("asubject1", "asubject2", "asubject3"); + } +#endif + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubEmailDeliveryService.cs b/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubEmailDeliveryService.cs new file mode 100644 index 00000000..cb9c3ce8 --- /dev/null +++ b/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubEmailDeliveryService.cs @@ -0,0 +1,28 @@ +using Application.Interfaces; +using Application.Persistence.Shared; +using Common; + +namespace AncillaryInfrastructure.IntegrationTests.Stubs; + +public sealed class StubEmailDeliveryService : IEmailDeliveryService +{ + public List AllSubjects { get; private set; } = new(); + + public Optional LastSubject { get; private set; } = Optional.None; + + public Task> DeliverAsync(ICallerContext context, string subject, string htmlBody, + string toEmailAddress, string? toDisplayName, + string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken = default) + { + AllSubjects.Add(subject); + LastSubject = Optional.Some(subject); + + return Task.FromResult(Result.Ok); + } + + public void Reset() + { + AllSubjects = new List(); + LastSubject = Optional.None; + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubUsageReportingService.cs b/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubUsageDeliveryService.cs similarity index 80% rename from src/AncillaryInfrastructure.IntegrationTests/Stubs/StubUsageReportingService.cs rename to src/AncillaryInfrastructure.IntegrationTests/Stubs/StubUsageDeliveryService.cs index 9a393b0c..bd362efb 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubUsageReportingService.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/Stubs/StubUsageDeliveryService.cs @@ -4,13 +4,13 @@ namespace AncillaryInfrastructure.IntegrationTests.Stubs; -public sealed class StubUsageReportingService : IUsageReportingService +public sealed class StubUsageDeliveryService : IUsageDeliveryService { public List AllEventNames { get; private set; } = new(); public Optional LastEventName { get; private set; } = Optional.None; - public Task> TrackAsync(ICallerContext context, string forId, string eventName, + public Task> DeliverAsync(ICallerContext context, string forId, string eventName, Dictionary? additional = null, CancellationToken cancellationToken = default) { diff --git a/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs b/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs index 42290c4a..40642736 100644 --- a/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs +++ b/src/AncillaryInfrastructure.IntegrationTests/UsagesApiSpec.cs @@ -19,15 +19,15 @@ namespace AncillaryInfrastructure.IntegrationTests; [Collection("API")] public class UsagesApiSpec : WebApiSpec { - private readonly IUsageMessageQueueRepository _usageMessageQueue; - private readonly StubUsageReportingService _usageReportingService; + private readonly StubUsageDeliveryService _usageDeliveryService; + private readonly IUsageMessageQueue _usageMessageQueue; public UsagesApiSpec(WebApiSetup setup) : base(setup, OverrideDependencies) { EmptyAllRepositories(setup); - _usageReportingService = setup.GetRequiredService().As(); - _usageReportingService.Reset(); - _usageMessageQueue = setup.GetRequiredService(); + _usageDeliveryService = setup.GetRequiredService().As(); + _usageDeliveryService.Reset(); + _usageMessageQueue = setup.GetRequiredService(); _usageMessageQueue.DestroyAllAsync(CancellationToken.None).GetAwaiter().GetResult(); } @@ -38,17 +38,17 @@ public async Task WhenDeliverUsage_ThenDelivers() { Message = new UsageMessage { + MessageId = "amessageid", CallId = "acallid", CallerId = "acallerid", EventName = "aneventname", - ForId = "aforid", - MessageId = "amessageid" + ForId = "aforid" }.ToJson()! }; var result = await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); result.Content.Value.IsDelivered.Should().BeTrue(); - _usageReportingService.LastEventName.Should().Be("aneventname"); + _usageDeliveryService.LastEventName.Should().Be("aneventname"); } #if TESTINGONLY @@ -58,7 +58,7 @@ public async Task WhenDrainAllUsagesAndNone_ThenDoesNotDrainAny() var request = new DrainAllUsagesRequest(); await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); - _usageReportingService.LastEventName.Should().BeNone(); + _usageDeliveryService.LastEventName.Should().BeNone(); } #endif @@ -89,13 +89,13 @@ public async Task WhenDrainAllUsagesAndSome_ThenDrains() var request = new DrainAllUsagesRequest(); await Api.PostAsync(request, req => req.SetHMACAuth(request, "asecret")); - _usageReportingService.AllEventNames.Count.Should().Be(3); - _usageReportingService.AllEventNames.Should().ContainInOrder("aneventname1", "aneventname2", "aneventname3"); + _usageDeliveryService.AllEventNames.Count.Should().Be(3); + _usageDeliveryService.AllEventNames.Should().ContainInOrder("aneventname1", "aneventname2", "aneventname3"); } #endif private static void OverrideDependencies(IServiceCollection services) { - services.AddSingleton(); + services.AddSingleton(); } } \ No newline at end of file diff --git a/src/AncillaryInfrastructure.UnitTests/Api/Emails/DeliverUsageRequestValidatorSpec.cs b/src/AncillaryInfrastructure.UnitTests/Api/Emails/DeliverUsageRequestValidatorSpec.cs new file mode 100644 index 00000000..86c79f19 --- /dev/null +++ b/src/AncillaryInfrastructure.UnitTests/Api/Emails/DeliverUsageRequestValidatorSpec.cs @@ -0,0 +1,40 @@ +using AncillaryInfrastructure.Api.Emails; +using FluentAssertions; +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using UnitTesting.Common.Validation; +using Xunit; + +namespace AncillaryInfrastructure.UnitTests.Api.Emails; + +[Trait("Category", "Unit")] +public class DeliverEmailRequestValidatorSpec +{ + private readonly DeliverEmailRequest _dto; + private readonly DeliverEmailRequestValidator _validator; + + public DeliverEmailRequestValidatorSpec() + { + _validator = new DeliverEmailRequestValidator(); + _dto = new DeliverEmailRequest + { + Message = "amessage" + }; + } + + [Fact] + public void WhenAllProperties_ThenSucceeds() + { + _validator.ValidateAndThrow(_dto); + } + + [Fact] + public void WhenMessageIsNull_ThenThrows() + { + _dto.Message = null!; + + _validator.Invoking(x => x.ValidateAndThrow(_dto)) + .Should().Throw() + .WithMessageLike(Resources.AnyQueueMessageValidator_InvalidMessage); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/AncillaryModule.cs b/src/AncillaryInfrastructure/AncillaryModule.cs index 473298a7..e80347db 100644 --- a/src/AncillaryInfrastructure/AncillaryModule.cs +++ b/src/AncillaryInfrastructure/AncillaryModule.cs @@ -39,12 +39,12 @@ public Action RegisterServices { services.RegisterUnshared(); services.RegisterUnshared(); - services.RegisterUnshared(c => - new UsageMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); + services.RegisterUnshared(c => + new UsageMessageQueue(c.Resolve(), c.ResolveForPlatform())); services.RegisterUnshared(c => new AuditMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); - services.RegisterUnshared(c => - new EmailMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); + services.RegisterUnshared(c => + new EmailMessageQueue(c.Resolve(), c.ResolveForPlatform())); services.RegisterUnshared(c => new AuditRepository(c.ResolveForUnshared(), c.ResolveForUnshared(), c.ResolveForUnshared>(), @@ -53,7 +53,8 @@ public Action RegisterServices c => new AuditProjection(c.ResolveForUnshared(), c.ResolveForUnshared(), c.ResolveForPlatform())); - services.RegisterUnshared(); + services.RegisterUnshared(); + services.RegisterUnshared(); }; } } diff --git a/src/AncillaryInfrastructure/Api/Emails/DeliverUsageRequestValidator.cs b/src/AncillaryInfrastructure/Api/Emails/DeliverUsageRequestValidator.cs new file mode 100644 index 00000000..c40652b0 --- /dev/null +++ b/src/AncillaryInfrastructure/Api/Emails/DeliverUsageRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using JetBrains.Annotations; + +namespace AncillaryInfrastructure.Api.Emails; + +[UsedImplicitly] +public class DeliverEmailRequestValidator : AbstractValidator +{ + public DeliverEmailRequestValidator() + { + RuleFor(req => req.Message) + .NotEmpty() + .WithMessage(Resources.AnyQueueMessageValidator_InvalidMessage); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/Api/Emails/EmailsApi.cs b/src/AncillaryInfrastructure/Api/Emails/EmailsApi.cs new file mode 100644 index 00000000..2c474eb1 --- /dev/null +++ b/src/AncillaryInfrastructure/Api/Emails/EmailsApi.cs @@ -0,0 +1,41 @@ +using AncillaryApplication; +using Common; +using Infrastructure.Interfaces; +using Infrastructure.Web.Api.Common.Extensions; +using Infrastructure.Web.Api.Interfaces; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; + +namespace AncillaryInfrastructure.Api.Emails; + +public sealed class EmailsApi : IWebApiService +{ + private readonly IAncillaryApplication _ancillaryApplication; + private readonly ICallerContextFactory _contextFactory; + + public EmailsApi(ICallerContextFactory contextFactory, IAncillaryApplication ancillaryApplication) + { + _contextFactory = contextFactory; + _ancillaryApplication = ancillaryApplication; + } + + public async Task> Deliver(DeliverEmailRequest request, + CancellationToken cancellationToken) + { + var delivered = + await _ancillaryApplication.DeliverEmailAsync(_contextFactory.Create(), request.Message, cancellationToken); + + return () => delivered.HandleApplicationResult(_ => + new PostResult(new DeliverMessageResponse { IsDelivered = true })); + } + +#if TESTINGONLY + public async Task DrainAll(DrainAllEmailsRequest request, + CancellationToken cancellationToken) + { + var result = await _ancillaryApplication.DrainAllEmailsAsync(_contextFactory.Create(), cancellationToken); + + return () => result.Match(() => new Result(), + error => new Result(error)); + } +#endif +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/ApplicationServices/NullEmailDeliveryService.cs b/src/AncillaryInfrastructure/ApplicationServices/NullEmailDeliveryService.cs new file mode 100644 index 00000000..0677a6c8 --- /dev/null +++ b/src/AncillaryInfrastructure/ApplicationServices/NullEmailDeliveryService.cs @@ -0,0 +1,39 @@ +using Application.Common; +using Application.Interfaces; +using Application.Persistence.Shared; +using Common; +using Common.Extensions; +using Task = System.Threading.Tasks.Task; + +namespace AncillaryInfrastructure.ApplicationServices; + +/// +/// Provides a that does nothing +/// +public class NullEmailDeliveryService : IEmailDeliveryService +{ + private readonly IRecorder _recorder; + + public NullEmailDeliveryService(IRecorder recorder) + { + _recorder = recorder; + } + + public Task> DeliverAsync(ICallerContext context, string subject, string htmlBody, + string toEmailAddress, string? toDisplayName, + string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken = default) + { + _recorder.TraceInformation(context.ToCall(), + $"{nameof(NullUsageDeliveryService)} delivers email event {{Event}} for {{For}} with properties: {{Properties}}", + subject, toEmailAddress, new + { + To = toEmailAddress, + ToDisplayName = toDisplayName, + From = fromEmailAddress, + FromDisplayName = fromDisplayName, + Body = htmlBody + }.ToJson()!); + + return Task.FromResult(Result.Ok); + } +} \ No newline at end of file diff --git a/src/AncillaryInfrastructure/ApplicationServices/NullUsageReportingService.cs b/src/AncillaryInfrastructure/ApplicationServices/NullUsageDeliveryService.cs similarity index 63% rename from src/AncillaryInfrastructure/ApplicationServices/NullUsageReportingService.cs rename to src/AncillaryInfrastructure/ApplicationServices/NullUsageDeliveryService.cs index cf94223f..6cce0fa8 100644 --- a/src/AncillaryInfrastructure/ApplicationServices/NullUsageReportingService.cs +++ b/src/AncillaryInfrastructure/ApplicationServices/NullUsageDeliveryService.cs @@ -8,18 +8,18 @@ namespace AncillaryInfrastructure.ApplicationServices; /// -/// Provides a that does nothing +/// Provides a that does nothing /// -public class NullUsageReportingService : IUsageReportingService +public class NullUsageDeliveryService : IUsageDeliveryService { private readonly IRecorder _recorder; - public NullUsageReportingService(IRecorder recorder) + public NullUsageDeliveryService(IRecorder recorder) { _recorder = recorder; } - public Task> TrackAsync(ICallerContext context, string forId, string eventName, + public Task> DeliverAsync(ICallerContext context, string forId, string eventName, Dictionary? additional = null, CancellationToken cancellationToken = default) { @@ -27,7 +27,7 @@ public Task> TrackAsync(ICallerContext context, string forId, stri ? additional.ToJson()! : "none"; _recorder.TraceInformation(context.ToCall(), - $"{nameof(NullUsageReportingService)} tracks usage event {{Event}} for {{For}} with properties: {{Properties}}", + $"{nameof(NullUsageDeliveryService)} delivers usage event {{Event}} for {{For}} with properties: {{Properties}}", eventName, forId, properties); return Task.FromResult(Result.Ok); diff --git a/src/ApiHost1/ApiHostModule.cs b/src/ApiHost1/ApiHostModule.cs index 770cbdb5..a05776a1 100644 --- a/src/ApiHost1/ApiHostModule.cs +++ b/src/ApiHost1/ApiHostModule.cs @@ -29,13 +29,13 @@ public Action RegisterServices { return (_, services) => { - services.RegisterUnshared(c => - new EmailMessageQueueRepository(c.Resolve(), c.ResolveForPlatform())); + services.RegisterUnshared(c => + new EmailMessageQueue(c.Resolve(), c.ResolveForPlatform())); services.RegisterUnshared(); services.RegisterUnshared(); services.RegisterUnshared(); - services.RegisterUnshared(); + services.RegisterUnshared(); }; } } diff --git a/src/Application.Persistence.Shared/IEmailDeliveryService.cs b/src/Application.Persistence.Shared/IEmailDeliveryService.cs new file mode 100644 index 00000000..4492aba8 --- /dev/null +++ b/src/Application.Persistence.Shared/IEmailDeliveryService.cs @@ -0,0 +1,17 @@ +using Application.Interfaces; +using Common; + +namespace Application.Persistence.Shared; + +/// +/// Defines a service to which we can deliver email events +/// +public interface IEmailDeliveryService +{ + /// + /// Delivers the email + /// + Task> DeliverAsync(ICallerContext context, string subject, string htmlBody, string toEmailAddress, + string? toDisplayName, + string fromEmailAddress, string? fromDisplayName, CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/Application.Persistence.Shared/IUsageMessageQueueRepository.cs b/src/Application.Persistence.Shared/IEmailMessageQueue.cs similarity index 69% rename from src/Application.Persistence.Shared/IUsageMessageQueueRepository.cs rename to src/Application.Persistence.Shared/IEmailMessageQueue.cs index 59652482..22f19629 100644 --- a/src/Application.Persistence.Shared/IUsageMessageQueueRepository.cs +++ b/src/Application.Persistence.Shared/IEmailMessageQueue.cs @@ -4,7 +4,7 @@ namespace Application.Persistence.Shared; -public interface IUsageMessageQueueRepository : IMessageQueueStore, IApplicationRepository +public interface IEmailMessageQueue : IMessageQueueStore, IApplicationRepository { new Task> DestroyAllAsync(CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Persistence.Shared/IUsageReportingService.cs b/src/Application.Persistence.Shared/IUsageDeliveryService.cs similarity index 52% rename from src/Application.Persistence.Shared/IUsageReportingService.cs rename to src/Application.Persistence.Shared/IUsageDeliveryService.cs index 6ec21026..cef428f5 100644 --- a/src/Application.Persistence.Shared/IUsageReportingService.cs +++ b/src/Application.Persistence.Shared/IUsageDeliveryService.cs @@ -4,13 +4,13 @@ namespace Application.Persistence.Shared; /// -/// Defines a service to which we can report usages events +/// Defines a service to which we can deliver usages events /// -public interface IUsageReportingService +public interface IUsageDeliveryService { /// - /// Tracks the usage event + /// Delivers the usage event /// - Task> TrackAsync(ICallerContext context, string forId, string eventName, + Task> DeliverAsync(ICallerContext context, string forId, string eventName, Dictionary? additional = null, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/Application.Persistence.Shared/IEmailMessageQueueRepository.cs b/src/Application.Persistence.Shared/IUsageMessageQueue.cs similarity index 69% rename from src/Application.Persistence.Shared/IEmailMessageQueueRepository.cs rename to src/Application.Persistence.Shared/IUsageMessageQueue.cs index ba8f7d88..9ff896bb 100644 --- a/src/Application.Persistence.Shared/IEmailMessageQueueRepository.cs +++ b/src/Application.Persistence.Shared/IUsageMessageQueue.cs @@ -4,7 +4,7 @@ namespace Application.Persistence.Shared; -public interface IEmailMessageQueueRepository : IMessageQueueStore, IApplicationRepository +public interface IUsageMessageQueue : IMessageQueueStore, IApplicationRepository { new Task> DestroyAllAsync(CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs index 239209fa..a89d5659 100644 --- a/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs +++ b/src/Application.Persistence.Shared/ReadModels/EmailMessage.cs @@ -12,7 +12,7 @@ public class QueuedEmailHtmlMessage { public string? FromDisplayName { get; set; } - public string? FromEmail { get; set; } + public string? FromEmailAddress { get; set; } public string? HtmlBody { get; set; } @@ -20,5 +20,5 @@ public class QueuedEmailHtmlMessage public string? ToDisplayName { get; set; } - public string? ToEmail { get; set; } + public string? ToEmailAddress { get; set; } } \ No newline at end of file diff --git a/src/Application.Services.Shared/IEmailSendingService.cs b/src/Application.Services.Shared/IEmailSchedulingService.cs similarity index 66% rename from src/Application.Services.Shared/IEmailSendingService.cs rename to src/Application.Services.Shared/IEmailSchedulingService.cs index 62f1bfa1..424b75b8 100644 --- a/src/Application.Services.Shared/IEmailSendingService.cs +++ b/src/Application.Services.Shared/IEmailSchedulingService.cs @@ -4,11 +4,12 @@ namespace Application.Services.Shared; /// -/// Defines an asynchronous email sending service, that will queue messages for asynchronous and deferred delivery +/// Defines an email scheduling service, that will schedule messages for asynchronous and deferred delivery /// -public interface IEmailSendingService +public interface IEmailSchedulingService { - Task> SendHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, CancellationToken cancellationToken); + Task> ScheduleHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, + CancellationToken cancellationToken); } /// diff --git a/src/AzureFunctions.Api.WorkerHost/Functions/DeliverEmail.cs b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverEmail.cs new file mode 100644 index 00000000..36265e8a --- /dev/null +++ b/src/AzureFunctions.Api.WorkerHost/Functions/DeliverEmail.cs @@ -0,0 +1,23 @@ +using Application.Persistence.Shared.ReadModels; +using Infrastructure.Workers.Api; +using Infrastructure.Workers.Api.Workers; +using Microsoft.Azure.Functions.Worker; + +namespace AzureFunctions.Api.WorkerHost.Functions; + +public sealed class DeliverEmail +{ + private readonly IQueueMonitoringApiRelayWorker _worker; + + public DeliverEmail(IQueueMonitoringApiRelayWorker worker) + { + _worker = worker; + } + + [Function(nameof(DeliverEmail))] + public Task Run([QueueTrigger(DeliverEmailRelayWorker.QueueName)] EmailMessage message, + FunctionContext context) + { + return _worker.RelayMessageOrThrowAsync(message, context.CancellationToken); + } +} \ No newline at end of file diff --git a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs index dcf5b17f..b648033c 100644 --- a/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs +++ b/src/AzureFunctions.Api.WorkerHost/HostExtensions.cs @@ -48,5 +48,6 @@ public static void AddDependencies(this IServiceCollection services, HostBuilder c.Resolve().GetAncillaryApiHostBaseUrl())); services.AddSingleton, DeliverUsageRelayWorker>(); services.AddSingleton, DeliverAuditRelayWorker>(); + services.AddSingleton, DeliverEmailRelayWorker>(); } } \ No newline at end of file diff --git a/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs b/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs index 7a8e42d0..5f49e7e8 100644 --- a/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs +++ b/src/Infrastructure.Common/Recording/QueuedUsageReporter.cs @@ -26,11 +26,11 @@ namespace Infrastructure.Common.Recording; /// public class QueuedUsageReporter : IUsageReporter { - private readonly IUsageMessageQueueRepository _repository; + private readonly IUsageMessageQueue _queue; // ReSharper disable once UnusedParameter.Local public QueuedUsageReporter(IDependencyContainer container, ISettings settings) - : this(new UsageMessageQueueRepository(NullRecorder.Instance, + : this(new UsageMessageQueue(NullRecorder.Instance, #if !TESTINGONLY #if HOSTEDONAZURE AzureStorageAccountQueueStore.Create(NullRecorder.Instance, settings) @@ -44,9 +44,9 @@ public QueuedUsageReporter(IDependencyContainer container, ISettings settings) { } - internal QueuedUsageReporter(IUsageMessageQueueRepository repository) + internal QueuedUsageReporter(IUsageMessageQueue queue) { - _repository = repository; + _queue = queue; } public void Track(ICallContext? context, string forId, string eventName, @@ -70,6 +70,6 @@ public void Track(ICallContext? context, string forId, string eventName, : string.Empty) }; - _repository.PushAsync(call, message, CancellationToken.None).GetAwaiter().GetResult(); + _queue.PushAsync(call, message, CancellationToken.None).GetAwaiter().GetResult(); } } \ No newline at end of file diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueueRepository.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueue.cs similarity index 90% rename from src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueueRepository.cs rename to src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueue.cs index 6201185d..063b90db 100644 --- a/src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueueRepository.cs +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/EmailMessageQueue.cs @@ -7,11 +7,11 @@ namespace Infrastructure.Persistence.Shared.ApplicationServices; -public class EmailMessageQueueRepository : IEmailMessageQueueRepository +public class EmailMessageQueue : IEmailMessageQueue { private readonly MessageQueueStore _messageQueue; - public EmailMessageQueueRepository(IRecorder recorder, IQueueStore store) + public EmailMessageQueue(IRecorder recorder, IQueueStore store) { _messageQueue = new MessageQueueStore(recorder, store); } diff --git a/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueueRepository.cs b/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueue.cs similarity index 90% rename from src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueueRepository.cs rename to src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueue.cs index af80b690..7dd246a2 100644 --- a/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueueRepository.cs +++ b/src/Infrastructure.Persistence.Shared/ApplicationServices/UsageMessageQueue.cs @@ -7,11 +7,11 @@ namespace Infrastructure.Persistence.Shared.ApplicationServices; -public class UsageMessageQueueRepository : IUsageMessageQueueRepository +public class UsageMessageQueue : IUsageMessageQueue { private readonly MessageQueueStore _messageQueue; - public UsageMessageQueueRepository(IRecorder recorder, IQueueStore store) + public UsageMessageQueue(IRecorder recorder, IQueueStore store) { _messageQueue = new MessageQueueStore(recorder, store); } diff --git a/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs index d3e505d1..fbe48c64 100644 --- a/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/EmailNotificationsService.cs @@ -9,14 +9,14 @@ namespace Infrastructure.Shared.ApplicationServices; /// /// Provides a that delivers notifications via asynchronous email delivery using -/// +/// /// public class EmailNotificationsService : INotificationsService { public const string ProductNameSettingName = "ApplicationServices:Notifications:SenderProductName"; public const string SenderDisplayNameSettingName = "ApplicationServices:Notifications:SenderDisplayName"; public const string SenderEmailAddressSettingName = "ApplicationServices:Notifications:SenderEmailAddress"; - private readonly IEmailSendingService _emailSendingService; + private readonly IEmailSchedulingService _emailSchedulingService; private readonly IHostSettings _hostSettings; private readonly string _productName; private readonly string _senderEmailAddress; @@ -24,11 +24,11 @@ public class EmailNotificationsService : INotificationsService private readonly IWebsiteUiService _websiteUiService; public EmailNotificationsService(IConfigurationSettings settings, IHostSettings hostSettings, - IWebsiteUiService websiteUiService, IEmailSendingService emailSendingService) + IWebsiteUiService websiteUiService, IEmailSchedulingService emailSchedulingService) { _hostSettings = hostSettings; _websiteUiService = websiteUiService; - _emailSendingService = emailSendingService; + _emailSchedulingService = emailSchedulingService; _productName = settings.Platform.GetString(ProductNameSettingName, nameof(EmailNotificationsService)); _senderEmailAddress = settings.Platform.GetString(SenderEmailAddressSettingName, nameof(EmailNotificationsService)); @@ -48,7 +48,7 @@ public async Task> NotifyPasswordRegistrationConfirmationAsync(ICa $"

Please click this link to confirm your email address

" + $"

This is an automated email from the support team at {_productName}

"; - return await _emailSendingService.SendHtmlEmail(caller, new HtmlEmail + return await _emailSchedulingService.ScheduleHtmlEmail(caller, new HtmlEmail { Subject = $"Welcome to {_productName}", Body = htmlBody, diff --git a/src/Infrastructure.Shared/ApplicationServices/EmailSendingService.cs b/src/Infrastructure.Shared/ApplicationServices/QueuingEmailSchedulingService.cs similarity index 63% rename from src/Infrastructure.Shared/ApplicationServices/EmailSendingService.cs rename to src/Infrastructure.Shared/ApplicationServices/QueuingEmailSchedulingService.cs index e74309c4..7049b8e2 100644 --- a/src/Infrastructure.Shared/ApplicationServices/EmailSendingService.cs +++ b/src/Infrastructure.Shared/ApplicationServices/QueuingEmailSchedulingService.cs @@ -8,31 +8,31 @@ namespace Infrastructure.Shared.ApplicationServices; /// -/// Provides a queueing service for asynchronous delivery of emails +/// Provides an queue scheduling service, that will schedule messages for asynchronous and deferred delivery /// -public class EmailSendingService : IEmailSendingService +public class QueuingEmailSchedulingService : IEmailSchedulingService { private readonly IRecorder _recorder; - private readonly IEmailMessageQueueRepository _repository; + private readonly IEmailMessageQueue _queue; - public EmailSendingService(IRecorder recorder, IEmailMessageQueueRepository repository) + public QueuingEmailSchedulingService(IRecorder recorder, IEmailMessageQueue queue) { _recorder = recorder; - _repository = repository; + _queue = queue; } - public async Task> SendHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, + public async Task> ScheduleHtmlEmail(ICallerContext caller, HtmlEmail htmlEmail, CancellationToken cancellationToken) { - var queued = await _repository.PushAsync(caller.ToCall(), new EmailMessage + var queued = await _queue.PushAsync(caller.ToCall(), new EmailMessage { Html = new QueuedEmailHtmlMessage { Subject = htmlEmail.Subject, - FromEmail = htmlEmail.FromEmailAddress, + FromEmailAddress = htmlEmail.FromEmailAddress, FromDisplayName = htmlEmail.FromDisplayName, HtmlBody = htmlEmail.Body, - ToEmail = htmlEmail.ToEmailAddress, + ToEmailAddress = htmlEmail.ToEmailAddress, ToDisplayName = htmlEmail.ToDisplayName } }, cancellationToken); diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DeliverEmailRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DeliverEmailRequest.cs new file mode 100644 index 00000000..a8b66854 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DeliverEmailRequest.cs @@ -0,0 +1,9 @@ +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +[Route("/emails/deliver", ServiceOperation.Post, AccessType.HMAC)] +public class DeliverEmailRequest : UnTenantedRequest +{ + public required string Message { get; set; } +} \ No newline at end of file diff --git a/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DrainAllEmailsRequest.cs b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DrainAllEmailsRequest.cs new file mode 100644 index 00000000..0274bfc8 --- /dev/null +++ b/src/Infrastructure.Web.Api.Operations.Shared/Ancillary/DrainAllEmailsRequest.cs @@ -0,0 +1,10 @@ +#if TESTINGONLY +using Infrastructure.Web.Api.Interfaces; + +namespace Infrastructure.Web.Api.Operations.Shared.Ancillary; + +[Route("/emails/drain", ServiceOperation.Post, AccessType.HMAC, true)] +public class DrainAllEmailsRequest : UnTenantedEmptyRequest +{ +} +#endif \ No newline at end of file diff --git a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs index 86195cae..7fa9557e 100644 --- a/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs +++ b/src/Infrastructure.Web.Hosting.Common/Extensions/HostExtensions.cs @@ -59,8 +59,8 @@ public static class HostExtensions { #if TESTINGONLY { "audits", new DrainAllAuditsRequest() }, - { "usages", new DrainAllUsagesRequest() } - // { "emails", new DrainAllEmailsRequest() }, + { "usages", new DrainAllUsagesRequest() }, + { "emails", new DrainAllEmailsRequest() }, // { "events", new DrainAllEventsRequest() }, #endif }; diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs index 309f9586..0f386fa1 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AWSLambdas/AWSLambdasApiSpec.cs @@ -1,15 +1,5 @@ -using Application.Persistence.Shared.ReadModels; -using Common.Extensions; -using FluentAssertions; -using Infrastructure.Web.Api.Operations.Shared.Ancillary; -using Infrastructure.Web.Interfaces.Clients; -using Infrastructure.Worker.Api.IntegrationTests.Stubs; -using Infrastructure.Workers.Api.Workers; using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using UnitTesting.Common; using Xunit; -using Task = System.Threading.Tasks.Task; namespace Infrastructure.Worker.Api.IntegrationTests.AWSLambdas; @@ -25,102 +15,21 @@ public DeliverUsageSpec(AWSLambdaHostSetup setup) : base(setup) } } - // public class DeliverUsageSpec : ApiWorkerSpec - // { - // private readonly StubServiceClient _serviceClient; - // - // public DeliverUsageSpec(AWSLambdaHostSetup setup) : base(setup, OverrideDependencies) - // { - // setup.QueueStore.DestroyAllAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None).GetAwaiter() - // .GetResult(); - // _serviceClient = setup.GetRequiredService().As(); - // _serviceClient.Reset(); - // } - // - // [Fact] - // public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() - // { - // await Setup.QueueStore.PushAsync(DeliverUsageRelayWorker.QueueName, "aninvalidusagemessage", - // CancellationToken.None); - // - // Setup.WaitForQueueProcessingToComplete(); - // - // (await Setup.QueueStore.CountAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None)) - // .Should().Be(0); - // _serviceClient.LastPostedMessage.Should().BeNone(); - // } - // - // [Fact] - // public async Task WhenMessageQueuedContaining_ThenApiCalled() - // { - // var message = new UsageMessage - // { - // ForId = "aforid", - // EventName = "aneventname" - // }.ToJson()!; - // await Setup.QueueStore.PushAsync(DeliverUsageRelayWorker.QueueName, message, CancellationToken.None); - // - // Setup.WaitForQueueProcessingToComplete(); - // - // (await Setup.QueueStore.CountAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None)) - // .Should().Be(0); - // _serviceClient.LastPostedMessage.Value.Should() - // .BeEquivalentTo(new DeliverUsageRequest { Message = message }); - // } - // - // private static void OverrideDependencies(IServiceCollection services) - // { - // services.AddSingleton(); - // } - // } - [Trait("Category", "Integration.External")] [Collection("AWSLambdas")] - public class DeliverAuditSpec : ApiWorkerSpec + public class DeliverAuditSpec : DeliverAuditSpecBase { - private readonly StubServiceClient _serviceClient; - - public DeliverAuditSpec(AWSLambdaHostSetup setup) : base(setup, OverrideDependencies) - { - setup.QueueStore.DestroyAllAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None).GetAwaiter() - .GetResult(); - _serviceClient = setup.GetRequiredService().As(); - _serviceClient.Reset(); - } - - [Fact] - public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() - { - await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, "aninvalidusagemessage", - CancellationToken.None); - - Setup.WaitForQueueProcessingToComplete(); - - (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) - .Should().Be(0); - _serviceClient.LastPostedMessage.Should().BeNone(); - } - - [Fact] - public async Task WhenMessageQueuedContaining_ThenApiCalled() + public DeliverAuditSpec(AWSLambdaHostSetup setup) : base(setup) { - var message = new AuditMessage - { - AuditCode = "anauditcode" - }.ToJson()!; - await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, message, CancellationToken.None); - - Setup.WaitForQueueProcessingToComplete(); - - (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) - .Should().Be(0); - _serviceClient.LastPostedMessage.Value.Should() - .BeEquivalentTo(new DeliverAuditRequest { Message = message }); } + } - private static void OverrideDependencies(IServiceCollection services) + [Trait("Category", "Integration.External")] + [Collection("AWSLambdas")] + public class DeliverEmailSpec : DeliverEmailSpecBase + { + public DeliverEmailSpec(AWSLambdaHostSetup setup) : base(setup) { - services.AddSingleton(); } } } \ No newline at end of file diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs index 8253a3d9..51fa1572 100644 --- a/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs +++ b/src/Infrastructure.Worker.Api.IntegrationTests/AzureFunctions/AzureFunctionsApiSpec.cs @@ -1,15 +1,5 @@ -using Application.Persistence.Shared.ReadModels; -using Common.Extensions; -using FluentAssertions; -using Infrastructure.Web.Api.Operations.Shared.Ancillary; -using Infrastructure.Web.Interfaces.Clients; -using Infrastructure.Worker.Api.IntegrationTests.Stubs; -using Infrastructure.Workers.Api.Workers; using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using UnitTesting.Common; using Xunit; -using Task = System.Threading.Tasks.Task; namespace Infrastructure.Worker.Api.IntegrationTests.AzureFunctions; @@ -18,103 +8,28 @@ public class AzureFunctionsApiSpec { [Trait("Category", "Integration.External")] [Collection("AzureFunctions")] - public class DeliverUsageSpec : ApiWorkerSpec + public class DeliverUsageSpec : DeliverUsageSpecBase { - private readonly StubServiceClient _serviceClient; - - public DeliverUsageSpec(AzureFunctionHostSetup setup) : base(setup, OverrideDependencies) - { - setup.QueueStore.DestroyAllAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None).GetAwaiter() - .GetResult(); - _serviceClient = setup.GetRequiredService().As(); - _serviceClient.Reset(); - } - - [Fact] - public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() + public DeliverUsageSpec(AzureFunctionHostSetup setup) : base(setup) { - await Setup.QueueStore.PushAsync(DeliverUsageRelayWorker.QueueName, "aninvalidusagemessage", - CancellationToken.None); - - Setup.WaitForQueueProcessingToComplete(); - - (await Setup.QueueStore.CountAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None)) - .Should().Be(0); - _serviceClient.LastPostedMessage.Should().BeNone(); - } - - [Fact] - public async Task WhenMessageQueuedContaining_ThenApiCalled() - { - var message = new UsageMessage - { - ForId = "aforid", - EventName = "aneventname" - }.ToJson()!; - await Setup.QueueStore.PushAsync(DeliverUsageRelayWorker.QueueName, message, CancellationToken.None); - - Setup.WaitForQueueProcessingToComplete(); - - (await Setup.QueueStore.CountAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None)) - .Should().Be(0); - _serviceClient.LastPostedMessage.Value.Should() - .BeEquivalentTo(new DeliverUsageRequest { Message = message }); - } - - private static void OverrideDependencies(IServiceCollection services) - { - services.AddSingleton(); } } [Trait("Category", "Integration.External")] [Collection("AzureFunctions")] - public class DeliverAuditSpec : ApiWorkerSpec + public class DeliverAuditSpec : DeliverAuditSpecBase { - private readonly StubServiceClient _serviceClient; - - public DeliverAuditSpec(AzureFunctionHostSetup setup) : base(setup, OverrideDependencies) + public DeliverAuditSpec(AzureFunctionHostSetup setup) : base(setup) { - setup.QueueStore.DestroyAllAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None).GetAwaiter() - .GetResult(); - _serviceClient = setup.GetRequiredService().As(); - _serviceClient.Reset(); - } - - [Fact] - public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() - { - await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, "aninvalidusagemessage", - CancellationToken.None); - - Setup.WaitForQueueProcessingToComplete(); - - (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) - .Should().Be(0); - _serviceClient.LastPostedMessage.Should().BeNone(); - } - - [Fact] - public async Task WhenMessageQueuedContaining_ThenApiCalled() - { - var message = new AuditMessage - { - TenantId = "atenantid", - AuditCode = "anauditcode" - }.ToJson()!; - await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, message, CancellationToken.None); - - Setup.WaitForQueueProcessingToComplete(); - - (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) - .Should().Be(0); - _serviceClient.LastPostedMessage.Value.Should() - .BeEquivalentTo(new DeliverAuditRequest { Message = message }); } + } - private static void OverrideDependencies(IServiceCollection services) + [Trait("Category", "Integration.External")] + [Collection("AzureFunctions")] + public class DeliverEmailSpec : DeliverEmailSpecBase + { + public DeliverEmailSpec(AzureFunctionHostSetup setup) : base(setup) { - services.AddSingleton(); } } } \ No newline at end of file diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverAuditSpecBase.cs b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverAuditSpecBase.cs new file mode 100644 index 00000000..69c180e7 --- /dev/null +++ b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverAuditSpecBase.cs @@ -0,0 +1,62 @@ +using Application.Persistence.Shared.ReadModels; +using Common.Extensions; +using FluentAssertions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Interfaces.Clients; +using Infrastructure.Worker.Api.IntegrationTests.Stubs; +using Infrastructure.Workers.Api.Workers; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common; +using Xunit; + +namespace Infrastructure.Worker.Api.IntegrationTests; + +public abstract class DeliverAuditSpecBase : ApiWorkerSpec + where TSetup : class, IApiWorkerSpec +{ + private readonly StubServiceClient _serviceClient; + + protected DeliverAuditSpecBase(TSetup setup) : base(setup, OverrideDependencies) + { + setup.QueueStore.DestroyAllAsync(DeliverUsageRelayWorker.QueueName, CancellationToken.None).GetAwaiter() + .GetResult(); + _serviceClient = setup.GetRequiredService().As(); + _serviceClient.Reset(); + } + + [Fact] + public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() + { + await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, "aninvalidusagemessage", + CancellationToken.None); + + Setup.WaitForQueueProcessingToComplete(); + + (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) + .Should().Be(0); + _serviceClient.LastPostedMessage.Should().BeNone(); + } + + [Fact] + public async Task WhenMessageQueuedContaining_ThenApiCalled() + { + var message = new AuditMessage + { + TenantId = "atenantid", + AuditCode = "anauditcode" + }.ToJson()!; + await Setup.QueueStore.PushAsync(DeliverAuditRelayWorker.QueueName, message, CancellationToken.None); + + Setup.WaitForQueueProcessingToComplete(); + + (await Setup.QueueStore.CountAsync(DeliverAuditRelayWorker.QueueName, CancellationToken.None)) + .Should().Be(0); + _serviceClient.LastPostedMessage.Value.Should() + .BeEquivalentTo(new DeliverAuditRequest { Message = message }); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Worker.Api.IntegrationTests/DeliverEmailSpecBase.cs b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverEmailSpecBase.cs new file mode 100644 index 00000000..cec14ad1 --- /dev/null +++ b/src/Infrastructure.Worker.Api.IntegrationTests/DeliverEmailSpecBase.cs @@ -0,0 +1,67 @@ +using Application.Persistence.Shared.ReadModels; +using FluentAssertions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Interfaces.Clients; +using Infrastructure.Worker.Api.IntegrationTests.Stubs; +using Infrastructure.Workers.Api.Workers; +using Microsoft.Extensions.DependencyInjection; +using UnitTesting.Common; +using Xunit; +using StringExtensions = Common.Extensions.StringExtensions; + +namespace Infrastructure.Worker.Api.IntegrationTests; + +public abstract class DeliverEmailSpecBase : ApiWorkerSpec + where TSetup : class, IApiWorkerSpec +{ + private readonly StubServiceClient _serviceClient; + + protected DeliverEmailSpecBase(TSetup setup) : base(setup, OverrideDependencies) + { + setup.QueueStore.DestroyAllAsync(DeliverEmailRelayWorker.QueueName, CancellationToken.None).GetAwaiter() + .GetResult(); + _serviceClient = setup.GetRequiredService().As(); + _serviceClient.Reset(); + } + + [Fact] + public async Task WhenMessageQueuedContainingInvalidContent_ThenApiNotCalled() + { + await Setup.QueueStore.PushAsync(DeliverEmailRelayWorker.QueueName, "aninvalidemailmessage", + CancellationToken.None); + + Setup.WaitForQueueProcessingToComplete(); + + (await Setup.QueueStore.CountAsync(DeliverEmailRelayWorker.QueueName, CancellationToken.None)) + .Should().Be(0); + _serviceClient.LastPostedMessage.Should().BeNone(); + } + + [Fact] + public async Task WhenMessageQueuedContaining_ThenApiCalled() + { + var message = StringExtensions.ToJson(new EmailMessage + { + Html = new QueuedEmailHtmlMessage + { + Subject = "asubject", + HtmlBody = "abody", + ToEmailAddress = "arecipientemailaddress", + FromEmailAddress = "asenderemailaddress" + } + })!; + await Setup.QueueStore.PushAsync(DeliverEmailRelayWorker.QueueName, message, CancellationToken.None); + + Setup.WaitForQueueProcessingToComplete(); + + (await Setup.QueueStore.CountAsync(DeliverEmailRelayWorker.QueueName, CancellationToken.None)) + .Should().Be(0); + _serviceClient.LastPostedMessage.Value.Should() + .BeEquivalentTo(new DeliverEmailRequest { Message = message }); + } + + private static void OverrideDependencies(IServiceCollection services) + { + services.AddSingleton(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.Workers.Api/Workers/DeliverEmailRelayWorker.cs b/src/Infrastructure.Workers.Api/Workers/DeliverEmailRelayWorker.cs new file mode 100644 index 00000000..ce7290e5 --- /dev/null +++ b/src/Infrastructure.Workers.Api/Workers/DeliverEmailRelayWorker.cs @@ -0,0 +1,33 @@ +using Application.Interfaces.Services; +using Application.Persistence.Shared.ReadModels; +using Common; +using Common.Extensions; +using Infrastructure.Web.Api.Operations.Shared.Ancillary; +using Infrastructure.Web.Interfaces.Clients; +using Task = System.Threading.Tasks.Task; + +namespace Infrastructure.Workers.Api.Workers; + +public sealed class DeliverEmailRelayWorker : IQueueMonitoringApiRelayWorker +{ + public const string QueueName = "emails"; + private readonly IRecorder _recorder; + private readonly IServiceClient _serviceClient; + private readonly IHostSettings _settings; + + public DeliverEmailRelayWorker(IRecorder recorder, IHostSettings settings, IServiceClient serviceClient) + { + _recorder = recorder; + _settings = settings; + _serviceClient = serviceClient; + } + + public async Task RelayMessageOrThrowAsync(EmailMessage message, CancellationToken cancellationToken) + { + await _serviceClient.PostQueuedMessageToApiOrThrowAsync(_recorder, + message, new DeliverEmailRequest + { + Message = message.ToJson()! + }, _settings.GetAncillaryApiHostHmacAuthSecret(), cancellationToken); + } +} \ No newline at end of file diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 224f1fd3..811a77cf 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -804,6 +804,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True @@ -840,8 +842,10 @@ public void When$condition$_Then$outcome$() True True True + True True True + True True True True @@ -890,6 +894,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True @@ -904,6 +910,8 @@ public void When$condition$_Then$outcome$() True True True + True + True True True True @@ -915,11 +923,14 @@ public void When$condition$_Then$outcome$() True True True + True True True True True True + True + True True True True