using Microsoft.Extensions.DependencyInjection; using Moq; using Shouldly; using Tnsu.ApprovalRoute.Application.RequestTypes; using Tnsu.ApprovalRoute.Application.TemplateRoutes; using Tnsu.ApprovalRoute.Application.TemplateRouteSteps; using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices; public sealed class TemplateRouteServiceTests : IClassFixture { private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001"); private readonly string _connectionString; private readonly ServiceProvider _services; private readonly TemplateRouteService _service; private readonly RequestTypeService _requestTypeService; public TemplateRouteServiceTests(PostgresDatabaseFixture fixture) { _connectionString = fixture.ConnectionString; var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); portalClient .Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); _services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); _service = _services.GetRequiredService(); _requestTypeService = _services.GetRequiredService(); } private async Task CreateRequestTypeAsync(string name) => (await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id; [Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")] public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Vacation"); // Action var created = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), CancellationToken.None); // Assert created.IsSuccess.ShouldBeTrue(); created.Value.RequestTypeId.ShouldBe(requestTypeId); var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); fetched.IsSuccess.ShouldBeTrue(); fetched.Value.CompanyId.ShouldBe(CompanyId); } [Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")] public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult() { // Action var result = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); // Assert result.IsFailed.ShouldBeTrue(); } [Fact(DisplayName = "Повторное создание маршрута с тем же типом заявки, отделом, компанией и признаком «по проекту» возвращает конфликт")] public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Business trip"); var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false); var first = await _service.CreateAsync(request, CancellationToken.None); first.IsSuccess.ShouldBeTrue(); // Action var duplicate = await _service.CreateAsync(request, CancellationToken.None); // Assert duplicate.IsFailed.ShouldBeTrue(); } [Fact(DisplayName = "Обновление маршрута изменяет тип маршрута и признак необходимости контроля")] public async Task UpdateAsync_ChangesFields() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Sick leave"); var created = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); // Action var updated = await _service.UpdateAsync( created.Value.Id, new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), CancellationToken.None); // Assert updated.IsSuccess.ShouldBeTrue(); updated.Value.RouteType.ShouldBe(RouteType.Parallel); updated.Value.RequiresControl.ShouldBeTrue(); } [Fact(DisplayName = "Патч маршрута меняет только переданные поля, остальные остаются прежними")] public async Task PatchAsync_UpdatesOnlyProvidedFields() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Patch route request"); var created = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); // Action var patched = await _service.PatchAsync( created.Value.Id, new PatchTemplateRouteRequest(RouteType.Parallel, DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true), CancellationToken.None); // Assert patched.IsSuccess.ShouldBeTrue(); patched.Value.RouteType.ShouldBe(RouteType.Parallel); patched.Value.RequiresControl.ShouldBeTrue(); patched.Value.CompanyId.ShouldBe(CompanyId); patched.Value.DepartmentId.ShouldBe(DepartmentId); } [Fact(DisplayName = "Удаление маршрута убирает его из базы")] public async Task DeleteAsync_RemovesEntity() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Remote work"); var created = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); // Action var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None); // Assert deleted.IsSuccess.ShouldBeTrue(); var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None); fetched.IsFailed.ShouldBeTrue(); } [Fact(DisplayName = "Резолв маршрута по точному совпадению отдела возвращает шаги с нужным статусом")] public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Exact match request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.RouteId.ShouldBe(route.Value.Id); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz"); } [Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")] public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo() { // Arrange var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность")); portalClient .Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny())) .ReturnsAsync("dup@tnsu.kz"); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var routeService = services.GetRequiredService(); var requestTypeService = services.GetRequiredService(); var stepService = services.GetRequiredService(); var requestTypeId = (await requestTypeService.CreateAsync( new CreateRequestTypeRequest("Collapse duplicates request", "/collapse-duplicates"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), CancellationToken.None); // Action var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.Count.ShouldBe(2); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz"); resolved.Value.Steps[0].OrderNo.ShouldBe(1); resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz"); resolved.Value.Steps[1].OrderNo.ShouldBe(2); } [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Project request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", Guid.NewGuid(), RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz"); } [Fact(DisplayName = "Резолв шага с должностью подставляет email сотрудника, найденного по должности и компании инициатора")] public async Task ResolveAsync_ByPosition_UsesEmployeeFoundByPositionAndCompany() { // Arrange var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer")); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny())) .ReturnsAsync("lead@tnsu.kz"); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var routeService = services.GetRequiredService(); var requestTypeService = services.GetRequiredService(); var stepService = services.GetRequiredService(); var requestTypeId = (await requestTypeService.CreateAsync( new CreateRequestTypeRequest("Position request", "/position-request"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false), CancellationToken.None); // Action var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("lead@tnsu.kz"); resolved.Value.Steps[0].Position.ShouldBe("Lead Engineer"); } [Fact(DisplayName = "Резолв шага с типом руководителя Chief подставляет email руководителя из api.tnsu.kz")] public async Task ResolveAsync_ByHeadTypeChief_UsesManagerEmail() { // Arrange var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position")); portalClient .Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny())) .ReturnsAsync("chief@tnsu.kz"); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var routeService = services.GetRequiredService(); var requestTypeService = services.GetRequiredService(); var stepService = services.GetRequiredService(); var requestTypeId = (await requestTypeService.CreateAsync( new CreateRequestTypeRequest("Chief request", "/chief-request"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false), CancellationToken.None); // Action var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz"); } [Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")] public async Task ResolveAsync_ByHeadTypeDirectorTansu_UsesConfiguredEmail() { // Arrange var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position")); var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object); var routeService = services.GetRequiredService(); var requestTypeService = services.GetRequiredService(); var stepService = services.GetRequiredService(); var requestTypeId = (await requestTypeService.CreateAsync( new CreateRequestTypeRequest("DirectorTansu request", "/director-tansu-request"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false), CancellationToken.None); // Action var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("director.tansu@tnsu.kz"); portalClient.Verify(x => x.GetManagerAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.RouteId.ShouldBe(route.Value.Id); } [Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")] public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult() { // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(999999, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsFailed.ShouldBeTrue(); } }