-
-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added Twilio adapter for sending SMS reliably
- Loading branch information
1 parent
887b288
commit b0eaf8f
Showing
34 changed files
with
984 additions
and
61 deletions.
There are no files selected for viewing
172 changes: 172 additions & 0 deletions
172
src/AncillaryApplication.UnitTests/TwilioApplicationSpec.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
using Application.Interfaces; | ||
using Application.Resources.Shared; | ||
using Application.Services.Shared; | ||
using Common; | ||
using Common.Extensions; | ||
using Moq; | ||
using UnitTesting.Common; | ||
using Xunit; | ||
|
||
namespace AncillaryApplication.UnitTests; | ||
|
||
[Trait("Category", "Unit")] | ||
public class TwilioApplicationSpec | ||
{ | ||
private readonly Mock<IAncillaryApplication> _ancillaryApplication; | ||
private readonly TwilioApplication _application; | ||
private readonly Mock<ICallerContext> _caller; | ||
private readonly Mock<IWebhookNotificationAuditService> _webhookNotificationAuditService; | ||
|
||
public TwilioApplicationSpec() | ||
{ | ||
_caller = new Mock<ICallerContext>(); | ||
var recorder = new Mock<IRecorder>(); | ||
_ancillaryApplication = new Mock<IAncillaryApplication>(); | ||
_webhookNotificationAuditService = new Mock<IWebhookNotificationAuditService>(); | ||
_webhookNotificationAuditService.Setup(wns => wns.CreateAuditAsync(It.IsAny<ICallerContext>(), | ||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), | ||
It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(new WebhookNotificationAudit | ||
{ | ||
Id = "anauditid", | ||
Source = "asource", | ||
EventId = "aneventid", | ||
EventType = "aneventtype", | ||
Status = WebhookNotificationStatus.Received | ||
}); | ||
_webhookNotificationAuditService.Setup(wns => wns.MarkAsProcessedAsync(It.IsAny<ICallerContext>(), | ||
It.IsAny<string>(), It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(new WebhookNotificationAudit | ||
{ | ||
Id = "anauditid", | ||
Source = "asource", | ||
EventId = "aneventid", | ||
EventType = "aneventtype", | ||
Status = WebhookNotificationStatus.Processed | ||
}); | ||
|
||
_application = new TwilioApplication(recorder.Object, _ancillaryApplication.Object, | ||
_webhookNotificationAuditService.Object); | ||
} | ||
|
||
[Fact] | ||
public async Task WhenNotifyWebhookEventWithUnhandledEvent_ThenReturnsOk() | ||
{ | ||
var eventData = new TwilioEventData | ||
{ | ||
MessageStatus = TwilioMessageStatus.Queued, | ||
MessageSid = "amessagesid", | ||
ErrorCode = null, | ||
RawDlrDoneDate = null | ||
}; | ||
|
||
var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None); | ||
|
||
result.Should().BeSuccess(); | ||
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object, | ||
TwilioConstants.AuditSourceName, It.Is<string>(s => s.StartsWith("amessagesid")), | ||
TwilioMessageStatus.Queued.ToString(), | ||
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>())); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsProcessedAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()), | ||
Times.Never); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()), | ||
Times.Never); | ||
} | ||
|
||
[Fact] | ||
public async Task WhenNotifyWebhookEventWithDeliveredEvent_ThenConfirms() | ||
{ | ||
var eventData = new TwilioEventData | ||
{ | ||
MessageStatus = TwilioMessageStatus.Delivered, | ||
MessageSid = "amessagesid", | ||
ErrorCode = null, | ||
RawDlrDoneDate = 2411271234 | ||
}; | ||
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), | ||
It.IsAny<DateTime>(), It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(Result.Ok); | ||
|
||
var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None); | ||
|
||
result.Should().BeSuccess(); | ||
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(_caller.Object, "areceiptid", | ||
new DateTime(2024, 11, 27, 12, 34, 00, DateTimeKind.Utc), It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(Result.Ok); | ||
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object, | ||
TwilioConstants.AuditSourceName, | ||
It.Is<string>(s => s.StartsWith("amessagesid")), TwilioMessageStatus.Delivered.ToString(), | ||
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>())); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny<CancellationToken>())); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()), | ||
Times.Never); | ||
} | ||
|
||
[Fact] | ||
public async Task WhenNotifyWebhookEventWithFailedEventButNoErrorCode_ThenReturnsOk() | ||
{ | ||
var eventData = new TwilioEventData | ||
{ | ||
MessageStatus = TwilioMessageStatus.Failed, | ||
MessageSid = "amessagesid", | ||
ErrorCode = null, | ||
RawDlrDoneDate = null | ||
}; | ||
|
||
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), | ||
It.IsAny<DateTime>(), It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(Result.Ok); | ||
|
||
var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None); | ||
|
||
result.Should().BeSuccess(); | ||
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveryFailedAsync(_caller.Object, | ||
It.Is<string>(s => s.StartsWith("amessagesid")), | ||
DateTime.UnixEpoch.AddSeconds(1), "none", It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(Result.Ok); | ||
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object, | ||
TwilioConstants.AuditSourceName, | ||
It.Is<string>(s => s.StartsWith("amessagesid")), TwilioMessageStatus.Failed.ToString(), | ||
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>())); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny<CancellationToken>())); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()), | ||
Times.Never); | ||
} | ||
|
||
[Fact] | ||
public async Task WhenNotifyWebhookEventWithFailedEventWithErrorCode_ThenConfirms() | ||
{ | ||
var eventData = new TwilioEventData | ||
{ | ||
MessageStatus = TwilioMessageStatus.Failed, | ||
MessageSid = "amessagesid", | ||
ErrorCode = "anerrorcode", | ||
RawDlrDoneDate = null | ||
}; | ||
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveredAsync(It.IsAny<ICallerContext>(), It.IsAny<string>(), | ||
It.IsAny<DateTime>(), It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(Result.Ok); | ||
|
||
var result = await _application.NotifyWebhookEvent(_caller.Object, eventData, CancellationToken.None); | ||
|
||
result.Should().BeSuccess(); | ||
_ancillaryApplication.Setup(aa => aa.ConfirmEmailDeliveryFailedAsync(_caller.Object, "areceiptid", | ||
DateTime.UnixEpoch.AddSeconds(1), "anerrorcode", It.IsAny<CancellationToken>())) | ||
.ReturnsAsync(Result.Ok); | ||
_webhookNotificationAuditService.Verify(wns => wns.CreateAuditAsync(_caller.Object, | ||
TwilioConstants.AuditSourceName, | ||
It.Is<string>(s => s.StartsWith("amessagesid")), TwilioMessageStatus.Failed.ToString(), | ||
eventData.ToJson(false, StringExtensions.JsonCasing.Pascal, false), It.IsAny<CancellationToken>())); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsProcessedAsync(_caller.Object, "anauditid", It.IsAny<CancellationToken>())); | ||
_webhookNotificationAuditService.Verify( | ||
wns => wns.MarkAsFailedProcessingAsync(_caller.Object, It.IsAny<string>(), It.IsAny<CancellationToken>()), | ||
Times.Never); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
using Application.Interfaces; | ||
using Application.Resources.Shared; | ||
using Common; | ||
|
||
namespace AncillaryApplication; | ||
|
||
public interface ITwilioApplication | ||
{ | ||
Task<Result<Error>> NotifyWebhookEvent(ICallerContext caller, TwilioEventData eventData, | ||
CancellationToken cancellationToken); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
using System.Globalization; | ||
using Application.Common.Extensions; | ||
using Application.Interfaces; | ||
using Application.Resources.Shared; | ||
using Application.Services.Shared; | ||
using Common; | ||
using Common.Extensions; | ||
|
||
namespace AncillaryApplication; | ||
|
||
public class TwilioApplication : ITwilioApplication | ||
{ | ||
private readonly IAncillaryApplication _ancillaryApplication; | ||
private readonly IRecorder _recorder; | ||
private readonly IWebhookNotificationAuditService _webHookNotificationAuditService; | ||
|
||
public TwilioApplication(IRecorder recorder, IAncillaryApplication ancillaryApplication, | ||
IWebhookNotificationAuditService webHookNotificationAuditService) | ||
{ | ||
_recorder = recorder; | ||
_ancillaryApplication = ancillaryApplication; | ||
_webHookNotificationAuditService = webHookNotificationAuditService; | ||
} | ||
|
||
public async Task<Result<Error>> NotifyWebhookEvent(ICallerContext caller, TwilioEventData eventData, | ||
CancellationToken cancellationToken) | ||
{ | ||
var eventId = $"{eventData.MessageSid}-{Guid.NewGuid():N}"; | ||
var eventStatus = eventData.MessageStatus; | ||
var @event = eventStatus.ToEnumOrDefault(TwilioMessageStatus.Unknown); | ||
_recorder.TraceInformation(caller.ToCall(), "Twilio webhook event received: {Event} for {Status}", eventId, | ||
@event); | ||
|
||
var created = await _webHookNotificationAuditService.CreateAuditAsync(caller, TwilioConstants.AuditSourceName, | ||
eventId, eventStatus.ToString(), eventData.ToJson(false), cancellationToken); | ||
if (created.IsFailure) | ||
{ | ||
_recorder.TraceError(caller.ToCall(), | ||
"Failed to audit Twilio webhook event {Event} for {Status} with {ErrorCode}: {Message}", eventId, | ||
eventStatus, created.Error.Code, created.Error.Message); | ||
return created.Error; | ||
} | ||
|
||
var audit = created.Value; | ||
switch (@event) | ||
{ | ||
case TwilioMessageStatus.Delivered: | ||
{ | ||
var deliveredAt = eventData.RawDlrDoneDate.HasValue | ||
? eventData.RawDlrDoneDate.Value.FromTwilioDateLong() | ||
: DateTime.UtcNow; | ||
var receiptId = eventData.MessageSid; | ||
|
||
return await ConfirmSmsDeliveryAsync(caller, audit, receiptId, deliveredAt, cancellationToken); | ||
} | ||
|
||
case TwilioMessageStatus.Failed: | ||
{ | ||
var failedAt = DateTime.UtcNow; | ||
var reason = eventData.ErrorCode ?? "none"; | ||
var receiptId = eventData.MessageSid; | ||
|
||
return await ConfirmSmsDeliveryFailedAsync(caller, audit, receiptId, failedAt, reason, | ||
cancellationToken); | ||
} | ||
|
||
default: | ||
_recorder.TraceInformation(caller.ToCall(), "Twilio webhook event ignored: {Event} for {Status}", | ||
eventId, @event); | ||
return Result.Ok; | ||
} | ||
} | ||
|
||
private async Task<Result<Error>> ConfirmSmsDeliveryAsync(ICallerContext caller, WebhookNotificationAudit audit, | ||
string receiptId, DateTime deliveredAt, CancellationToken cancellationToken) | ||
{ | ||
var delivered = | ||
await _ancillaryApplication.ConfirmSmsDeliveredAsync(caller, receiptId, deliveredAt, cancellationToken); | ||
if (delivered.IsFailure) | ||
{ | ||
_recorder.TraceError(caller.ToCall(), | ||
"Failed to confirm delivery for Twilio receipt {Receipt}, with {ErrorCode}: {Message}", | ||
receiptId, delivered.Error.Code, delivered.Error.Message); | ||
|
||
var updated = | ||
await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken); | ||
if (updated.IsFailure) | ||
{ | ||
return updated.Error; | ||
} | ||
|
||
return delivered.Error; | ||
} | ||
|
||
var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken); | ||
if (saved.IsFailure) | ||
{ | ||
return saved.Error; | ||
} | ||
|
||
return Result.Ok; | ||
} | ||
|
||
private async Task<Result<Error>> ConfirmSmsDeliveryFailedAsync(ICallerContext caller, | ||
WebhookNotificationAudit audit, string receiptId, DateTime failedAt, string reason, | ||
CancellationToken cancellationToken) | ||
{ | ||
var delivered = await _ancillaryApplication.ConfirmSmsDeliveryFailedAsync(caller, | ||
receiptId, failedAt, reason, cancellationToken); | ||
if (delivered.IsFailure) | ||
{ | ||
_recorder.TraceError(caller.ToCall(), | ||
"Failed to confirm failed delivery for Twilio receipt {Receipt}, with {ErrorCode}: {Message}", | ||
receiptId, delivered.Error.Code, delivered.Error.Message); | ||
|
||
var updated = | ||
await _webHookNotificationAuditService.MarkAsFailedProcessingAsync(caller, audit.Id, cancellationToken); | ||
if (updated.IsFailure) | ||
{ | ||
return updated.Error; | ||
} | ||
|
||
return delivered.Error; | ||
} | ||
|
||
var saved = await _webHookNotificationAuditService.MarkAsProcessedAsync(caller, audit.Id, cancellationToken); | ||
if (saved.IsFailure) | ||
{ | ||
return saved.Error; | ||
} | ||
|
||
return Result.Ok; | ||
} | ||
} | ||
|
||
public static class TwilioApplicationConversionExtensions | ||
{ | ||
public static DateTime FromTwilioDateLong(this long twilioDate) | ||
{ | ||
return DateTime.ParseExact(twilioDate.ToString().PadLeft(10), "yyMMddHHmm", | ||
CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); | ||
} | ||
|
||
public static long ToTwilioDateLong(this DateTime dateTime) | ||
{ | ||
return dateTime | ||
.ToNearestMinute() | ||
.ToString("yyMMddHHmm") | ||
.PadLeft(10) | ||
.ToLong(); | ||
} | ||
} |
Oops, something went wrong.