95d460c80a
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/19
622 lines
31 KiB
C#
622 lines
31 KiB
C#
using Microsoft.Extensions.DependencyInjection;
|
||
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.IntegrationTests.Infrastructure;
|
||
|
||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||
|
||
public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||
{
|
||
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
|
||
|
||
private readonly TemplateRouteStepService _service;
|
||
private readonly TemplateRouteService _routeService;
|
||
private readonly RequestTypeService _requestTypeService;
|
||
|
||
public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture)
|
||
{
|
||
var services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||
_service = services.GetRequiredService<TemplateRouteStepService>();
|
||
_routeService = services.GetRequiredService<TemplateRouteService>();
|
||
_requestTypeService = services.GetRequiredService<RequestTypeService>();
|
||
}
|
||
|
||
private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject, string? listName = null)
|
||
{
|
||
var siteUrl = listName is null ? null : "https://example.sharepoint.com/sites/test";
|
||
var requestTypeId = (await _requestTypeService.CreateAsync(
|
||
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}", listName, siteUrl, "General"), CancellationToken.None)).Value.Id;
|
||
|
||
var route = await _routeService.CreateAsync(
|
||
new CreateTemplateRouteRequest(requestTypeId, null, CompanyId, byProject, RequiresControl: false),
|
||
CancellationToken.None);
|
||
|
||
return route.Value.Id;
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага для несуществующего маршрута возвращает неуспешный результат")]
|
||
public async Task CreateAsync_UnknownRoute_ReturnsFailedResult()
|
||
{
|
||
// Action
|
||
var result = await _service.CreateAsync(
|
||
999999,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Роль в проекте недопустима для шага маршрута без признака «по проекту»")]
|
||
public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Non project route", byProject: false);
|
||
|
||
// Action
|
||
var result = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Повторное создание шага с теми же исполнителем, должностью, ролью, типом руководителя и статусом возвращает конфликт")]
|
||
public async Task CreateAsync_DuplicateStep_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with duplicate step", byProject: false);
|
||
var request = new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And);
|
||
var first = await _service.CreateAsync(routeId, request, CancellationToken.None);
|
||
first.IsSuccess.ShouldBeTrue();
|
||
|
||
// Action
|
||
var duplicate = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, "a@b.kz", "Engineer", null, HeadType.Chief, RouteStepStatus.Approval, true, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
duplicate.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Порядок условий и логика их объединения не влияют на определение дубликата")]
|
||
public async Task CreateAsync_DuplicateStepWithSameConditionsInDifferentOrder_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with duplicate conditions", byProject: false, listName: "Requests");
|
||
var first = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false,
|
||
new[]
|
||
{
|
||
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"),
|
||
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent")
|
||
},
|
||
ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
first.IsSuccess.ShouldBeTrue();
|
||
|
||
// Action — те же два условия, в обратном порядке, но с той же логикой И
|
||
var duplicate = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false,
|
||
new[]
|
||
{
|
||
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent"),
|
||
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000")
|
||
},
|
||
ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
duplicate.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Те же условия, но с другой логикой объединения (И/ИЛИ), — не дубликат")]
|
||
public async Task CreateAsync_SameConditionsWithDifferentLogic_DoesNotConflict()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with different conditions logic", byProject: false, listName: "Requests");
|
||
var conditions = new[]
|
||
{
|
||
new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000"),
|
||
new RouteStepConditionDto("Priority", ComparisonOperator.Equal, "Urgent")
|
||
};
|
||
var first = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, conditions, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
first.IsSuccess.ShouldBeTrue();
|
||
|
||
// Action — те же условия, но объединены через ИЛИ, а не И
|
||
var second = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, conditions, ConditionsLogic.Or),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
second.IsSuccess.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Второй шаг с тем же типом руководителя в том же маршруте — конфликт, даже с другим статусом/переназначением")]
|
||
public async Task CreateAsync_SecondStepWithSameHeadType_ReturnsFailedResult()
|
||
{
|
||
// Arrange — тип руководителя резолвится в ОДНОГО конкретного человека
|
||
// (см. HasDuplicateHeadTypeAsync), поэтому два шага с одним HeadType в
|
||
// одном маршруте не должны допускаться, даже если остальные поля разные.
|
||
var routeId = await CreateRouteAsync("Route with duplicate head type", byProject: false);
|
||
var first = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
first.IsSuccess.ShouldBeTrue();
|
||
|
||
// Action — другой статус (Execution вместо Approval), но тот же HeadType.Chief
|
||
var second = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
second.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Два шага «Руководитель указанного отдела» в одном маршруте — конфликт")]
|
||
public async Task CreateAsync_TwoDepartmentHeadSteps_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with duplicate department head", byProject: false);
|
||
var first = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
first.IsSuccess.ShouldBeTrue();
|
||
|
||
// Action
|
||
var second = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DepartmentHead, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
second.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Разные типы руководителя в одном маршруте — допустимы (конфликт только при совпадении HeadType)")]
|
||
public async Task CreateAsync_DifferentHeadTypes_BothSucceed()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with different head types", byProject: false);
|
||
|
||
// Action
|
||
var chief = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
var directorTansu = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
chief.IsSuccess.ShouldBeTrue();
|
||
directorTansu.IsSuccess.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Два шага с одним типом руководителя, но разными условиями появления — допустимы")]
|
||
public async Task CreateAsync_SameHeadTypeDifferentConditions_BothSucceed()
|
||
{
|
||
// Arrange — условия появления взаимоисключающи по данным заявки, поэтому
|
||
// в конкретный резолв попадёт только один из двух шагов.
|
||
var routeId = await CreateRouteAsync("Route with head type per condition", byProject: false, listName: "Requests");
|
||
|
||
// Action
|
||
var small = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
var big = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
small.IsSuccess.ShouldBeTrue();
|
||
big.IsSuccess.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Два шага с одним конкретным исполнителем, но разными условиями появления — допустимы")]
|
||
public async Task CreateAsync_SameExecutorDifferentConditions_BothSucceed()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with executor per condition", byProject: false, listName: "Requests");
|
||
|
||
// Action
|
||
var small = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.LessThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
var big = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
small.IsSuccess.ShouldBeTrue();
|
||
big.IsSuccess.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Два шага с одним типом руководителя и одинаковым условием появления — конфликт")]
|
||
public async Task CreateAsync_SameHeadTypeSameCondition_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with head type same condition", byProject: false, listName: "Requests");
|
||
var first = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
first.IsSuccess.ShouldBeTrue();
|
||
|
||
// Action — то же условие, другой статус
|
||
var second = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(2, null, null, null, HeadType.Chief, RouteStepStatus.Execution, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
second.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Обновление шага, при котором HeadType не меняется, не конфликтует само с собой")]
|
||
public async Task UpdateAsync_SameHeadTypeOnSameStep_DoesNotConflictWithItself()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route to update head type on itself", byProject: false);
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action — HasDuplicateHeadTypeAsync должен исключать сам обновляемый шаг (excludeId)
|
||
var updated = await _service.UpdateAsync(
|
||
routeId,
|
||
created.Value.Id,
|
||
new UpdateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
updated.IsSuccess.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага только с типом руководителя (без исполнителя, должности и роли) успешно")]
|
||
public async Task CreateAsync_HeadTypeOnly_ReturnsCreatedStep()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Head type only route", byProject: false);
|
||
|
||
// Action
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
created.IsSuccess.ShouldBeTrue();
|
||
created.Value.HeadType.ShouldBe(HeadType.Chief);
|
||
created.Value.ExecutorEmail.ShouldBeNull();
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
|
||
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Simple route", byProject: false);
|
||
|
||
// Action
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
created.IsSuccess.ShouldBeTrue();
|
||
|
||
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||
fetched.IsSuccess.ShouldBeTrue();
|
||
fetched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага без роли в проекте на маршруте «по проекту» возвращает предупреждение")]
|
||
public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("By-project route without role", byProject: true);
|
||
|
||
// Action
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
created.IsSuccess.ShouldBeTrue();
|
||
created.Value.Warning.ShouldNotBeNull();
|
||
created.Value.Warning.ShouldContain("ролью в проекте");
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")]
|
||
public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("By-project route with role", byProject: true);
|
||
|
||
// Action
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
created.IsSuccess.ShouldBeTrue();
|
||
created.Value.Warning.ShouldBeNull();
|
||
}
|
||
|
||
[Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")]
|
||
public async Task UpdateAsync_ChangesFields()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var updated = await _service.UpdateAsync(
|
||
routeId,
|
||
created.Value.Id,
|
||
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
updated.IsSuccess.ShouldBeTrue();
|
||
updated.Value.ExecutorEmail.ShouldBe("b@b.kz");
|
||
updated.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Патч шага маршрута меняет только переданные поля, остальные остаются прежними")]
|
||
public async Task PatchAsync_UpdatesOnlyProvidedFields()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route to patch step", byProject: false);
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var patched = await _service.PatchAsync(
|
||
routeId,
|
||
created.Value.Id,
|
||
new PatchTemplateRouteStepRequest(OrderNo: 2, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.Execution, AllowReassignToOtherApprovers: null, Conditions: null),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
patched.IsSuccess.ShouldBeTrue();
|
||
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||
patched.Value.OrderNo.ShouldBe(2);
|
||
patched.Value.Status.ShouldBe(RouteStepStatus.Execution);
|
||
}
|
||
|
||
[Fact(DisplayName = "Патч, после которого заполнено больше одного из полей исполнителя, возвращает неуспешный результат")]
|
||
public async Task PatchAsync_ResultsInMoreThanOneFilledField_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route to patch step into conflict", byProject: false);
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var patched = await _service.PatchAsync(
|
||
routeId,
|
||
created.Value.Id,
|
||
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: "a@b.kz", Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, Conditions: null),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
patched.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Патч со статусом None возвращает неуспешный результат")]
|
||
public async Task PatchAsync_StatusNone_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route to patch step status none", byProject: false);
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var patched = await _service.PatchAsync(
|
||
routeId,
|
||
created.Value.Id,
|
||
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: RouteStepStatus.None, AllowReassignToOtherApprovers: null, Conditions: null),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
patched.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
// ConditionsLogic здесь всюду передаётся явно, потому что он обязателен: у
|
||
// Create/Update-запроса значения по умолчанию нет вовсе, а пропущенное в JSON
|
||
// поле превращается в ConditionsLogic.None и отвергается валидатором на слое
|
||
// API (TemplateRouteStepValidators + ValidationActionFilter). Отдельным тестом
|
||
// это не покрыто — сервис валидаторы не вызывает.
|
||
|
||
[Fact(DisplayName = "Явные ProjectRole.None и HeadType.None при патче считаются незаполненными, как и null")]
|
||
public async Task PatchAsync_ExplicitNoneValues_TreatedAsUnfilled()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with explicit None values on patch", byProject: false);
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", null, null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var patched = await _service.PatchAsync(
|
||
routeId,
|
||
created.Value.Id,
|
||
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: ProjectRole.None, HeadType: HeadType.None, Status: null, AllowReassignToOtherApprovers: null, Conditions: null),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
patched.IsSuccess.ShouldBeTrue();
|
||
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага с условием без значения возвращает неуспешный результат")]
|
||
public async Task CreateAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with partial condition", byProject: false, listName: "Requests");
|
||
|
||
// Action — оператор задан, но value пуст: условие наполовину заполнено
|
||
var result = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false,
|
||
new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага с условием на типе заявки без list_name возвращает неуспешный результат")]
|
||
public async Task CreateAsync_ConditionFieldsWithoutListName_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route without list name", byProject: false);
|
||
|
||
// Action
|
||
var result = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Создание шага с условием на типе заявки со list_name успешно")]
|
||
public async Task CreateAsync_ConditionFieldsWithListName_ReturnsCreatedStep()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route with list name", byProject: false, listName: "Requests");
|
||
|
||
// Action
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, new[] { new RouteStepConditionDto("Amount", ComparisonOperator.GreaterThan, "1000") }, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
created.IsSuccess.ShouldBeTrue();
|
||
created.Value.Conditions.ShouldHaveSingleItem();
|
||
created.Value.Conditions[0].FieldName.ShouldBe("Amount");
|
||
created.Value.Conditions[0].Operation.ShouldBe(ComparisonOperator.GreaterThan);
|
||
created.Value.Conditions[0].Value.ShouldBe("1000");
|
||
}
|
||
|
||
[Fact(DisplayName = "Патч, после которого поля условия заполнены частично, возвращает неуспешный результат")]
|
||
public async Task PatchAsync_ConditionFieldsPartiallyFilled_ReturnsFailedResult()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route to patch condition into conflict", byProject: false, listName: "Requests");
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var patched = await _service.PatchAsync(
|
||
routeId,
|
||
created.Value.Id,
|
||
new PatchTemplateRouteStepRequest(OrderNo: null, ExecutorEmail: null, Position: null, ProjectRole: null, HeadType: null, Status: null, AllowReassignToOtherApprovers: null, Conditions: new[] { new RouteStepConditionDto("Amount", ComparisonOperator.None, "") }),
|
||
CancellationToken.None);
|
||
|
||
// Assert
|
||
patched.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
|
||
public async Task DeleteAsync_RemovesStep()
|
||
{
|
||
// Arrange
|
||
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
|
||
var created = await _service.CreateAsync(
|
||
routeId,
|
||
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None);
|
||
|
||
// Assert
|
||
deleted.IsSuccess.ShouldBeTrue();
|
||
|
||
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
|
||
fetched.IsFailed.ShouldBeTrue();
|
||
}
|
||
|
||
[Fact(DisplayName = "ListByExecutorEmailAsync находит шаги с данным исполнителем сразу по разным маршрутам, регистронезависимо")]
|
||
public async Task ListByExecutorEmailAsync_FindsStepsAcrossRoutes_CaseInsensitive()
|
||
{
|
||
// Arrange
|
||
var routeAId = await CreateRouteAsync("Route A for executor search", byProject: false);
|
||
var routeBId = await CreateRouteAsync("Route B for executor search", byProject: false);
|
||
await _service.CreateAsync(
|
||
routeAId,
|
||
new CreateTemplateRouteStepRequest(1, "Target@Example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
await _service.CreateAsync(
|
||
routeBId,
|
||
new CreateTemplateRouteStepRequest(1, "target@example.kz", "Manager", null, null, RouteStepStatus.Execution, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
// Другой исполнитель — не должен попасть в выдачу.
|
||
await _service.CreateAsync(
|
||
routeAId,
|
||
new CreateTemplateRouteStepRequest(2, "other@example.kz", "Engineer", null, null, RouteStepStatus.Approval, false, null, ConditionsLogic.And),
|
||
CancellationToken.None);
|
||
|
||
// Action
|
||
var result = await _service.ListByExecutorEmailAsync("TARGET@EXAMPLE.KZ", CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsSuccess.ShouldBeTrue();
|
||
result.Value.Count.ShouldBe(2);
|
||
result.Value.ShouldAllBe(x => x.RouteId == routeAId || x.RouteId == routeBId);
|
||
}
|
||
|
||
[Fact(DisplayName = "ListByExecutorEmailAsync с пустым email возвращает пустой список, а не ошибку")]
|
||
public async Task ListByExecutorEmailAsync_EmptyEmail_ReturnsEmptyList()
|
||
{
|
||
// Action
|
||
var result = await _service.ListByExecutorEmailAsync(string.Empty, CancellationToken.None);
|
||
|
||
// Assert
|
||
result.IsSuccess.ShouldBeTrue();
|
||
result.Value.ShouldBeEmpty();
|
||
}
|
||
}
|