Files
ERP_Distribution/Distribution.Tests/Services/RouteServiceTests.cs
T
2026-08-31 13:00:42 +05:30

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);
}
}