0289236d19
Покрывает ровно тот сценарий, который выглядел как баг на скриншоте из ManageRequestsScreen (один и тот же человек дважды на одном шаге) — подтверждает, что текущий код (CollapseByExecutorEmail) схлопывает такую пару в одну строку без предупреждения. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1129 lines
63 KiB
C#
1129 lines
63 KiB
C#
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<PostgresDatabaseFixture>
|
|
{
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
|
portalClient
|
|
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(false, null, null));
|
|
|
|
_services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
_service = _services.GetRequiredService<TemplateRouteService>();
|
|
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
|
}
|
|
|
|
private async Task<int> CreateRequestTypeAsync(string name) =>
|
|
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}", null, null, "General"), CancellationToken.None)).Value.Id;
|
|
|
|
private async Task<int> 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<TemplateRouteStepService>();
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
|
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<int> 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<ResolvedRouteStepDto> 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<TemplateRouteStepService>();
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(
|
|
1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null,
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
|
portalClient
|
|
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync("dup@tnsu.kz");
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
CancellationToken.None);
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
|
CancellationToken.None);
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
|
portalClient
|
|
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "chief", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync("same@tnsu.kz");
|
|
portalClient
|
|
.Setup(x => x.GetManagerAsync(It.IsAny<string>(), "directionHead", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync("same@tnsu.kz");
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
CancellationToken.None);
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectionHead, RouteStepStatus.Approval, false, null),
|
|
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<TemplateRouteStepService>();
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null),
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
|
portalClient
|
|
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync("head-of-department@tnsu.kz");
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность") });
|
|
portalClient
|
|
.Setup(x => x.GetDepartmentHeadEmailAsync(targetDepartmentId, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync((string?)null);
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
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<TemplateRouteStepService>();
|
|
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") }),
|
|
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<TemplateRouteStepService>();
|
|
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") }),
|
|
CancellationToken.None);
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(2, "always@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null),
|
|
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<TemplateRouteStepService>();
|
|
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") }),
|
|
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<TemplateRouteStepService>();
|
|
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") }),
|
|
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<TemplateRouteStepService>();
|
|
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) }),
|
|
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<TemplateRouteStepService>();
|
|
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<TemplateRouteStepService>();
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null),
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer") });
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович"));
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Chief position") });
|
|
portalClient
|
|
.Setup(x => x.GetManagerAsync("someone@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync("chief@tnsu.kz");
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo> { new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Director position") });
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
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<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
|
}
|
|
|
|
[Fact(DisplayName = "Резолв шага с типом руководителя CompanyDirectors резолвится в директора каждой компании инициатора, все с одним OrderNo")]
|
|
public async Task ResolveAsync_ByHeadTypeCompanyDirectors_ResolvesOneRowPerCompany()
|
|
{
|
|
// Arrange — инициатор числится в двух компаниях; для одной из них (KazPromService)
|
|
// должность директора захардкожена как «Главный механик», а не «Директор».
|
|
var portalClient = new Mock<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo>
|
|
{
|
|
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
|
|
new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности")
|
|
});
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU"));
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS"));
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
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<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo>
|
|
{
|
|
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
|
|
new(Guid.NewGuid(), DepartmentId, "ТОО СТ Global", "Начальник отдела службы безопасности")
|
|
});
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор"));
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync("Генеральный директор", "ТОО СТ Global", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(true, "same.director@tnsu.kz", "Общий директор"));
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
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 = "Резолв: если Директор Tansu совпадает с одним из Директоров компаний, он идёт не параллельно блоку, а сразу после него")]
|
|
public async Task ResolveAsync_DirectorTansuMatchesOneOfCompanyDirectors_SequencedAfterCompanyDirectorsBlock()
|
|
{
|
|
// Arrange — директор ТОО TANSU Construction (из шага CompanyDirectors, OrderNo 5)
|
|
// и Директор Tansu (из шага DirectorTansu, сам сконфигурирован с OrderNo 10) —
|
|
// один и тот же человек с одним и тем же email.
|
|
var portalClient = new Mock<ITnsuPortalClient>();
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new List<EmployeeDirectoryInfo>
|
|
{
|
|
new(CompanyId, DepartmentId, "ТОО TANSU Construction", "Начальник отдела службы безопасности"),
|
|
new(Guid.NewGuid(), DepartmentId, "ТОО KazPromService", "Начальник отдела службы безопасности")
|
|
});
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync("Директор", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(true, "director.tansu@tnsu.kz", "Директор TANSU"));
|
|
portalClient
|
|
.Setup(x => x.GetEmployeeByPositionAsync("Главный механик", "ТОО KazPromService", It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new EmployeeLookupResult(true, "mechanic.kps@tnsu.kz", "Главный механик KPS"));
|
|
|
|
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
|
var routeService = services.GetRequiredService<TemplateRouteService>();
|
|
var requestTypeService = services.GetRequiredService<RequestTypeService>();
|
|
var stepService = services.GetRequiredService<TemplateRouteStepService>();
|
|
|
|
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),
|
|
CancellationToken.None);
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(10, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null),
|
|
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 — все три строки остаются (директор не пропадает и не схлопывается
|
|
// молча), но Директор Tansu не параллелен блоку CompanyDirectors (OrderNo 5),
|
|
// а идёт сразу после него — OrderNo 6, а не свой сконфигурированный 10.
|
|
resolved.IsSuccess.ShouldBeTrue();
|
|
resolved.Value.Steps.Count.ShouldBe(3);
|
|
var tansuStep = resolved.Value.Steps.Single(s => s.PositionCode == HeadType.DirectorTansu);
|
|
tansuStep.ExecutorEmail.ShouldBe("director.tansu@tnsu.kz");
|
|
tansuStep.OrderNo.ShouldBe(6);
|
|
tansuStep.Warning.ShouldBeNull();
|
|
|
|
var companyDirectorSteps = resolved.Value.Steps.Where(s => s.PositionCode == HeadType.CompanyDirectors).ToList();
|
|
companyDirectorSteps.Count.ShouldBe(2);
|
|
companyDirectorSteps.ShouldAllBe(s => s.OrderNo == 5);
|
|
companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "director.tansu@tnsu.kz");
|
|
companyDirectorSteps.ShouldContain(s => s.ExecutorEmail == "mechanic.kps@tnsu.kz");
|
|
}
|
|
|
|
[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<TemplateRouteStepService>();
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null),
|
|
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<TemplateRouteStepService>();
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(1, null, "Несуществующая должность", null, null, RouteStepStatus.Approval, false, null),
|
|
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<TemplateRouteStepService>();
|
|
await stepService.CreateAsync(
|
|
route.Value.Id,
|
|
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.Security, null, RouteStepStatus.Approval, false, null),
|
|
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();
|
|
}
|
|
}
|