5 Commits

Author SHA1 Message Date
gorbenko.pavel 8479190ec7 схлопываниение
Tests / test (pull_request) Successful in 5m31s
2026-07-27 17:10:45 +05:00
gorbenko.pavel aa49db309b логика готова 2026-07-27 17:06:16 +05:00
gorbenko.pavel 13f557e1f4 теперь поля nullable 2026-07-27 15:44:25 +05:00
gorbenko.pavel 8f92a9646d добавлены patch 2026-07-27 09:21:15 +05:00
gorbenko.pavel 5928377465 правки 2026-07-24 17:54:34 +05:00
35 changed files with 644 additions and 118 deletions
@@ -5,7 +5,6 @@ using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Api.Auth; namespace Tnsu.ApprovalRoute.Api.Auth;
// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID).
public sealed class DevAuthHandler( public sealed class DevAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options, IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, ILoggerFactory logger,
@@ -35,6 +35,13 @@ public sealed class RequestTypesController(RequestTypeService service) : Control
public async Task<IActionResult> Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) => public async Task<IActionResult> Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult(); (await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpPatch("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Patch(int id, PatchRequestTypeRequest body, CancellationToken ct) =>
(await service.PatchAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
@@ -36,6 +36,14 @@ public sealed class TemplateRouteStepsController(TemplateRouteStepService servic
public async Task<IActionResult> Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => public async Task<IActionResult> Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
[HttpPatch("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Patch(int routeId, int id, PatchTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.PatchAsync(routeId, id, body, ct)).ToActionResult();
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
@@ -45,6 +45,14 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con
public async Task<IActionResult> Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) => public async Task<IActionResult> Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult(); (await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpPatch("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)]
public async Task<IActionResult> Patch(int id, PatchTemplateRouteRequest body, CancellationToken ct) =>
(await service.PatchAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:int}")] [HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
@@ -5,7 +5,6 @@ using Tnsu.ApprovalRoute.Api.Common;
namespace Tnsu.ApprovalRoute.Api.Filters; namespace Tnsu.ApprovalRoute.Api.Filters;
// Валидирует аргументы action'ов, для которых зарегистрирован IValidator<T> — без валидации внутри сервисов.
public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter
{ {
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
@@ -47,7 +47,6 @@ builder.Services.AddOpenApi(options =>
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>()) builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
.AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()));
// Тот же конвертер для Http.Json — их читает генератор OpenAPI, чтобы enum в схеме были строками, а не числами.
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
var app = builder.Build(); var app = builder.Build();
@@ -56,8 +55,6 @@ if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.MapOpenApi();
// Scalar API reference UI — нативно понимает документ MapOpenApi (включая securitySchemes/security)
// и корректно прикрепляет Bearer-токен к запросам.
app.MapScalarApiReference(options => app.MapScalarApiReference(options =>
{ {
options options
@@ -20,6 +20,7 @@
"TenantId": "", "TenantId": "",
"ClientId": "", "ClientId": "",
"ClientSecret": "", "ClientSecret": "",
"Scope": "" "Scope": "",
"DirectorTansuEmail": "director.tansu@tnsu.kz"
} }
} }
@@ -5,3 +5,5 @@ public sealed record RequestTypeDto(int Id, string Name, string PageUrl);
public sealed record CreateRequestTypeRequest(string Name, string PageUrl); public sealed record CreateRequestTypeRequest(string Name, string PageUrl);
public sealed record UpdateRequestTypeRequest(string Name, string PageUrl); public sealed record UpdateRequestTypeRequest(string Name, string PageUrl);
public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl);
@@ -49,6 +49,19 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db)
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
} }
public async Task<Result<RequestTypeDto>> PatchAsync(int id, PatchRequestTypeRequest request, CancellationToken cancellationToken)
{
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null)
return Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id));
entity.Name = request.Name ?? entity.Name;
entity.PageUrl = request.PageUrl ?? entity.PageUrl;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken) public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
{ {
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -19,3 +19,14 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<Update
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
} }
} }
public sealed class PatchRequestTypeRequestValidator : AbstractValidator<PatchRequestTypeRequest>
{
public PatchRequestTypeRequestValidator()
{
When(x => x.Name is not null, () =>
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки не может быть пустым."));
When(x => x.PageUrl is not null, () =>
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы не может быть пустым."));
}
}
@@ -5,10 +5,10 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed record TemplateRouteStepDto( public sealed record TemplateRouteStepDto(
int Id, int Id,
int OrderNo, int OrderNo,
string ExecutorEmail, string? ExecutorEmail,
string Position, string? Position,
ProjectRole ProjectRole, ProjectRole? ProjectRole,
HeadType HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
int RouteId, int RouteId,
bool AllowReassignToOtherApprovers, bool AllowReassignToOtherApprovers,
@@ -16,18 +16,27 @@ public sealed record TemplateRouteStepDto(
public sealed record CreateTemplateRouteStepRequest( public sealed record CreateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
string ExecutorEmail, string? ExecutorEmail,
string Position, string? Position,
ProjectRole ProjectRole, ProjectRole? ProjectRole,
HeadType HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers);
public sealed record UpdateTemplateRouteStepRequest( public sealed record UpdateTemplateRouteStepRequest(
int OrderNo, int OrderNo,
string ExecutorEmail, string? ExecutorEmail,
string Position, string? Position,
ProjectRole ProjectRole, ProjectRole? ProjectRole,
HeadType HeadType, HeadType? HeadType,
RouteStepStatus Status, RouteStepStatus Status,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers);
public sealed record PatchTemplateRouteStepRequest(
int? OrderNo,
string? ExecutorEmail,
string? Position,
ProjectRole? ProjectRole,
HeadType? HeadType,
RouteStepStatus? Status,
bool? AllowReassignToOtherApprovers);
@@ -37,10 +37,16 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
if (route is null) if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
if (!route.ByProject && r.ProjectRole != ProjectRole.None) if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
var entity = new TemplateRouteStep var entity = new TemplateRouteStep
{ {
RouteId = route.Id, RouteId = route.Id,
@@ -69,10 +75,16 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
if (entity is null) if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id)); return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
if (!route.ByProject && r.ProjectRole != ProjectRole.None) if (!route.ByProject && r.ProjectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError( return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
var duplicate = await HasDuplicateAsync(
route.Id, r.ExecutorEmail, r.Position, r.ProjectRole, r.HeadType, r.Status, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
entity.OrderNo = r.OrderNo; entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail; entity.ExecutorEmail = r.ExecutorEmail;
entity.Position = r.Position; entity.Position = r.Position;
@@ -85,6 +97,49 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
} }
public async Task<Result<TemplateRouteStepDto>> PatchAsync(
int routeId, int id, PatchTemplateRouteStepRequest r, CancellationToken cancellationToken)
{
var route = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == routeId, cancellationToken);
if (route is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Маршрут", routeId));
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
if (entity is null)
return Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id));
var executorEmail = r.ExecutorEmail ?? entity.ExecutorEmail;
var position = r.Position ?? entity.Position;
var projectRole = r.ProjectRole ?? entity.ProjectRole;
var headType = r.HeadType ?? entity.HeadType;
var status = r.Status ?? entity.Status;
if (!route.ByProject && projectRole is not (null or ProjectRole.None))
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
if (TemplateRouteStepFields.CountFilled(executorEmail, position, projectRole, headType) != 1)
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя."));
var duplicate = await HasDuplicateAsync(
route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteStepDto>(new ConflictError(
"Шаг с такими исполнителем, должностью, ролью в проекте, типом руководителя и статусом уже существует в этом маршруте."));
entity.OrderNo = r.OrderNo ?? entity.OrderNo;
entity.ExecutorEmail = executorEmail;
entity.Position = position;
entity.ProjectRole = projectRole;
entity.HeadType = headType;
entity.Status = status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers ?? entity.AllowReassignToOtherApprovers;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
public async Task<Result> DeleteAsync(int routeId, int id, CancellationToken cancellationToken) public async Task<Result> DeleteAsync(int routeId, int id, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
@@ -97,7 +152,19 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
return Result.Ok(); return Result.Ok();
} }
// Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning. private Task<bool> HasDuplicateAsync(
int routeId, string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType,
RouteStepStatus status, int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRouteSteps.AnyAsync(x =>
x.Id != excludeId &&
x.RouteId == routeId &&
x.ExecutorEmail == executorEmail &&
x.Position == position &&
x.ProjectRole == projectRole &&
x.HeadType == headType &&
x.Status == status,
cancellationToken);
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync( private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
{ {
@@ -105,7 +172,7 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
if (route.ByProject) if (route.ByProject)
{ {
var hasProjectRoleStep = await db.TemplateRouteSteps var hasProjectRoleStep = await db.TemplateRouteSteps
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken); .AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != null && x.ProjectRole != ProjectRole.None, cancellationToken);
if (!hasProjectRoleStep) if (!hasProjectRoleStep)
warning = "Должен быть хотя бы один шаг с ролью в проекте."; warning = "Должен быть хотя бы один шаг с ролью в проекте.";
} }
@@ -8,8 +8,8 @@ public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<
public CreateTemplateRouteStepRequestValidator() public CreateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x) RuleFor(x => x)
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
} }
} }
@@ -18,7 +18,16 @@ public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<
public UpdateTemplateRouteStepRequestValidator() public UpdateTemplateRouteStepRequestValidator()
{ {
RuleFor(x => x) RuleFor(x => x)
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None)) .Must(x => TemplateRouteStepFields.CountFilled(x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType) == 1)
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно."); .WithMessage("Должно быть заполнено ровно одно из полей: исполнитель, должность, роль в проекте или тип руководителя.");
} }
} }
internal static class TemplateRouteStepFields
{
public static int CountFilled(string? executorEmail, string? position, ProjectRole? projectRole, HeadType? headType) =>
(string.IsNullOrWhiteSpace(executorEmail) ? 0 : 1) +
(string.IsNullOrWhiteSpace(position) ? 0 : 1) +
(projectRole is null or ProjectRole.None ? 0 : 1) +
(headType is null or HeadType.None ? 0 : 1);
}
@@ -5,11 +5,8 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record ResolvedRouteStepDto( public sealed record ResolvedRouteStepDto(
int Id, int Id,
int OrderNo, int OrderNo,
string ExecutorEmail, string? ExecutorEmail,
string Position, string? Position,
ProjectRole ProjectRole,
HeadType HeadType,
RouteStepStatus Status,
bool AllowReassignToOtherApprovers); bool AllowReassignToOtherApprovers);
public sealed record ResolveTemplateRouteResultDto( public sealed record ResolveTemplateRouteResultDto(
@@ -25,3 +25,10 @@ public sealed record UpdateTemplateRouteRequest(
Guid CompanyId, Guid CompanyId,
bool ByProject, bool ByProject,
bool RequiresControl); bool RequiresControl);
public sealed record PatchTemplateRouteRequest(
RouteType? RouteType,
Guid? DepartmentId,
Guid? CompanyId,
bool? ByProject,
bool? RequiresControl);
@@ -1,5 +1,6 @@
using FluentResults; using FluentResults;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Application.Common.Errors; using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces; using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
@@ -12,7 +13,8 @@ namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class TemplateRouteService( public sealed class TemplateRouteService(
ApprovalRouteDbContext db, ApprovalRouteDbContext db,
ITnsuPortalClient portalClient, ITnsuPortalClient portalClient,
ICurrentUser currentUser) ICurrentUser currentUser,
IOptions<TnsuPortalOptions> portalOptions)
{ {
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken) public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
{ {
@@ -84,6 +86,31 @@ public sealed class TemplateRouteService(
return Result.Ok(ToDto(entity)); return Result.Ok(ToDto(entity));
} }
public async Task<Result<TemplateRouteDto>> PatchAsync(int id, PatchTemplateRouteRequest r, CancellationToken cancellationToken)
{
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id));
var departmentId = r.DepartmentId ?? entity.DepartmentId;
var companyId = r.CompanyId ?? entity.CompanyId;
var byProject = r.ByProject ?? entity.ByProject;
var duplicate = await HasDuplicateAsync(entity.RequestTypeId, departmentId, companyId, byProject, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType ?? entity.RouteType;
entity.DepartmentId = departmentId;
entity.CompanyId = companyId;
entity.ByProject = byProject;
entity.RequiresControl = r.RequiresControl ?? entity.RequiresControl;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken) public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
{ {
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
@@ -141,13 +168,18 @@ public sealed class TemplateRouteService(
if (request.ProjectId is { } projectId) if (request.ProjectId is { } projectId)
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken); projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
var steps = route.Steps var matchingSteps = route.Steps
.Where(s => s.Status == request.Status) .Where(s => s.Status == request.Status)
.OrderBy(s => s.OrderNo) .OrderBy(s => s.OrderNo)
.Select(s => ResolveStep(s, projectExecutors))
.ToList(); .ToList();
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps)); var steps = new List<ResolvedRouteStepDto>();
foreach (var step in matchingSteps)
steps.Add(await ResolveStepAsync(step, email, employee.Company, projectExecutors, cancellationToken));
var collapsedSteps = CollapseByExecutorEmail(steps);
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, collapsedSteps));
} }
private Task<bool> HasDuplicateAsync( private Task<bool> HasDuplicateAsync(
@@ -160,22 +192,53 @@ public sealed class TemplateRouteService(
x.ByProject == byProject, x.ByProject == byProject,
cancellationToken); cancellationToken);
private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors) private async Task<ResolvedRouteStepDto> ResolveStepAsync(
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
IReadOnlyList<ProjectExecutorInfo> projectExecutors, CancellationToken cancellationToken)
{ {
// Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz); string? resolvedEmail;
// должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона.
if (step.ProjectRole != ProjectRole.None) if (!string.IsNullOrWhiteSpace(step.ExecutorEmail))
resolvedEmail = step.ExecutorEmail;
else if (!string.IsNullOrWhiteSpace(step.Position))
resolvedEmail = await portalClient.GetEmployeeByPositionAsync(step.Position, initiatorCompany, cancellationToken);
else if (step.ProjectRole is not (null or ProjectRole.None))
resolvedEmail = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole)?.Email;
else if (step.HeadType is not (null or HeadType.None))
resolvedEmail = step.HeadType switch
{
HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken),
HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken),
HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail,
_ => null
};
else
resolvedEmail = null;
string? resolvedPosition = null;
if (!string.IsNullOrWhiteSpace(resolvedEmail))
{ {
var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); var resolvedEmployee = await portalClient.GetEmployeeByEmailAsync(resolvedEmail, cancellationToken);
if (projectExecutor is not null) resolvedPosition = resolvedEmployee?.Position;
return new ResolvedRouteStepDto(
step.Id, step.OrderNo, projectExecutor.Email, step.Position,
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
} }
return new ResolvedRouteStepDto( return new ResolvedRouteStepDto(step.Id, step.OrderNo, resolvedEmail, resolvedPosition, step.AllowReassignToOtherApprovers);
step.Id, step.OrderNo, step.ExecutorEmail, step.Position, }
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
private static IReadOnlyList<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
{
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var result = new List<ResolvedRouteStepDto>();
foreach (var step in steps)
{
if (!string.IsNullOrWhiteSpace(step.ExecutorEmail) && !seenEmails.Add(step.ExecutorEmail))
continue;
result.Add(step);
}
return result;
} }
private static TemplateRouteDto ToDto(TemplateRoute e) => new( private static TemplateRouteDto ToDto(TemplateRoute e) => new(
@@ -19,6 +19,15 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<Upda
} }
} }
public sealed class PatchTemplateRouteRequestValidator : AbstractValidator<PatchTemplateRouteRequest>
{
public PatchTemplateRouteRequestValidator()
{
When(x => x.CompanyId is not null, () =>
RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна."));
}
}
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest> public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
{ {
public ResolveTemplateRouteRequestValidator() public ResolveTemplateRouteRequestValidator()
@@ -8,8 +8,6 @@ public class TemplateRoute
public RouteType RouteType { get; set; } = RouteType.Sequential; public RouteType RouteType { get; set; } = RouteType.Sequential;
public int RequestTypeId { get; set; } public int RequestTypeId { get; set; }
// Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД.
// Отдел опционален, компания обязательна.
public Guid? DepartmentId { get; set; } public Guid? DepartmentId { get; set; }
public Guid CompanyId { get; set; } public Guid CompanyId { get; set; }
public bool ByProject { get; set; } public bool ByProject { get; set; }
@@ -7,11 +7,10 @@ public class TemplateRouteStep
public int Id { get; set; } public int Id { get; set; }
public int OrderNo { get; set; } public int OrderNo { get; set; }
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users. public string? ExecutorEmail { get; set; }
public string ExecutorEmail { get; set; } = string.Empty; public string? Position { get; set; }
public string Position { get; set; } = string.Empty; public ProjectRole? ProjectRole { get; set; }
public ProjectRole ProjectRole { get; set; } = ProjectRole.None; public HeadType? HeadType { get; set; }
public HeadType HeadType { get; set; } = HeadType.None;
public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval; public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
public int RouteId { get; set; } public int RouteId { get; set; }
@@ -1,7 +1,5 @@
namespace Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Domain.Enums;
// Роли в проекте — соответствуют именованным полям проекта в api-tnsu-portal
// (project_manager / planner / manager_hse / security).
public enum ProjectRole public enum ProjectRole
{ {
None, None,
@@ -29,10 +29,10 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
modelBuilder.Entity<TemplateRouteStep>(e => modelBuilder.Entity<TemplateRouteStep>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.Property(x => x.ExecutorEmail).HasMaxLength(256).IsRequired(); e.Property(x => x.ExecutorEmail).HasMaxLength(256);
e.Property(x => x.Position).HasMaxLength(128).IsRequired(); e.Property(x => x.Position).HasMaxLength(128);
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32);
e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.Status).HasConversion<string>().HasMaxLength(32).IsRequired();
e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId); e.HasOne(x => x.Route).WithMany(x => x.Steps).HasForeignKey(x => x.RouteId);
e.HasIndex(x => new { x.RouteId, x.OrderNo }); e.HasIndex(x => new { x.RouteId, x.OrderNo });
@@ -12,7 +12,7 @@ using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
{ {
[DbContext(typeof(ApprovalRouteDbContext))] [DbContext(typeof(ApprovalRouteDbContext))]
[Migration("20260724114550_InitialCreate")] [Migration("20260727103117_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -111,13 +111,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("allow_reassign_to_other_approvers"); .HasColumnName("allow_reassign_to_other_approvers");
b.Property<string>("ExecutorEmail") b.Property<string>("ExecutorEmail")
.IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("HeadType") b.Property<string>("HeadType")
.IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("head_type"); .HasColumnName("head_type");
@@ -127,13 +125,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("order_no"); .HasColumnName("order_no");
b.Property<string>("Position") b.Property<string>("Position")
.IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
.HasColumnName("position"); .HasColumnName("position");
b.Property<string>("ProjectRole") b.Property<string>("ProjectRole")
.IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("project_role"); .HasColumnName("project_role");
@@ -57,10 +57,10 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
id = table.Column<int>(type: "integer", nullable: false) id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
order_no = table.Column<int>(type: "integer", nullable: false), order_no = table.Column<int>(type: "integer", nullable: false),
executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), executor_email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: true),
project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false), project_role = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false), head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false), status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
route_id = table.Column<int>(type: "integer", nullable: false), route_id = table.Column<int>(type: "integer", nullable: false),
allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false) allow_reassign_to_other_approvers = table.Column<bool>(type: "boolean", nullable: false)
@@ -108,13 +108,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("allow_reassign_to_other_approvers"); .HasColumnName("allow_reassign_to_other_approvers");
b.Property<string>("ExecutorEmail") b.Property<string>("ExecutorEmail")
.IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("HeadType") b.Property<string>("HeadType")
.IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("head_type"); .HasColumnName("head_type");
@@ -124,13 +122,11 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("order_no"); .HasColumnName("order_no");
b.Property<string>("Position") b.Property<string>("Position")
.IsRequired()
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("character varying(128)") .HasColumnType("character varying(128)")
.HasColumnName("position"); .HasColumnName("position");
b.Property<string>("ProjectRole") b.Property<string>("ProjectRole")
.IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("project_role"); .HasColumnName("project_role");
@@ -8,48 +8,45 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
// Клиент api.tnsu.kz (api-tnsu-portal).
public sealed class HttpTnsuPortalClient( public sealed class HttpTnsuPortalClient(
HttpClient httpClient, HttpClient httpClient,
TnsuPortalTokenProvider tokenProvider, TnsuPortalTokenProvider tokenProvider,
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
{ {
// Разные эндпоинты api-tnsu-portal отдают JSON в разном регистре (Dapper-результаты — PascalCase,
// строго типизированные модели — camelCase), поэтому сопоставляем поля без учёта регистра.
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) public async Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}"); var employee = await GetAsync<EmployeeResponse>(
await AuthorizeAsync(request, cancellationToken); $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}", cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
var employee = await response.Content.ReadFromJsonAsync<EmployeeResponse>(JsonOptions, cancellationToken);
if (employee is null) if (employee is null)
return null; return null;
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId); return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId, employee.Company ?? string.Empty, employee.Position);
}
public async Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken)
{
var employee = await GetAsync<EmployeeResponse>(
$"/Employee/ByPosition?position={Uri.EscapeDataString(position)}&company={Uri.EscapeDataString(company)}", cancellationToken);
return employee?.Email;
}
public async Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken)
{
var manager = await GetAsync<ManagerResponse>(
$"/Employee/Manager?email={Uri.EscapeDataString(email)}&managerType={Uri.EscapeDataString(managerType)}", cancellationToken);
return manager?.ManagerEmail;
} }
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken) public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, $"/Project/{projectId}"); var project = await GetAsync<ProjectResponse>($"/Project/{projectId}", cancellationToken);
await AuthorizeAsync(request, cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return [];
response.EnsureSuccessStatusCode();
var project = await response.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions, cancellationToken);
if (project is null) if (project is null)
return []; return [];
// Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security).
var executors = new List<ProjectExecutorInfo>(); var executors = new List<ProjectExecutorInfo>();
AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager); AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager);
AddIfPresent(executors, ProjectRole.Planner, project.Planner); AddIfPresent(executors, ProjectRole.Planner, project.Planner);
@@ -59,15 +56,25 @@ public sealed class HttpTnsuPortalClient(
return executors; return executors;
} }
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
{
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeAsync(request, cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<T>(JsonOptions, cancellationToken);
}
private static void AddIfPresent(List<ProjectExecutorInfo> executors, ProjectRole role, string? email) private static void AddIfPresent(List<ProjectExecutorInfo> executors, ProjectRole role, string? email)
{ {
if (!string.IsNullOrWhiteSpace(email)) if (!string.IsNullOrWhiteSpace(email))
executors.Add(new ProjectExecutorInfo(role, email)); executors.Add(new ProjectExecutorInfo(role, email));
} }
// ВРЕМЕННО: для TnsuPortal ещё нет своего client-credentials приложения в Entra, поэтому прокидываем
// токен текущего пользователя дальше — его aud совпадает с ClientId api-tnsu-portal, так что подходит.
// Когда появится своё приложение — вернуть на tokenProvider.GetAccessTokenAsync(...).
private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken) private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{ {
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
@@ -82,8 +89,14 @@ public sealed class HttpTnsuPortalClient(
} }
private sealed record EmployeeResponse( private sealed record EmployeeResponse(
[property: JsonPropertyName("email")] string? Email,
[property: JsonPropertyName("company")] string? Company,
[property: JsonPropertyName("companyId")] Guid? CompanyId, [property: JsonPropertyName("companyId")] Guid? CompanyId,
[property: JsonPropertyName("departmentId")] Guid? DepartmentId); [property: JsonPropertyName("departmentId")] Guid? DepartmentId,
[property: JsonPropertyName("position")] string? Position);
private sealed record ManagerResponse(
[property: JsonPropertyName("managerEmail")] string? ManagerEmail);
private sealed record ProjectResponse( private sealed record ProjectResponse(
[property: JsonPropertyName("projectManager")] string? ProjectManager, [property: JsonPropertyName("projectManager")] string? ProjectManager,
@@ -4,5 +4,9 @@ public interface ITnsuPortalClient
{ {
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken); Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
Task<string?> GetEmployeeByPositionAsync(string position, string company, CancellationToken cancellationToken);
Task<string?> GetManagerAsync(string email, string managerType, CancellationToken cancellationToken);
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken); Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
} }
@@ -2,6 +2,6 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId); public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId, string Company, string? Position);
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email); public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
@@ -7,4 +7,5 @@ public sealed class TnsuPortalOptions
public string ClientId { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty; public string ClientSecret { get; set; } = string.Empty;
public string Scope { get; set; } = string.Empty; public string Scope { get; set; } = string.Empty;
public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz";
} }
@@ -4,7 +4,6 @@ using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя.
public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options) public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options)
{ {
private string? _cachedToken; private string? _cachedToken;
@@ -66,6 +66,33 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I
result.IsFailed.ShouldBeTrue(); result.IsFailed.ShouldBeTrue();
} }
[Fact(DisplayName = "Патч типа заявки меняет только переданное поле, остальные остаются прежними")]
public async Task PatchAsync_UpdatesOnlyProvidedFields()
{
// Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Original name", "/original"), CancellationToken.None);
// Action
var patched = await _service.PatchAsync(
created.Value.Id, new PatchRequestTypeRequest(Name: "Patched name", PageUrl: null), CancellationToken.None);
// Assert
patched.IsSuccess.ShouldBeTrue();
patched.Value.Name.ShouldBe("Patched name");
patched.Value.PageUrl.ShouldBe("/original");
}
[Fact(DisplayName = "Патч несуществующего типа заявки возвращает неуспешный результат")]
public async Task PatchAsync_UnknownId_ReturnsFailedResult()
{
// Action
var result = await _service.PatchAsync(
999999, new PatchRequestTypeRequest(Name: "X", PageUrl: null), CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Удаление типа заявки убирает его из базы")] [Fact(DisplayName = "Удаление типа заявки убирает его из базы")]
public async Task DeleteAsync_RemovesEntity() public async Task DeleteAsync_RemovesEntity()
{ {
@@ -15,21 +15,24 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002"); 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 static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly string _connectionString;
private readonly ServiceProvider _services; private readonly ServiceProvider _services;
private readonly TemplateRouteService _service; private readonly TemplateRouteService _service;
private readonly RequestTypeService _requestTypeService; private readonly RequestTypeService _requestTypeService;
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture) public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
{ {
_connectionString = fixture.ConnectionString;
var portalClient = new Mock<ITnsuPortalClient>(); var portalClient = new Mock<ITnsuPortalClient>();
portalClient portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId)); .ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Тестовая должность"));
portalClient portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>())) .Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]); .ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, portalClient: portalClient.Object); _services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
_service = _services.GetRequiredService<TemplateRouteService>(); _service = _services.GetRequiredService<TemplateRouteService>();
_requestTypeService = _services.GetRequiredService<RequestTypeService>(); _requestTypeService = _services.GetRequiredService<RequestTypeService>();
} }
@@ -106,6 +109,29 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
updated.Value.RequiresControl.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(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 = "Удаление маршрута убирает его из базы")] [Fact(DisplayName = "Удаление маршрута убирает его из базы")]
public async Task DeleteAsync_RemovesEntity() public async Task DeleteAsync_RemovesEntity()
{ {
@@ -137,7 +163,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -152,6 +178,56 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz"); 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"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "dup@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(2, "other@tnsu.kz", null, null, null, RouteStepStatus.Approval, false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(3, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.Count.ShouldBe(2);
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("dup@tnsu.kz");
resolved.Value.Steps[0].OrderNo.ShouldBe(1);
resolved.Value.Steps[1].ExecutorEmail.ShouldBe("other@tnsu.kz");
resolved.Value.Steps[1].OrderNo.ShouldBe(2);
}
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")] [Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail() public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{ {
@@ -164,7 +240,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
var stepService = _services.GetRequiredService<TemplateRouteStepService>(); var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync( await stepService.CreateAsync(
route.Value.Id, route.Value.Id,
new CreateTemplateRouteStepRequest(1, "", "Lead", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, null, null, ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -178,6 +254,119 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz"); 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"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, "Lead", null, null, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("lead@tnsu.kz");
resolved.Value.Steps[0].Position.ShouldBe("Lead Engineer");
}
[Fact(DisplayName = "Резолв шага с типом руководителя Chief подставляет email руководителя из api.tnsu.kz")]
public async Task ResolveAsync_ByHeadTypeChief_UsesManagerEmail()
{
// Arrange
var portalClient = new Mock<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"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.Chief, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("chief@tnsu.kz");
}
[Fact(DisplayName = "Резолв шага с типом руководителя DirectorTansu подставляет захардкоженный email из настроек")]
public async Task ResolveAsync_ByHeadTypeDirectorTansu_UsesConfiguredEmail()
{
// Arrange
var portalClient = new Mock<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"), CancellationToken.None)).Value.Id;
var route = await routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, null, null, null, HeadType.DirectorTansu, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await routeService.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("director.tansu@tnsu.kz");
portalClient.Verify(x => x.GetManagerAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")] [Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute() public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
{ {
@@ -42,7 +42,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
999999, 999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -58,13 +58,50 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var result = await _service.CreateAsync( var result = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
result.IsFailed.ShouldBeTrue(); 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);
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),
CancellationToken.None);
// Assert
duplicate.IsFailed.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),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.HeadType.ShouldBe(HeadType.Chief);
created.Value.ExecutorEmail.ShouldBeNull();
}
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")] [Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep() public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
{ {
@@ -74,7 +111,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -94,7 +131,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -112,7 +149,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
// Action // Action
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -127,14 +164,14 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to update step", byProject: false); var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Action // Action
var updated = await _service.UpdateAsync( var updated = await _service.UpdateAsync(
routeId, routeId,
created.Value.Id, created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Execution, true), new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", null, HeadType.Chief, RouteStepStatus.Execution, true),
CancellationToken.None); CancellationToken.None);
// Assert // Assert
@@ -144,6 +181,73 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue(); 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),
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),
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),
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),
CancellationToken.None);
// Assert
patched.IsFailed.ShouldBeTrue();
}
[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),
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),
CancellationToken.None);
// Assert
patched.IsSuccess.ShouldBeTrue();
patched.Value.ExecutorEmail.ShouldBe("a@b.kz");
}
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")] [Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
public async Task DeleteAsync_RemovesStep() public async Task DeleteAsync_RemovesStep()
{ {
@@ -151,7 +255,7 @@ public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDataba
var routeId = await CreateRouteAsync("Route to delete step", byProject: false); var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync( var created = await _service.CreateAsync(
routeId, routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false), new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", null, null, RouteStepStatus.Approval, false),
CancellationToken.None); CancellationToken.None);
// Action // Action
@@ -5,8 +5,6 @@ using Xunit;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
// Отдельная БД на один xUnit-collection (= один тестовый класс) в общем инстансе Postgres.
// Тесты внутри класса делят эту БД; разные классы (разные коллекции) получают разные БД.
public sealed class PostgresDatabaseFixture : IAsyncLifetime public sealed class PostgresDatabaseFixture : IAsyncLifetime
{ {
private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}"; private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}";
@@ -7,8 +7,6 @@ using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure; namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
// Собирает такой же DI-контейнер, как реальное приложение (те же extension-методы, что в Program.cs),
// но с тестовой БД, тестовым ICurrentUser и подменяемым (Moq) клиентом api.tnsu.kz.
internal static class TestServiceProviderFactory internal static class TestServiceProviderFactory
{ {
public static ServiceProvider Build( public static ServiceProvider Build(