6b9f27573b
- Integrated SignalR for real-time notification delivery to users.
210 lines
8.5 KiB
C#
210 lines
8.5 KiB
C#
using Distribution.Features.Notifications;
|
|
using Distribution.Infra.UoW;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Distribution.Tests.Services;
|
|
|
|
public class NotificationServiceTests
|
|
{
|
|
private readonly Mock<INotificationRepository> _repo = new();
|
|
private readonly Mock<IUnitOfWork> _uow = new();
|
|
private readonly Mock<IHubContext<NotificationHub>> _hub = new();
|
|
private readonly Mock<IHubClients> _clients = new();
|
|
private readonly Mock<IClientProxy> _proxy = new();
|
|
private readonly NotificationService _sut;
|
|
|
|
public NotificationServiceTests()
|
|
{
|
|
_repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny<IEnumerable<Guid>>()))
|
|
.ReturnsAsync((IEnumerable<Guid> ids) => ids.ToList());
|
|
_hub.SetupGet(h => h.Clients).Returns(_clients.Object);
|
|
_clients.Setup(c => c.User(It.IsAny<string>())).Returns(_proxy.Object);
|
|
|
|
_sut = new NotificationService(
|
|
_repo.Object,
|
|
new NotificationRecipientResolver(_repo.Object),
|
|
_hub.Object,
|
|
_uow.Object,
|
|
NullLogger<NotificationService>.Instance);
|
|
}
|
|
|
|
private static NotificationArgs Args(Guid? actor = null) =>
|
|
new(new { StoreName = "Fort Mini Mart" }, "DeliveryTask", Guid.NewGuid(), "/operations/delivery-tasks/1", actor);
|
|
|
|
// Registry
|
|
|
|
[Fact]
|
|
public void EveryNotificationTypeConstant_HasADefinition()
|
|
{
|
|
var constants = typeof(NotificationTypes)
|
|
.GetFields()
|
|
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
|
.Select(f => (string)f.GetRawConstantValue()!)
|
|
.ToList();
|
|
|
|
Assert.NotEmpty(constants);
|
|
Assert.Equal(constants.Count, constants.Distinct().Count());
|
|
Assert.All(constants, c => Assert.True(NotificationTypes.All.ContainsKey(c), $"{c} has no definition"));
|
|
}
|
|
|
|
// NotifyAsync
|
|
|
|
[Fact]
|
|
public async Task NotifyAsync_DoesNothing_WhenTypeIsNotRegistered()
|
|
{
|
|
await _sut.NotifyAsync("NotARealType", [RecipientRule.Users(Guid.NewGuid())], Args());
|
|
|
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients()
|
|
{
|
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args());
|
|
|
|
_repo.Verify(r => r.AddAsync(It.IsAny<Models.Notification>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotifyAsync_ExcludesTheActingUser()
|
|
{
|
|
var actor = Guid.NewGuid();
|
|
var other = Guid.NewGuid();
|
|
Models.Notification? saved = null;
|
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
|
.Callback<Models.Notification>(n => saved = n)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed,
|
|
[RecipientRule.Users(actor, other)], Args(actor));
|
|
|
|
Assert.NotNull(saved);
|
|
var recipient = Assert.Single(saved!.Recipients);
|
|
Assert.Equal(other, recipient.FK_UserId);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotifyAsync_RendersTemplate_CommitsAndPushesToEachRecipient()
|
|
{
|
|
var a = Guid.NewGuid();
|
|
var b = Guid.NewGuid();
|
|
Models.Notification? saved = null;
|
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
|
.Callback<Models.Notification>(n => saved = n)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(a, b)], Args());
|
|
|
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
|
Assert.Equal(Models.NotificationSeverity.Warning, saved.Severity);
|
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
|
_clients.Verify(c => c.User(a.ToString()), Times.Once);
|
|
_clients.Verify(c => c.User(b.ToString()), Times.Once);
|
|
_proxy.Verify(p => p.SendCoreAsync(NotificationHub.ClientEvent,
|
|
It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity()
|
|
{
|
|
_uow.Setup(u => u.CommitAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("db down"));
|
|
|
|
var ex = await Record.ExceptionAsync(() =>
|
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
|
|
|
Assert.Null(ex);
|
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Once);
|
|
_proxy.Verify(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification()
|
|
{
|
|
_proxy.Setup(p => p.SendCoreAsync(It.IsAny<string>(), It.IsAny<object?[]>(), It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new InvalidOperationException("socket gone"));
|
|
|
|
var ex = await Record.ExceptionAsync(() =>
|
|
_sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())], Args()));
|
|
|
|
Assert.Null(ex);
|
|
_uow.Verify(u => u.CommitAsync(It.IsAny<CancellationToken>()), Times.Once);
|
|
_repo.Verify(r => r.Discard(It.IsAny<Models.Notification>()), Times.Never);
|
|
}
|
|
|
|
// Recipient rules
|
|
|
|
[Fact]
|
|
public async Task PermissionInScope_ResolvesToTheIntersectionOfPermissionAndScope()
|
|
{
|
|
var both = Guid.NewGuid();
|
|
var permissionOnly = Guid.NewGuid();
|
|
var scopeOnly = Guid.NewGuid();
|
|
var storeId = Guid.NewGuid();
|
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("store.manage")).ReturnsAsync([both, permissionOnly]);
|
|
_repo.Setup(r => r.GetUserIdsByScopeAsync(ScopeKind.Store, storeId)).ReturnsAsync([both, scopeOnly]);
|
|
|
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
|
.ResolveAsync([RecipientRule.PermissionInScope("store.manage", ScopeKind.Store, storeId)], actingUserId: null);
|
|
|
|
Assert.Equal([both], ids);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReportsTo_ResolvesToTheSupervisor_AndIsEmptyWhenThereIsNone()
|
|
{
|
|
var user = Guid.NewGuid();
|
|
var supervisor = Guid.NewGuid();
|
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
|
|
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync(supervisor);
|
|
Assert.Equal([supervisor], await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
|
|
|
_repo.Setup(r => r.GetReportsToUserIdAsync(user)).ReturnsAsync((Guid?)null);
|
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.ReportsTo(user)], null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RouteOwner_ResolvesToTheOwner_AndIsEmptyWhenTheRouteHasNone()
|
|
{
|
|
var storeId = Guid.NewGuid();
|
|
var owner = Guid.NewGuid();
|
|
var resolver = new NotificationRecipientResolver(_repo.Object);
|
|
|
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync(owner);
|
|
Assert.Equal([owner], await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
|
|
|
_repo.Setup(r => r.GetRouteOwnerUserIdAsync(ScopeKind.Store, storeId)).ReturnsAsync((Guid?)null);
|
|
Assert.Empty(await resolver.ResolveAsync([RecipientRule.RouteOwner(ScopeKind.Store, storeId)], null));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task OptionalTokenAtTheEndOfATemplate_LeavesNoTrailingSpace()
|
|
{
|
|
Models.Notification? saved = null;
|
|
_repo.Setup(r => r.AddAsync(It.IsAny<Models.Notification>()))
|
|
.Callback<Models.Notification>(n => saved = n)
|
|
.Returns(Task.CompletedTask);
|
|
|
|
await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users(Guid.NewGuid())],
|
|
new NotificationArgs(new { StoreName = "Fort Mini Mart", Note = (string?)null }, "DeliveryTask", Guid.NewGuid(), "/x"));
|
|
|
|
Assert.Equal("Delivery to Fort Mini Mart failed.", saved!.Message);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Resolve_DeduplicatesUsersAcrossRules()
|
|
{
|
|
var user = Guid.NewGuid();
|
|
_repo.Setup(r => r.GetUserIdsByPermissionAsync("stock.manage")).ReturnsAsync([user]);
|
|
|
|
var ids = await new NotificationRecipientResolver(_repo.Object)
|
|
.ResolveAsync([RecipientRule.Users(user), RecipientRule.Permission("stock.manage")], null);
|
|
|
|
Assert.Equal([user], ids);
|
|
}
|
|
}
|