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 _repo = new(); private readonly Mock _uow = new(); private readonly Mock> _hub = new(); private readonly Mock _clients = new(); private readonly Mock _proxy = new(); private readonly NotificationService _sut; public NotificationServiceTests() { _repo.Setup(r => r.FilterActiveUserIdsAsync(It.IsAny>())) .ReturnsAsync((IEnumerable ids) => ids.ToList()); _hub.SetupGet(h => h.Clients).Returns(_clients.Object); _clients.Setup(c => c.User(It.IsAny())).Returns(_proxy.Object); _sut = new NotificationService( _repo.Object, new NotificationRecipientResolver(_repo.Object), _hub.Object, _uow.Object, NullLogger.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()), Times.Never); _uow.Verify(u => u.CommitAsync(It.IsAny()), Times.Never); } [Fact] public async Task NotifyAsync_DoesNothing_WhenThereAreNoRecipients() { await _sut.NotifyAsync(NotificationTypes.DeliveryTaskFailed, [RecipientRule.Users()], Args()); _repo.Verify(r => r.AddAsync(It.IsAny()), 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())) .Callback(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())) .Callback(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()), 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(), It.IsAny()), Times.Exactly(2)); } [Fact] public async Task NotifyAsync_DoesNotThrow_WhenSavingFails_AndDiscardsTheEntity() { _uow.Setup(u => u.CommitAsync(It.IsAny())).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()), Times.Once); _proxy.Verify(p => p.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task NotifyAsync_DoesNotThrow_WhenPushFails_AndKeepsTheSavedNotification() { _proxy.Setup(p => p.SendCoreAsync(It.IsAny(), It.IsAny(), It.IsAny())) .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()), Times.Once); _repo.Verify(r => r.Discard(It.IsAny()), 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())) .Callback(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); } }