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 List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetProjectExecutorsAsync(It.IsAny(), It.IsAny())) .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); portalClient .Setup(x => x.GetEmployeeByPositionAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(false, null, null)); _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}", null, null, "General"), CancellationToken.None)).Value.Id; private async Task CreateRequestTypeWithListAsync(string name) => (await _requestTypeService.CreateAsync( new CreateRequestTypeRequest(name, $"/{name}", "Requests", "https://tnsukz.sharepoint.com/sites/hrm", "General"), 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(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(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(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(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); // Action var updated = await _service.UpdateAsync( created.Value.Id, new UpdateTemplateRouteRequest(DepartmentId, CompanyId, ByProject: false, RequiresControl: true), CancellationToken.None); // Assert updated.IsSuccess.ShouldBeTrue(); 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(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); // Action var patched = await _service.PatchAsync( created.Value.Id, new PatchTemplateRouteRequest(DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true), CancellationToken.None); // Assert patched.IsSuccess.ShouldBeTrue(); 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(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(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, null, ConditionsLogic.And), 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"); // Исполнитель задан email'ом, а не типом руководителя — PositionCode пуст. resolved.Value.Steps[0].PositionCode.ShouldBeNull(); } // SLA приезжает в резолв уже с применённым наследованием — это осознанное // решение: строку списка согласования пишет request-portal, и он не должен // повторять у себя правило «шаг перекрывает тип». private async Task CreateRequestTypeWithSlaAsync(string name, int? slaHours, bool? askOverdueReason) => (await _requestTypeService.CreateAsync( new CreateRequestTypeRequest(name, $"/{name}", null, null, "General", slaHours, askOverdueReason), CancellationToken.None)).Value.Id; private async Task ResolveSingleStepWithSlaAsync( string requestTypeName, int? typeSla, bool? typeAsk, int? stepSla, bool? stepAsk) { var requestTypeId = await CreateRequestTypeWithSlaAsync(requestTypeName, typeSla, typeAsk); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest( 1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And, SlaHours: stepSla, AskOverdueReason: stepAsk), CancellationToken.None); var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); resolved.IsSuccess.ShouldBeTrue(); return resolved.Value.Steps.ShouldHaveSingleItem(); } [Fact(DisplayName = "Резолв: шаг без своего SLA наследует часы и флаг от типа заявки")] public async Task ResolveAsync_StepWithoutSla_InheritsFromRequestType() { var step = await ResolveSingleStepWithSlaAsync( "Sla inherit", typeSla: 48, typeAsk: true, stepSla: null, stepAsk: null); step.SlaHours.ShouldBe(48); step.AskOverdueReason.ShouldBe(true); } [Fact(DisplayName = "Резолв: SLA шага перекрывает SLA типа заявки")] public async Task ResolveAsync_StepSla_OverridesRequestTypeSla() { var step = await ResolveSingleStepWithSlaAsync( "Sla override", typeSla: 48, typeAsk: true, stepSla: 8, stepAsk: false); step.SlaHours.ShouldBe(8); step.AskOverdueReason.ShouldBe(false); } // Ключевое отличие «не задано» от «ноль»: ноль на шаге — это осознанное // «напоминать сразу», и подменять его типовым значением нельзя. [Fact(DisplayName = "Резолв: заданный на шаге SLA = 0 не подменяется значением типа заявки")] public async Task ResolveAsync_StepSlaZero_IsNotReplacedByRequestTypeSla() { var step = await ResolveSingleStepWithSlaAsync( "Sla zero", typeSla: 48, typeAsk: true, stepSla: 0, stepAsk: false); step.SlaHours.ShouldBe(0); step.AskOverdueReason.ShouldBe(false); } [Fact(DisplayName = "Резолв: SLA не настроен ни на шаге, ни на типе — в ответе null")] public async Task ResolveAsync_NoSlaAnywhere_ReturnsNull() { var step = await ResolveSingleStepWithSlaAsync( "Sla absent", typeSla: null, typeAsk: null, stepSla: null, stepAsk: null); step.SlaHours.ShouldBeNull(); step.AskOverdueReason.ShouldBeNull(); } [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 List { new(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", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(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, null, ConditionsLogic.And), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And), 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); // Схлопнутый шаг #1 задан по email (PositionCode пуст), но дубликат — шаг #3 // по HeadType.Chief — принёс PositionCode. Он не должен потеряться при схлопывании. resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief); resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz"); resolved.Value.Steps[1].OrderNo.ShouldBe(2); } [Fact(DisplayName = "Резолв маршрута: один email через два разных HeadType — предупреждение, PositionCode первого из них")] public async Task ResolveAsync_StepsResolveToSameEmailWithDifferentPositionCode_AddsWarningAndKeepsFirst() { // Arrange — оба типа руководителя резолвятся в один и тот же email. var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetManagerAsync(It.IsAny(), "chief", It.IsAny())) .ReturnsAsync("same@tnsu.kz"); portalClient .Setup(x => x.GetManagerAsync(It.IsAny(), "directionHead", It.IsAny())) .ReturnsAsync("same@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 conflicting position code request", "/collapse-conflicting-position-code", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(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, null, ConditionsLogic.And), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And), 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("same@tnsu.kz"); resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief); var warning = resolved.Value.Steps[0].Warning; warning.ShouldNotBeNull(); warning.ShouldContain("Chief"); warning.ShouldContain("DirectionHead"); } [Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» без departmentId в запросе — маршрут формируется, шаг с предупреждением")] public async Task ResolveAsync_DepartmentHeadStepWithoutDepartmentId_ReturnsWarningAndNullExecutor() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Department head without id"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action — departmentId в запросе не передан var resolved = await _service.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.ShouldBeNull(); var warning = resolved.Value.Steps[0].Warning; warning.ShouldNotBeNull(); warning.ShouldContain("departmentId"); } [Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела» и переданным departmentId — резолвится через портал")] public async Task ResolveAsync_DepartmentHeadStepWithDepartmentId_ResolvesViaPortalClient() { // Arrange var targetDepartmentId = Guid.Parse("00000000-0000-0000-0000-0000000000aa"); var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny())) .ReturnsAsync("head-of-department@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("Department head resolved", "/department-head-resolved", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, DepartmentId: targetDepartmentId), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("head-of-department@tnsu.kz"); resolved.Value.Steps[0].Warning.ShouldBeNull(); } [Fact(DisplayName = "Резолв маршрута с шагом «Руководитель указанного отдела», для которого портал не вернул руководителя — предупреждение")] public async Task ResolveAsync_DepartmentHeadNotFoundByPortal_ReturnsWarning() { // Arrange var targetDepartmentId = Guid.Parse("00000000-0000-0000-0000-0000000000bb"); var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") }); portalClient .Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny())) .ReturnsAsync((string?)null); 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("Department head not found", "/department-head-not-found", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, DepartmentId: targetDepartmentId), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull(); var warning = resolved.Value.Steps[0].Warning; warning.ShouldNotBeNull(); warning.ShouldContain(targetDepartmentId.ToString()); } [Fact(DisplayName = "Резолв маршрута включает шаг с условием «больше», когда переданное значение поля его удовлетворяет")] public async Task ResolveAsync_ConditionGreaterThan_SatisfiedByFieldValue_IncludesStep() { // Arrange var requestTypeId = await CreateRequestTypeWithListAsync("Condition request satisfied"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, [new ResolveFieldValueDto("Amount", "1500")]), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("big-amount@tnsu.kz"); } [Fact(DisplayName = "Резолв маршрута исключает шаг с условием, когда значение поля его не удовлетворяет")] public async Task ResolveAsync_ConditionGreaterThan_NotSatisfiedByFieldValue_ExcludesStep() { // Arrange var requestTypeId = await CreateRequestTypeWithListAsync("Condition request not satisfied"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, [new ResolveFieldValueDto("Amount", "500")]), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("always@tnsu.kz"); } [Fact(DisplayName = "Резолв маршрута исключает шаг с условием, если значение поля вообще не передано")] public async Task ResolveAsync_ConditionField_MissingFromRequest_ExcludesStep() { // Arrange var requestTypeId = await CreateRequestTypeWithListAsync("Condition request missing value"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "big-amount@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And), CancellationToken.None); // Action — fieldValues не переданы вовсе var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldBeEmpty(); } [Fact(DisplayName = "Резолв маршрута с условием «равно» сравнивает значения как строки без учёта регистра")] public async Task ResolveAsync_ConditionEqual_MatchesCaseInsensitive() { // Arrange var requestTypeId = await CreateRequestTypeWithListAsync("Condition request equal"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "urgent@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }, ConditionsLogic.And), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, [new ResolveFieldValueDto("Priority", "URGENT")]), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBe("urgent@tnsu.kz"); } [Theory(DisplayName = "Резолв маршрута с условиями «не равно», «содержит», «не содержит»")] [InlineData(ComparisonOperator.NotEqual, "Urgent", "Normal", true)] [InlineData(ComparisonOperator.NotEqual, "Urgent", "URGENT", false)] [InlineData(ComparisonOperator.Contains, "urge", "Very Urgent", true)] [InlineData(ComparisonOperator.Contains, "urge", "Normal", false)] [InlineData(ComparisonOperator.NotContains, "urge", "Normal", true)] [InlineData(ComparisonOperator.NotContains, "urge", "Very Urgent", false)] public async Task ResolveAsync_TextConditions_IncludeStepOnlyWhenConditionHolds( ComparisonOperator operation, string expected, string actual, bool shouldMatch) { // Arrange var requestTypeId = await CreateRequestTypeWithListAsync($"Condition request {operation} {actual}"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "text-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Priority", operation, expected) }, ConditionsLogic.And), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, [new ResolveFieldValueDto("Priority", actual)]), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.Count.ShouldBe(shouldMatch ? 1 : 0); } [Theory(DisplayName = "Резолв маршрута с несколькими условиями шага, объединёнными через И/ИЛИ")] // И: оба условия должны выполниться. [InlineData(ConditionsLogic.And, "1500", "Urgent", true)] [InlineData(ConditionsLogic.And, "500", "Urgent", false)] [InlineData(ConditionsLogic.And, "1500", "Normal", false)] // ИЛИ: достаточно одного. [InlineData(ConditionsLogic.Or, "500", "Urgent", true)] [InlineData(ConditionsLogic.Or, "1500", "Normal", true)] [InlineData(ConditionsLogic.Or, "500", "Normal", false)] public async Task ResolveAsync_MultipleConditions_CombinesWithConfiguredLogic( ConditionsLogic logic, string amount, string priority, bool shouldMatch) { // Arrange var requestTypeId = await CreateRequestTypeWithListAsync($"Multi-condition request {logic} {amount} {priority}"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, "multi-condition@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"), new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent") }, logic), CancellationToken.None); // Action var resolved = await _service.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval, [new ResolveFieldValueDto("Amount", amount), new ResolveFieldValueDto("Priority", priority)]), CancellationToken.None); // Assert resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.Count.ShouldBe(shouldMatch ? 1 : 0); } [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Project request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(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, null, ConditionsLogic.And), 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 List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer") }); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "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", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(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, null, ConditionsLogic.And), 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 List { new(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", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(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, null, ConditionsLogic.And), 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"); resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.Chief); } [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 List { new(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", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(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, null, ConditionsLogic.And), 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 = "Резолв шага с типом руководителя CompanyDirectors резолвится в директора каждой компании инициатора, все с одним OrderNo")] public async Task ResolveAsync_ByHeadTypeCompanyDirectors_ResolvesOneRowPerCompany() { // Arrange — инициатор числится в двух компаниях; для одной из них (KazPromService) // должность директора захардкожена как «Главный механик», а не «Директор». var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности") }); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU")); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS")); 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("CompanyDirectors request", "/company-directors-request", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And), 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.ShouldAllBe(s => s.OrderNo == 5); resolved.Value.Steps.ShouldAllBe(s => s.PositionCode == HeadType.CompanyDirectors); resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz"); } [Fact(DisplayName = "Резолв: один и тот же директор двух разных компаний инициатора в CompanyDirectors схлопывается в одну строку")] public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDirectorOfTwoCompanies_CollapsesToOneRow() { // Arrange — инициатор числится в двух компаниях, и директор у них ОДИН и тот же // человек (например, холдинговая структура) — оба GetEmployeeByPositionAsync // возвращают один и тот же email. var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Начальник отдела службы безопасности") }); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор")); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "same.director@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("CompanyDirectors same person request", "/company-directors-same-person", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And), 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("same.director@tnsu.kz"); resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors); resolved.Value.Steps[0].Warning.ShouldBeNull(); } [Fact(DisplayName = "Резолв: один и тот же человек под РАЗНЫМИ email (общий Id в справочнике) в CompanyDirectors схлопывается в одну строку")] public async Task ResolveAsync_ByHeadTypeCompanyDirectors_SamePersonDifferentEmailsSameId_CollapsesToOneRow() { // Arrange — в справочнике api.tnsu.kz это один человек (один Guid Id), но у // него разные email в разных компаниях. Дедуп по email это бы не поймал — // отсюда и был реальный баг (одна и та же строка дважды на одном шаге). var personId = Guid.NewGuid(); var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync("someone@tnsu.kz", It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Начальник отдела службы безопасности") }); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu.email@tnsu.kz", "Общий директор")); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "director.stglobal.email@tnsu.kz", "Общий директор")); // Оба email в справочнике резолвятся в ОДНОГО и того же человека (общий Id) — // как в реальном порталe, где у сотрудника может быть несколько адресов. portalClient .Setup(x => x.GetEmployeeByEmailAsync("director.tansu.email@tnsu.kz", It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Директор", personId) }); portalClient .Setup(x => x.GetEmployeeByEmailAsync("director.stglobal.email@tnsu.kz", It.IsAny())) .ReturnsAsync(new List { new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Генеральный директор", personId) }); 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("CompanyDirectors same person different emails request", "/company-directors-same-person-different-emails", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert — дедуп по Id сотрудника, а не по email: одна строка, а не две. resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].PositionCode.ShouldBe(HeadType.CompanyDirectors); resolved.Value.Steps[0].Warning.ShouldBeNull(); } [Fact(DisplayName = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, эта строка убирается из CompanyDirectors")] public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_RemovesDuplicateFromCompanyDirectors() { // Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5) // и Директор Tansu (из шага DirectorTansu, OrderNo 10) — один и тот же человек // с одним и тем же email. var portalClient = new Mock(); portalClient .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new List { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"), new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности") }); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU")); portalClient .Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny())) .ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS")); 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("CompanyDirectors overlaps DirectorTansu request", "/company-directors-overlaps-director-tansu", null, null, "General"), CancellationToken.None)).Value.Id; var route = await routeService.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(5, null, null, null, HeadType.CompanyDirectors, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And), CancellationToken.None); // Action — тестовая конфигурация не переопределяет TnsuPortal:DirectorTansuEmail, // поэтому HeadType.DirectorTansu резолвится в дефолт из TnsuPortalOptions // ("director.tansu@tnsu.kz") — тот же email, что вернул GetEmployeeByPositionAsync // для ТОО TANSU Construction. var resolved = await routeService.ResolveAsync( new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval), CancellationToken.None); // Assert — вместо трёх строк (2 CompanyDirectors + 1 DirectorTansu) остаётся // две: KazPromService-директор и Директор Tansu, без предупреждения о конфликте. resolved.IsSuccess.ShouldBeTrue(); resolved.Value.Steps.Count.ShouldBe(2); var tansuStep = resolved.Value.Steps.Single(s => s.ExecutorEmail == "director.tansu@tnsu.kz"); tansuStep.PositionCode.ShouldBe(HeadType.DirectorTansu); tansuStep.OrderNo.ShouldBe(10); tansuStep.Warning.ShouldBeNull(); resolved.Value.Steps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz" && s.PositionCode == HeadType.CompanyDirectors); } [Fact(DisplayName = "Резолв шага с типом руководителя Chief возвращает warning, если руководитель не найден")] public async Task ResolveAsync_ByHeadTypeChief_NotFound_ReturnsWarning() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Chief not found request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And), 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.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull(); var warning = resolved.Value.Steps[0].Warning; warning.ShouldNotBeNull(); warning.ShouldContain("руководитель"); } [Fact(DisplayName = "Резолв шага с должностью возвращает warning, если сотрудник по должности не найден")] public async Task ResolveAsync_ByPosition_NotFound_ReturnsWarning() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Position not found request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false), CancellationToken.None); var stepService = _services.GetRequiredService(); await stepService.CreateAsync( route.Value.Id, new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), 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.Steps.ShouldHaveSingleItem(); resolved.Value.Steps[0].ExecutorEmail.ShouldBeNull(); var warning = resolved.Value.Steps[0].Warning; warning.ShouldNotBeNull(); warning.ShouldContain("должности"); } [Fact(DisplayName = "Резолв шага с ролью в проекте возвращает warning, если исполнитель с такой ролью не найден")] public async Task ResolveAsync_ByProjectRole_NotFound_ReturnsWarning() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Project role not found request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(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.Security, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And), 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.ShouldBeNull(); var warning = resolved.Value.Steps[0].Warning; warning.ShouldNotBeNull(); warning.ShouldContain("роль"); } [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Company-wide request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(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_NoCompanyRoute_FallsBackToRouteWithoutCompany() { // Arrange var requestTypeId = await CreateRequestTypeAsync("No company route request"); var route = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId: null, 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_RouteWithCompanyExists_PreferredOverRouteWithoutCompany() { // Arrange var requestTypeId = await CreateRequestTypeAsync("Company priority request"); var companyWideRoute = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId: null, ByProject: false, RequiresControl: false), CancellationToken.None); var companySpecificRoute = await _service.CreateAsync( new CreateTemplateRouteRequest(requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: true), 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(companySpecificRoute.Value.Id); resolved.Value.RouteId.ShouldNotBe(companyWideRoute.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(); } }