Files
tnsu-approval-route/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs
T
2026-07-31 12:51:48 +05:00

485 lines
25 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 EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
_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;
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Vacation");
// Action
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.RequestTypeId.ShouldBe(requestTypeId);
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsSuccess.ShouldBeTrue();
fetched.Value.CompanyId.ShouldBe(CompanyId);
}
[Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult()
{
// Action
var result = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Повторное создание маршрута с тем же типом заявки, отделом, компанией и признаком «по проекту» возвращает конфликт")]
public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Business trip");
var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false);
var first = await _service.CreateAsync(request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action
var duplicate = await _service.CreateAsync(request, CancellationToken.None);
// Assert
duplicate.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Обновление маршрута изменяет тип маршрута и признак необходимости контроля")]
public async Task UpdateAsync_ChangesFields()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Sick leave");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var updated = await _service.UpdateAsync(
created.Value.Id,
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Assert
updated.IsSuccess.ShouldBeTrue();
updated.Value.RouteType.ShouldBe(RouteType.Parallel);
updated.Value.RequiresControl.ShouldBeTrue();
}
[Fact(DisplayName = "Патч маршрута меняет только переданные поля, остальные остаются прежними")]
public async Task PatchAsync_UpdatesOnlyProvidedFields()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Patch route request");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var patched = await _service.PatchAsync(
created.Value.Id,
new PatchTemplateRouteRequest(RouteType.Parallel, DepartmentId: null, CompanyId: null, ByProject: null, RequiresControl: true),
CancellationToken.None);
// Assert
patched.IsSuccess.ShouldBeTrue();
patched.Value.RouteType.ShouldBe(RouteType.Parallel);
patched.Value.RequiresControl.ShouldBeTrue();
patched.Value.CompanyId.ShouldBe(CompanyId);
patched.Value.DepartmentId.ShouldBe(DepartmentId);
}
[Fact(DisplayName = "Удаление маршрута убирает его из базы")]
public async Task DeleteAsync_RemovesEntity()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Remote work");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
// Assert
deleted.IsSuccess.ShouldBeTrue();
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Резолв маршрута по точному совпадению отдела возвращает шаги с нужным статусом")]
public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Exact match request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, 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");
}
[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 EmployeeDirectoryInfo(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(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, 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);
resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz");
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
}
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Project request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, null, 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 EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
portalClient
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
.ReturnsAsync("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(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false, null, null, 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 EmployeeDirectoryInfo(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(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, null, 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");
}
[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 EmployeeDirectoryInfo(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(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, null, 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 = "Резолв шага с типом руководителя 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(RouteType.Sequential, 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, null, 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();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].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(RouteType.Sequential, 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, null, 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();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].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(RouteType.Sequential, 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, null, 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();
resolved.Value.Steps[0].Warning.ShouldNotBeNull();
resolved.Value.Steps[0].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(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.RouteId.ShouldBe(route.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
{
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(999999, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsFailed.ShouldBeTrue();
}
}