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 _repo = new(); private readonly Mock _regionRepo = new(); private readonly Mock _uow = new(); private readonly Mock _currentUser = new(); private readonly Mock _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())).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())).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.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 { 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()), 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 { 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())).Throws(new Exception("db error")); await Assert.ThrowsAsync(() => _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())).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())).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())).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()), 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())).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())).Throws(new Exception("db error")); await Assert.ThrowsAsync(() => _sut.RecordCheckOutAsync(route.Id, new RecordCheckOutRequest(storeId, null))); _uow.Verify(u => u.RollbackAsync(default), Times.Once); } }