344 lines
14 KiB
C#
344 lines
14 KiB
C#
using Distribution.Features.Region;
|
|
using Distribution.Features.Route;
|
|
using Distribution.Infra.Authorization;
|
|
using Distribution.Infra.UoW;
|
|
using Distribution.Models;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace Distribution.Tests.Services;
|
|
|
|
public class RouteServiceTests
|
|
{
|
|
private readonly Mock<IRouteRepository> _repo = new();
|
|
private readonly Mock<IRegionRepository> _regionRepo = new();
|
|
private readonly Mock<IUnitOfWork> _uow = new();
|
|
private readonly Mock<ICurrentUserService> _currentUser = new();
|
|
private readonly Mock<IScopeAccessService> _scopeAccess = new();
|
|
private readonly RouteService _sut;
|
|
|
|
private static readonly Guid RepUserId = Guid.NewGuid();
|
|
|
|
public RouteServiceTests()
|
|
{
|
|
_currentUser.Setup(u => u.RoleLevel).Returns(20);
|
|
_currentUser.Setup(u => u.UserId).Returns(RepUserId);
|
|
_sut = new RouteService(_repo.Object, _regionRepo.Object, _uow.Object, _currentUser.Object, _scopeAccess.Object);
|
|
}
|
|
|
|
private static Models.Route ActiveRoute(Guid? ownerId = null) =>
|
|
new() { Id = Guid.NewGuid(), RouteName = "R1", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = true, FK_OwnerUserId = ownerId };
|
|
|
|
private static Models.Route InactiveRoute() =>
|
|
new() { Id = Guid.NewGuid(), RouteName = "Old", StartPoint = "A", EndPoint = "B", Length = 10, IsActive = false };
|
|
|
|
private static Store ActiveStore(Guid routeId, decimal lat = 10m, decimal lon = 20m) =>
|
|
new() { Id = Guid.NewGuid(), FK_RouteId = routeId, IsActive = true, Latitude = lat, Longitude = lon };
|
|
|
|
// AssignStoresToRouteAsync
|
|
|
|
[Fact]
|
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteNotFound()
|
|
{
|
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
|
|
|
|
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
|
|
|
Assert.Null(result);
|
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenRouteInactive()
|
|
{
|
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
|
|
|
|
var result = await _sut.AssignStoresToRouteAsync(Guid.NewGuid(), new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerLacksRouteScope()
|
|
{
|
|
var route = ActiveRoute();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
|
|
|
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
|
|
|
Assert.Null(result);
|
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenCallerBelowRoleFloor()
|
|
{
|
|
_currentUser.Setup(u => u.RoleLevel).Returns(10);
|
|
var route = ActiveRoute();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
|
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest([Guid.NewGuid()]));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignStoresToRouteAsync_ReturnsNull_WhenAnyStoreIdMissing()
|
|
{
|
|
var route = ActiveRoute();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
var storeIds = new List<Guid> { Guid.NewGuid(), Guid.NewGuid() };
|
|
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync([ActiveStore(route.Id)]); // only one of two found
|
|
|
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
|
|
|
|
Assert.Null(result);
|
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignStoresToRouteAsync_UpdatesEachStore_AndCommits()
|
|
{
|
|
var route = ActiveRoute();
|
|
var otherRouteId = Guid.NewGuid();
|
|
var stores = new List<Store> { ActiveStore(otherRouteId), ActiveStore(otherRouteId) };
|
|
var storeIds = stores.Select(s => s.Id).ToList();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
|
|
|
|
var result = await _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.All(stores, s => Assert.Equal(route.Id, s.FK_RouteId));
|
|
_repo.Verify(r => r.UpdateStore(It.IsAny<Store>()), Times.Exactly(2));
|
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignStoresToRouteAsync_Rollbacks_WhenUpdateThrows()
|
|
{
|
|
var route = ActiveRoute();
|
|
var stores = new List<Store> { ActiveStore(route.Id) };
|
|
var storeIds = stores.Select(s => s.Id).ToList();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetStoresByIdsAsync(storeIds)).ReturnsAsync(stores);
|
|
_repo.Setup(r => r.UpdateStore(It.IsAny<Store>())).Throws(new Exception("db error"));
|
|
|
|
await Assert.ThrowsAsync<Exception>(() => _sut.AssignStoresToRouteAsync(route.Id, new AssignStoresToRouteRequest(storeIds)));
|
|
|
|
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
|
_uow.Verify(u => u.CommitAsync(default), Times.Never);
|
|
}
|
|
|
|
// AssignOwnerAsync
|
|
|
|
[Fact]
|
|
public async Task AssignOwnerAsync_ReturnsNull_WhenRouteNotFound()
|
|
{
|
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync((Models.Route?)null);
|
|
|
|
var result = await _sut.AssignOwnerAsync(Guid.NewGuid(), new AssignRouteOwnerRequest(Guid.NewGuid()));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignOwnerAsync_ReturnsNull_WhenTargetUserNotActive()
|
|
{
|
|
var route = ActiveRoute();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetActiveUserAsync(It.IsAny<Guid>())).ReturnsAsync((User?)null);
|
|
|
|
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(Guid.NewGuid()));
|
|
|
|
Assert.Null(result);
|
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignOwnerAsync_SetsOwner_AndCommits()
|
|
{
|
|
var route = ActiveRoute();
|
|
var salesRep = new User { Id = Guid.NewGuid(), IsActive = true };
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetActiveUserAsync(salesRep.Id)).ReturnsAsync(salesRep);
|
|
|
|
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(salesRep.Id));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(salesRep.Id, result.OwnerUserId);
|
|
Assert.Equal(salesRep.Id, route.FK_OwnerUserId);
|
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task AssignOwnerAsync_AllowsReassignment_ToDifferentSalesRep()
|
|
{
|
|
var firstOwner = Guid.NewGuid();
|
|
var route = ActiveRoute(firstOwner);
|
|
var newRep = new User { Id = Guid.NewGuid(), IsActive = true };
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetActiveUserAsync(newRep.Id)).ReturnsAsync(newRep);
|
|
|
|
var result = await _sut.AssignOwnerAsync(route.Id, new AssignRouteOwnerRequest(newRep.Id));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(newRep.Id, result.OwnerUserId);
|
|
Assert.NotEqual(firstOwner, result.OwnerUserId);
|
|
}
|
|
|
|
// RecordCheckInAsync
|
|
|
|
[Fact]
|
|
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerNotAuthenticated()
|
|
{
|
|
_currentUser.Setup(u => u.UserId).Returns((Guid?)null);
|
|
|
|
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckInAsync_ReturnsNull_WhenRouteNotFoundOrInactive()
|
|
{
|
|
_repo.Setup(r => r.GetByIdAsync(It.IsAny<Guid>())).ReturnsAsync(InactiveRoute());
|
|
|
|
var result = await _sut.RecordCheckInAsync(Guid.NewGuid(), new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckInAsync_ReturnsNull_WhenCallerLacksRouteScope()
|
|
{
|
|
var route = ActiveRoute();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(false);
|
|
|
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(Guid.NewGuid(), 1, 2, null));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckInAsync_ReturnsNull_WhenStoreNotOnRoute()
|
|
{
|
|
var route = ActiveRoute();
|
|
var store = ActiveStore(Guid.NewGuid()); // belongs to a different route
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
|
|
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 1, 2, null));
|
|
|
|
Assert.Null(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckInAsync_NotFlagged_WhenGpsMatchesStoreLocation()
|
|
{
|
|
var route = ActiveRoute();
|
|
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m);
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
|
|
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 12.9716m, 77.5946m, null));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.False(result.IsDeviationFlagged);
|
|
_repo.Verify(r => r.AddVisitLogAsync(It.IsAny<RouteVisitLog>()), Times.Once);
|
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckInAsync_Flagged_WhenGpsFarFromStoreLocation()
|
|
{
|
|
var route = ActiveRoute();
|
|
var store = ActiveStore(route.Id, lat: 12.9716m, lon: 77.5946m); // Bengaluru
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
|
|
|
// Mumbai coordinates - hundreds of km away
|
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, 19.0760m, 72.8777m, null));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.True(result.IsDeviationFlagged);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckInAsync_UsesCurrentUserId_AsVisitingRep()
|
|
{
|
|
var route = ActiveRoute();
|
|
var store = ActiveStore(route.Id);
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetStoreByIdAsync(store.Id)).ReturnsAsync(store);
|
|
|
|
var result = await _sut.RecordCheckInAsync(route.Id, new RecordCheckInRequest(store.Id, store.Latitude, store.Longitude, null));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(RepUserId, result.UserId);
|
|
}
|
|
|
|
// RecordCheckOutAsync
|
|
|
|
[Fact]
|
|
public async Task RecordCheckOutAsync_ReturnsNull_WhenNoOpenVisit()
|
|
{
|
|
var route = ActiveRoute();
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, It.IsAny<Guid>())).ReturnsAsync((RouteVisitLog?)null);
|
|
|
|
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(Guid.NewGuid(), null));
|
|
|
|
Assert.Null(result);
|
|
_uow.Verify(u => u.BeginAsync(default), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckOutAsync_SetsCheckOutAt_AndCommits()
|
|
{
|
|
var route = ActiveRoute();
|
|
var storeId = Guid.NewGuid();
|
|
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now.AddHours(-1) };
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
|
|
|
|
var result = await _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null));
|
|
|
|
Assert.NotNull(result);
|
|
Assert.NotNull(result.CheckOutAt);
|
|
Assert.NotNull(log.CheckOutAt);
|
|
_repo.Verify(r => r.UpdateVisitLog(log), Times.Once);
|
|
_uow.Verify(u => u.CommitAsync(default), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RecordCheckOutAsync_Rollbacks_WhenUpdateThrows()
|
|
{
|
|
var route = ActiveRoute();
|
|
var storeId = Guid.NewGuid();
|
|
var log = new RouteVisitLog { Id = Guid.NewGuid(), FK_RouteId = route.Id, FK_StoreId = storeId, FK_UserId = RepUserId, CheckInAt = DateTime.Now };
|
|
_repo.Setup(r => r.GetByIdAsync(route.Id)).ReturnsAsync(route);
|
|
_scopeAccess.Setup(s => s.HasRouteScopeAsync(RepUserId, route.Id)).ReturnsAsync(true);
|
|
_repo.Setup(r => r.GetOpenVisitAsync(route.Id, storeId)).ReturnsAsync(log);
|
|
_repo.Setup(r => r.UpdateVisitLog(It.IsAny<RouteVisitLog>())).Throws(new Exception("db error"));
|
|
|
|
await Assert.ThrowsAsync<Exception>(() => _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null)));
|
|
|
|
_uow.Verify(u => u.RollbackAsync(default), Times.Once);
|
|
}
|
|
}
|