diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs index ed829eb..8086a82 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Auth/DevAuthHandler.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Options; namespace Tnsu.ApprovalRoute.Api.Auth; -// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID). public sealed class DevAuthHandler( IOptionsMonitor options, ILoggerFactory logger, diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs index 4cef00c..65bb312 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/RequestTypesController.cs @@ -35,6 +35,13 @@ public sealed class RequestTypesController(RequestTypeService service) : Control public async Task Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); + [HttpPatch("{id:int}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + public async Task Patch(int id, PatchRequestTypeRequest body, CancellationToken ct) => + (await service.PatchAsync(id, body, ct)).ToActionResult(); + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs index a7e6771..0f74e73 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRouteStepsController.cs @@ -36,6 +36,14 @@ public sealed class TemplateRouteStepsController(TemplateRouteStepService servic public async Task Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) => (await service.UpdateAsync(routeId, id, body, ct)).ToActionResult(); + [HttpPatch("{id:int}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] + public async Task Patch(int routeId, int id, PatchTemplateRouteStepRequest body, CancellationToken ct) => + (await service.PatchAsync(routeId, id, body, ct)).ToActionResult(); + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs index 4a62d74..8b38e22 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Controllers/TemplateRoutesController.cs @@ -45,6 +45,14 @@ public sealed class TemplateRoutesController(TemplateRouteService service) : Con public async Task Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) => (await service.UpdateAsync(id, body, ct)).ToActionResult(); + [HttpPatch("{id:int}")] + [ProducesResponseType(typeof(ApiResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ApiError), StatusCodes.Status409Conflict)] + public async Task Patch(int id, PatchTemplateRouteRequest body, CancellationToken ct) => + (await service.PatchAsync(id, body, ct)).ToActionResult(); + [HttpDelete("{id:int}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)] diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs index f6cb0b3..d447132 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Filters/ValidationActionFilter.cs @@ -5,7 +5,6 @@ using Tnsu.ApprovalRoute.Api.Common; namespace Tnsu.ApprovalRoute.Api.Filters; -// Валидирует аргументы action'ов, для которых зарегистрирован IValidator — без валидации внутри сервисов. public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter { public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) diff --git a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs index dccf8c5..2e6474d 100644 --- a/backend/src/Tnsu.ApprovalRoute.Api/Program.cs +++ b/backend/src/Tnsu.ApprovalRoute.Api/Program.cs @@ -47,7 +47,6 @@ builder.Services.AddOpenApi(options => builder.Services.AddHealthChecks(); builder.Services.AddControllers(options => options.Filters.Add()) .AddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); -// Тот же конвертер для Http.Json — их читает генератор OpenAPI, чтобы enum в схеме были строками, а не числами. builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); var app = builder.Build(); @@ -56,8 +55,6 @@ if (app.Environment.IsDevelopment()) { app.MapOpenApi(); - // Scalar API reference UI — нативно понимает документ MapOpenApi (включая securitySchemes/security) - // и корректно прикрепляет Bearer-токен к запросам. app.MapScalarApiReference(options => { options diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs index 1a1b481..50cdce7 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeDto.cs @@ -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 UpdateRequestTypeRequest(string Name, string PageUrl); + +public sealed record PatchRequestTypeRequest(string? Name, string? PageUrl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs index df914ee..481c5d9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeService.cs @@ -49,6 +49,19 @@ public sealed class RequestTypeService(ApprovalRouteDbContext db) return Result.Ok(ToDto(entity)); } + public async Task> 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(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 DeleteAsync(int id, CancellationToken cancellationToken) { var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs index cfd5bf4..51946db 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/RequestTypes/RequestTypeValidators.cs @@ -19,3 +19,14 @@ public sealed class UpdateRequestTypeRequestValidator : AbstractValidator x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен."); } } + +public sealed class PatchRequestTypeRequestValidator : AbstractValidator +{ + 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 страницы не может быть пустым.")); + } +} diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs index 73eef71..c28cf0e 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepDto.cs @@ -31,3 +31,12 @@ public sealed record UpdateTemplateRouteStepRequest( HeadType HeadType, RouteStepStatus Status, bool AllowReassignToOtherApprovers); + +public sealed record PatchTemplateRouteStepRequest( + int? OrderNo, + string? ExecutorEmail, + string? Position, + ProjectRole? ProjectRole, + HeadType? HeadType, + RouteStepStatus? Status, + bool? AllowReassignToOtherApprovers); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs index 2bc5267..be18cb8 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRouteSteps/TemplateRouteStepService.cs @@ -103,6 +103,50 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken)); } + public async Task> 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(new NotFoundError("Маршрут", routeId)); + + var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); + if (entity is null) + return Result.Fail(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 != ProjectRole.None) + return Result.Fail(new ValidationError( + "Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false).")); + + if (string.IsNullOrWhiteSpace(executorEmail) && string.IsNullOrWhiteSpace(position) && + projectRole == ProjectRole.None && headType == HeadType.None) + return Result.Fail(new ValidationError( + "Должно быть заполнено хотя бы одно из полей: исполнитель, должность, роль в проекте или тип руководителя.")); + + var duplicate = await HasDuplicateAsync( + route.Id, executorEmail, position, projectRole, headType, status, excludeId: entity.Id, cancellationToken); + if (duplicate) + return Result.Fail(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 DeleteAsync(int routeId, int id, CancellationToken cancellationToken) { var entity = await db.TemplateRouteSteps.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken); @@ -128,7 +172,6 @@ public sealed class TemplateRouteStepService(ApprovalRouteDbContext db) x.Status == status, cancellationToken); - // Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning. private async Task ToDtoWithWarningAsync( TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken) { diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs index 1b1bd1b..6992701 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteDto.cs @@ -25,3 +25,10 @@ public sealed record UpdateTemplateRouteRequest( Guid CompanyId, bool ByProject, bool RequiresControl); + +public sealed record PatchTemplateRouteRequest( + RouteType? RouteType, + Guid? DepartmentId, + Guid? CompanyId, + bool? ByProject, + bool? RequiresControl); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 94167ca..1ca4a9a 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -84,6 +84,31 @@ public sealed class TemplateRouteService( return Result.Ok(ToDto(entity)); } + public async Task> 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(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(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 DeleteAsync(int id, CancellationToken cancellationToken) { var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); @@ -162,8 +187,6 @@ public sealed class TemplateRouteService( private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList projectExecutors) { - // Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz); - // должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона. if (step.ProjectRole != ProjectRole.None) { var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole); diff --git a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs index 0088486..23019e4 100644 --- a/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs +++ b/backend/src/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteValidators.cs @@ -19,6 +19,15 @@ public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator +{ + public PatchTemplateRouteRequestValidator() + { + When(x => x.CompanyId is not null, () => + RuleFor(x => x.CompanyId!.Value).NotEqual(Guid.Empty).WithMessage("Компания обязательна.")); + } +} + public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator { public ResolveTemplateRouteRequestValidator() diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs index bf50e4d..9319917 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRoute.cs @@ -8,8 +8,6 @@ public class TemplateRoute public RouteType RouteType { get; set; } = RouteType.Sequential; public int RequestTypeId { get; set; } - // Id компании/отдела инициатора из 1С/AD (api-tnsu-portal /Employee/ByEmail) — без FK в этой БД. - // Отдел опционален, компания обязательна. public Guid? DepartmentId { get; set; } public Guid CompanyId { get; set; } public bool ByProject { get; set; } diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs index 9eff50c..75305c9 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Entities/TemplateRouteStep.cs @@ -7,7 +7,6 @@ public class TemplateRouteStep public int Id { get; set; } public int OrderNo { get; set; } - // Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users. public string ExecutorEmail { get; set; } = string.Empty; public string Position { get; set; } = string.Empty; public ProjectRole ProjectRole { get; set; } = ProjectRole.None; diff --git a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs index a9c7afd..3d4bfcd 100644 --- a/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs +++ b/backend/src/Tnsu.ApprovalRoute.Domain/Enums/ProjectRole.cs @@ -1,7 +1,5 @@ namespace Tnsu.ApprovalRoute.Domain.Enums; -// Роли в проекте — соответствуют именованным полям проекта в api-tnsu-portal -// (project_manager / planner / manager_hse / security). public enum ProjectRole { None, diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs index 81b0848..17b73e1 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/HttpTnsuPortalClient.cs @@ -8,14 +8,11 @@ using Tnsu.ApprovalRoute.Domain.Enums; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -// Клиент api.tnsu.kz (api-tnsu-portal). public sealed class HttpTnsuPortalClient( HttpClient httpClient, TnsuPortalTokenProvider tokenProvider, IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient { - // Разные эндпоинты api-tnsu-portal отдают JSON в разном регистре (Dapper-результаты — PascalCase, - // строго типизированные модели — camelCase), поэтому сопоставляем поля без учёта регистра. private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; public async Task GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken) @@ -49,7 +46,6 @@ public sealed class HttpTnsuPortalClient( if (project is null) return []; - // Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security). var executors = new List(); AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager); AddIfPresent(executors, ProjectRole.Planner, project.Planner); @@ -65,9 +61,6 @@ public sealed class HttpTnsuPortalClient( 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) { var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString(); diff --git a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs index 6f7dfd4..2d5dcac 100644 --- a/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs +++ b/backend/src/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalTokenProvider.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Options; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; -// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя. public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions options) { private string? _cachedToken; diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs index a14b282..30ff46f 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/RequestTypeServiceTests.cs @@ -66,6 +66,33 @@ public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : I 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 = "Удаление типа заявки убирает его из базы")] public async Task DeleteAsync_RemovesEntity() { diff --git a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs index 1c49452..165947b 100644 --- a/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs +++ b/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/TemplateRouteServiceTests.cs @@ -106,6 +106,29 @@ public sealed class TemplateRouteServiceTests : IClassFixture