33 Commits

Author SHA1 Message Date
gorbenko.pavel 3ae1965c29 перешли на гуид
Tests / test (pull_request) Successful in 1m41s
2026-07-24 16:53:35 +05:00
gorbenko.pavel a125ed2834 передача токена 2026-07-24 15:33:54 +05:00
gorbenko.pavel 40e7974244 DirectorTansu 2026-07-24 14:27:21 +05:00
gorbenko.pavel 00f00a4c3a теперь массив ошибок 2026-07-24 14:18:16 +05:00
gorbenko.pavel 06cab46efc warnings из общего тела убрано 2026-07-24 14:05:37 +05:00
gorbenko.pavel 5e3ef814f3 перешли на int 2026-07-24 13:57:30 +05:00
gorbenko.pavel 5e45c18817 enum в api строки 2026-07-24 12:58:59 +05:00
gorbenko.pavel 97ca6c284e переход на скаляр
Tests / test (pull_request) Successful in 3m8s
2026-07-24 12:33:09 +05:00
gorbenko.pavel fa13e93190 переход на реальное api 2026-07-24 12:27:42 +05:00
gorbenko.pavel 2759c1ef7d main master
Tests / test (pull_request) Successful in 1m49s
2026-07-23 18:02:18 +05:00
gorbenko.pavel a1b83bcab2 примари конструктор
Tests / test (pull_request) Successful in 3m29s
2026-07-23 17:39:08 +05:00
gorbenko.pavel 0bf5475553 тесты причесаны
Tests / test (pull_request) Successful in 3m31s
2026-07-23 17:38:14 +05:00
gorbenko.pavel e201823f69 основное по тестам готово
Tests / test (pull_request) Successful in 4m5s
2026-07-23 17:21:30 +05:00
gorbenko.pavel 253fafa575 переход на fluentValidation 2026-07-23 16:58:18 +05:00
gorbenko.pavel 7cce18e67b отказ от mediatr 2026-07-23 16:45:18 +05:00
gorbenko.pavel 0ddb9f0ba6 удалил ожидание postgres 2026-07-23 16:31:52 +05:00
gorbenko.pavel c36902a80f без systemd
Tests / test (pull_request) Successful in 7m26s
2026-07-23 16:09:02 +05:00
gorbenko.pavel 9921c1f4b3 еще попытка
Tests / test (pull_request) Failing after 1m15s
2026-07-23 16:06:11 +05:00
gorbenko.pavel b496fcdcdd поменял порядок
Tests / test (pull_request) Failing after 1m42s
2026-07-23 16:00:40 +05:00
gorbenko.pavel c3b4798703 возвращен dotnet
Tests / test (pull_request) Has been cancelled
2026-07-23 15:49:26 +05:00
gorbenko.pavel 369354f0c9 Wait for Postgres
Tests / test (pull_request) Failing after 4s
2026-07-23 15:46:36 +05:00
gorbenko.pavel 7f378ad632 пробуем
Tests / test (pull_request) Failing after 3m3s
2026-07-23 15:39:20 +05:00
gorbenko.pavel 3c3e7caf81 правочки 2026-07-23 15:36:52 +05:00
gorbenko.pavel a0afac6e07 недостающий тест
Tests / test (pull_request) Failing after 3m14s
2026-07-23 15:30:04 +05:00
gorbenko.pavel 9b27f328a7 тест вроде готов
Tests / test (pull_request) Successful in 1m51s
2026-07-23 15:26:06 +05:00
gorbenko.pavel 436c8ce7d4 ланч сеттингс добавлен 2026-07-23 14:55:04 +05:00
gorbenko.pavel 52d3325f26 вроде как свагер готов 2026-07-23 14:47:23 +05:00
gorbenko.pavel f29fee8339 маршрут по данным пользователя 2026-07-23 14:38:16 +05:00
gorbenko.pavel 409093c444 клиент для tansu 2026-07-23 14:28:48 +05:00
gorbenko.pavel f2a448d985 авторизация готова 2026-07-23 14:14:44 +05:00
gorbenko.pavel 1a5ed34182 основной круд готов- 2026-07-23 13:55:53 +05:00
gorbenko.pavel 719cd81391 изменения в модели и миграции 2026-07-23 12:50:31 +05:00
gorbenko.pavel ccba53cc94 переименование 2026-07-23 12:36:56 +05:00
58 changed files with 1962 additions and 125 deletions
+40
View File
@@ -0,0 +1,40 @@
name: Tests
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Install PostgreSQL
run: |
sudo apt-get update
sudo apt-get install -y postgresql
sudo pg_ctlcluster 16 main start
sudo -u postgres psql -c "CREATE USER tnsu_approval_route WITH PASSWORD 'tnsu_approval_route' SUPERUSER;"
sudo -u postgres psql -c "CREATE DATABASE tnsu_approval_route OWNER tnsu_approval_route;"
- name: Restore
run: dotnet restore backend/Tnsu.ApprovalRoute.sln
- name: Build
run: dotnet build backend/Tnsu.ApprovalRoute.sln --no-restore -c Release
- name: Test
env:
TEST_POSTGRES_CONNECTION: "Host=localhost;Port=5432;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
run: dotnet test backend/Tnsu.ApprovalRoute.sln --no-build -c Release
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="UserContentModel"> <component name="UserContentModel">
<attachedFolders /> <attachedFolders>
<Path>../.gitea/workflows</Path>
</attachedFolders>
<explicitIncludes /> <explicitIncludes />
<explicitExcludes /> <explicitExcludes />
</component> </component>
+13 -1
View File
@@ -5,12 +5,24 @@
<ItemGroup> <ItemGroup>
<PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" /> <PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
<PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" /> <PackageVersion Include="Extensions.Hosting.AsyncInitialization" Version="3.0.1" />
<PackageVersion Include="FluentResults" Version="4.0.0" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" /> <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="MediatR" Version="14.2.0" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+17
View File
@@ -13,6 +13,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Infrastr
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.Api", "src\Tnsu.ApprovalRoute.Api\Tnsu.ApprovalRoute.Api.csproj", "{4697B342-BE47-435B-B854-75F9277AD769}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tnsu.ApprovalRoute.IntegrationTests", "tests\Tnsu.ApprovalRoute.IntegrationTests\Tnsu.ApprovalRoute.IntegrationTests.csproj", "{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@@ -71,6 +75,18 @@ Global
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU {4697B342-BE47-435B-B854-75F9277AD769}.Release|x64.Build.0 = Release|Any CPU
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU {4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.ActiveCfg = Release|Any CPU
{4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU {4697B342-BE47-435B-B854-75F9277AD769}.Release|x86.Build.0 = Release|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.ActiveCfg = Debug|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x64.Build.0 = Debug|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.ActiveCfg = Debug|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Debug|x86.Build.0 = Debug|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|Any CPU.Build.0 = Release|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.ActiveCfg = Release|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x64.Build.0 = Release|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.ActiveCfg = Release|Any CPU
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -80,5 +96,6 @@ Global
{2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {2EEA333B-16C7-460E-BD07-AB1F32D6A9A9} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {46EF4247-8B98-4CC9-A981-186C8D24163C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {4697B342-BE47-435B-B854-75F9277AD769} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{F5E2BA0E-7978-414C-98D4-D3C91C162FDE} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal
@@ -0,0 +1,6 @@
namespace Tnsu.ApprovalRoute.Api.Auth;
public static class AuthSchemes
{
public const string Dev = "Dev";
}
@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
namespace Tnsu.ApprovalRoute.Api.Auth;
public static class AuthSetup
{
public static IServiceCollection AddApprovalRouteAuth(this IServiceCollection services, IConfiguration configuration)
{
var tenantId = configuration["Entra:TenantId"];
var audience = configuration["Entra:Audience"];
if (!string.IsNullOrWhiteSpace(tenantId) && !string.IsNullOrWhiteSpace(audience))
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidIssuers =
[
$"https://login.microsoftonline.com/{tenantId}/v2.0",
$"https://sts.windows.net/{tenantId}/"
],
ValidAudiences = [audience, $"api://{audience}"]
};
});
}
else
{
services.AddAuthentication(AuthSchemes.Dev)
.AddScheme<AuthenticationSchemeOptions, DevAuthHandler>(AuthSchemes.Dev, _ => { });
}
services.AddAuthorization();
return services;
}
}
@@ -0,0 +1,11 @@
using System.Security.Claims;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
namespace Tnsu.ApprovalRoute.Api.Auth;
public sealed class CurrentUserAccessor(IHttpContextAccessor httpContextAccessor) : ICurrentUser
{
public string? Email =>
httpContextAccessor.HttpContext?.User.FindFirstValue(ClaimTypes.Email)
?? httpContextAccessor.HttpContext?.User.FindFirstValue("preferred_username");
}
@@ -0,0 +1,27 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Api.Auth;
// Используется только когда Entra:TenantId/Entra:Audience не заданы (локальная разработка без реального Entra ID).
public sealed class DevAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var email = Request.Headers["X-Dev-User-Email"].FirstOrDefault()
?? configuration["Dev:DefaultUserEmail"]
?? "dev@tnsu.kz";
var identity = new ClaimsIdentity([new Claim(ClaimTypes.Email, email)], AuthSchemes.Dev);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthSchemes.Dev);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -0,0 +1,5 @@
namespace Tnsu.ApprovalRoute.Api.Common;
public sealed record ApiResponse<T>(T Data);
public sealed record ApiError(IReadOnlyList<string> Messages);
@@ -0,0 +1,32 @@
using FluentResults;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Application.Common.Errors;
namespace Tnsu.ApprovalRoute.Api.Common;
public static class ResultExtensions
{
public static IActionResult ToActionResult<T>(this Result<T> result)
{
if (result.IsFailed)
return result.Errors[0].ToActionResult();
return new OkObjectResult(new ApiResponse<T>(result.Value));
}
public static IActionResult ToActionResult(this Result result)
{
if (result.IsFailed)
return result.Errors[0].ToActionResult();
return new NoContentResult();
}
private static IActionResult ToActionResult(this IError error) => error switch
{
NotFoundError => new NotFoundObjectResult(new ApiError([error.Message])),
ValidationError => new BadRequestObjectResult(new ApiError([error.Message])),
ConflictError => new ConflictObjectResult(new ApiError([error.Message])),
_ => new ObjectResult(new ApiError([error.Message])) { StatusCode = StatusCodes.Status500InternalServerError }
};
}
@@ -0,0 +1,43 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.RequestTypes;
namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/request-types")]
[Produces("application/json")]
public sealed class RequestTypesController(RequestTypeService service) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<RequestTypeDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(CancellationToken ct) =>
(await service.ListAsync(ct)).ToActionResult();
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(int id, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToActionResult();
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(CreateRequestTypeRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<RequestTypeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int id, UpdateRequestTypeRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
(await service.DeleteAsync(id, ct)).ToActionResult();
}
@@ -0,0 +1,44 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/template-routes/{routeId:int}/steps")]
[Produces("application/json")]
public sealed class TemplateRouteStepsController(TemplateRouteStepService service) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteStepDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(int routeId, CancellationToken ct) =>
(await service.ListAsync(routeId, ct)).ToActionResult();
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(int routeId, int id, CancellationToken ct) =>
(await service.GetAsync(routeId, id, ct)).ToActionResult();
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Create(int routeId, CreateTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.CreateAsync(routeId, body, ct)).ToActionResult();
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteStepDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int routeId, int id, UpdateTemplateRouteStepRequest body, CancellationToken ct) =>
(await service.UpdateAsync(routeId, id, body, ct)).ToActionResult();
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int routeId, int id, CancellationToken ct) =>
(await service.DeleteAsync(routeId, id, ct)).ToActionResult();
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Tnsu.ApprovalRoute.Api.Common;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
namespace Tnsu.ApprovalRoute.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/template-routes")]
[Produces("application/json")]
public sealed class TemplateRoutesController(TemplateRouteService service) : ControllerBase
{
[HttpGet("resolve")]
[ProducesResponseType(typeof(ApiResponse<ResolveTemplateRouteResultDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Resolve([FromQuery] ResolveTemplateRouteRequest request, CancellationToken ct) =>
(await service.ResolveAsync(request, ct)).ToActionResult();
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<IReadOnlyList<TemplateRouteDto>>), StatusCodes.Status200OK)]
public async Task<IActionResult> List([FromQuery] int? requestTypeId, CancellationToken ct) =>
(await service.ListAsync(requestTypeId, ct)).ToActionResult();
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ApiResponse<TemplateRouteDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Get(int id, CancellationToken ct) =>
(await service.GetAsync(id, ct)).ToActionResult();
[HttpPost]
[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> Create(CreateTemplateRouteRequest body, CancellationToken ct) =>
(await service.CreateAsync(body, ct)).ToActionResult();
[HttpPut("{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> Update(int id, UpdateTemplateRouteRequest body, CancellationToken ct) =>
(await service.UpdateAsync(id, body, ct)).ToActionResult();
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(typeof(ApiError), StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id, CancellationToken ct) =>
(await service.DeleteAsync(id, ct)).ToActionResult();
}
@@ -0,0 +1,34 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Tnsu.ApprovalRoute.Api.Common;
namespace Tnsu.ApprovalRoute.Api.Filters;
// Валидирует аргументы action'ов, для которых зарегистрирован IValidator<T> — без валидации внутри сервисов.
public sealed class ValidationActionFilter(IServiceProvider serviceProvider) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
foreach (var argument in context.ActionArguments.Values)
{
if (argument is null)
continue;
var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType());
if (serviceProvider.GetService(validatorType) is not IValidator validator)
continue;
var validationContext = new ValidationContext<object>(argument);
var result = await validator.ValidateAsync(validationContext, context.HttpContext.RequestAborted);
if (result.IsValid)
continue;
context.Result = new BadRequestObjectResult(
new ApiError(result.Errors.Select(e => e.ErrorMessage).ToList()));
return;
}
await next();
}
}
+56 -1
View File
@@ -1,24 +1,79 @@
using System.Text.Json.Serialization;
using Extensions.Hosting.AsyncInitialization; using Extensions.Hosting.AsyncInitialization;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi;
using Scalar.AspNetCore;
using Tnsu.ApprovalRoute.Api.Auth;
using Tnsu.ApprovalRoute.Api.Filters;
using Tnsu.ApprovalRoute.Application; using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure; using Tnsu.ApprovalRoute.Infrastructure;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApprovalRouteInfrastructure(builder.Configuration); builder.Services.AddApprovalRouteInfrastructure(builder.Configuration);
builder.Services.AddApprovalRouteApplication(); builder.Services.AddApprovalRouteApplication();
builder.Services.AddOpenApi(); builder.Services.AddApprovalRouteAuth(builder.Configuration);
builder.Services.AddScoped<ICurrentUser, CurrentUserAccessor>();
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Введите JWT токен"
};
return Task.CompletedTask;
});
options.AddOperationTransformer((operation, context, _) =>
{
operation.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", context.Document)] = []
}
];
return Task.CompletedTask;
});
});
builder.Services.AddHealthChecks(); builder.Services.AddHealthChecks();
builder.Services.AddControllers(options => options.Filters.Add<ValidationActionFilter>())
.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(); var app = builder.Build();
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); app.MapOpenApi();
// Scalar API reference UI — нативно понимает документ MapOpenApi (включая securitySchemes/security)
// и корректно прикрепляет Bearer-токен к запросам.
app.MapScalarApiReference(options =>
{
options
.WithTitle("Tnsu Approval Route")
.AddPreferredSecuritySchemes("Bearer")
.AddHttpAuthentication("Bearer", _ => { });
});
}
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await app.InitAndRunAsync(); await app.InitAndRunAsync();
public partial class Program; public partial class Program;
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -7,10 +7,10 @@
<PackageReference Include="Extensions.Hosting.AsyncInitialization" /> <PackageReference Include="Extensions.Hosting.AsyncInitialization" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="MediatR" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Scalar.AspNetCore" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -7,5 +7,19 @@
}, },
"ConnectionStrings": { "ConnectionStrings": {
"Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route" "Postgres": "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route"
},
"Entra": {
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"Audience": "9232d9e2-9807-4753-808f-07250e5474b0"
},
"Dev": {
"DefaultUserEmail": "dev@tnsu.kz"
},
"TnsuPortal": {
"BaseUrl": "https://api.tnsu.kz",
"TenantId": "",
"ClientId": "",
"ClientSecret": "",
"Scope": ""
} }
} }
@@ -1,6 +1,9 @@
using System.Reflection; using System.Reflection;
using MediatR; using FluentValidation;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
namespace Tnsu.ApprovalRoute.Application; namespace Tnsu.ApprovalRoute.Application;
@@ -8,7 +11,12 @@ public static class ApplicationServiceCollectionExtensions
{ {
public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services) public static IServiceCollection AddApprovalRouteApplication(this IServiceCollection services)
{ {
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly())); services.AddScoped<RequestTypeService>();
services.AddScoped<TemplateRouteService>();
services.AddScoped<TemplateRouteStepService>();
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
return services; return services;
} }
} }
@@ -0,0 +1,10 @@
using FluentResults;
namespace Tnsu.ApprovalRoute.Application.Common.Errors;
public sealed class NotFoundError(string entity, object id)
: Error($"{entity} с id {id} не найден.");
public sealed class ValidationError(string message) : Error(message);
public sealed class ConflictError(string message) : Error(message);
@@ -0,0 +1,6 @@
namespace Tnsu.ApprovalRoute.Application.Common.Interfaces;
public interface ICurrentUser
{
string? Email { get; }
}
@@ -0,0 +1,7 @@
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
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);
@@ -0,0 +1,65 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed class RequestTypeService(ApprovalRouteDbContext db)
{
public async Task<Result<IReadOnlyList<RequestTypeDto>>> ListAsync(CancellationToken cancellationToken)
{
var items = await db.RequestTypes.AsNoTracking()
.OrderBy(x => x.Name)
.Select(x => new RequestTypeDto(x.Id, x.Name, x.PageUrl))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<RequestTypeDto>>(items);
}
public async Task<Result<RequestTypeDto>> GetAsync(int id, CancellationToken cancellationToken)
{
var entity = await db.RequestTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return entity is null
? Result.Fail<RequestTypeDto>(new NotFoundError("Тип заявки", id))
: Result.Ok(ToDto(entity));
}
public async Task<Result<RequestTypeDto>> CreateAsync(CreateRequestTypeRequest request, CancellationToken cancellationToken)
{
var entity = new RequestType { Name = request.Name, PageUrl = request.PageUrl };
db.RequestTypes.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result<RequestTypeDto>> UpdateAsync(int id, UpdateRequestTypeRequest 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.PageUrl = request.PageUrl;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
{
var entity = await db.RequestTypes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null)
return Result.Fail(new NotFoundError("Тип заявки", id));
db.RequestTypes.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
private static RequestTypeDto ToDto(RequestType e) => new(e.Id, e.Name, e.PageUrl);
}
@@ -0,0 +1,21 @@
using FluentValidation;
namespace Tnsu.ApprovalRoute.Application.RequestTypes;
public sealed class CreateRequestTypeRequestValidator : AbstractValidator<CreateRequestTypeRequest>
{
public CreateRequestTypeRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
}
}
public sealed class UpdateRequestTypeRequestValidator : AbstractValidator<UpdateRequestTypeRequest>
{
public UpdateRequestTypeRequestValidator()
{
RuleFor(x => x.Name).NotEmpty().WithMessage("Имя типа заявки обязательно.");
RuleFor(x => x.PageUrl).NotEmpty().WithMessage("Url страницы обязателен.");
}
}
@@ -0,0 +1,33 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed record TemplateRouteStepDto(
int Id,
int OrderNo,
string ExecutorEmail,
string Position,
ProjectRole ProjectRole,
HeadType HeadType,
RouteStepStatus Status,
int RouteId,
bool AllowReassignToOtherApprovers,
string? Warning = null);
public sealed record CreateTemplateRouteStepRequest(
int OrderNo,
string ExecutorEmail,
string Position,
ProjectRole ProjectRole,
HeadType HeadType,
RouteStepStatus Status,
bool AllowReassignToOtherApprovers);
public sealed record UpdateTemplateRouteStepRequest(
int OrderNo,
string ExecutorEmail,
string Position,
ProjectRole ProjectRole,
HeadType HeadType,
RouteStepStatus Status,
bool AllowReassignToOtherApprovers);
@@ -0,0 +1,118 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed class TemplateRouteStepService(ApprovalRouteDbContext db)
{
public async Task<Result<IReadOnlyList<TemplateRouteStepDto>>> ListAsync(int routeId, CancellationToken cancellationToken)
{
var items = await db.TemplateRouteSteps.AsNoTracking()
.Where(x => x.RouteId == routeId)
.OrderBy(x => x.OrderNo)
.Select(x => new TemplateRouteStepDto(
x.Id, x.OrderNo, x.ExecutorEmail, x.Position, x.ProjectRole, x.HeadType, x.Status, x.RouteId, x.AllowReassignToOtherApprovers))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteStepDto>>(items);
}
public async Task<Result<TemplateRouteStepDto>> GetAsync(int routeId, int id, CancellationToken cancellationToken)
{
var entity = await db.TemplateRouteSteps.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == id && x.RouteId == routeId, cancellationToken);
return entity is null
? Result.Fail<TemplateRouteStepDto>(new NotFoundError("Шаг маршрута", id))
: Result.Ok(ToDto(entity));
}
public async Task<Result<TemplateRouteStepDto>> CreateAsync(int routeId, CreateTemplateRouteStepRequest 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));
if (!route.ByProject && r.ProjectRole != ProjectRole.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
var entity = new TemplateRouteStep
{
RouteId = route.Id,
OrderNo = r.OrderNo,
ExecutorEmail = r.ExecutorEmail,
Position = r.Position,
ProjectRole = r.ProjectRole,
HeadType = r.HeadType,
Status = r.Status,
AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers
};
db.TemplateRouteSteps.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, cancellationToken));
}
public async Task<Result<TemplateRouteStepDto>> UpdateAsync(
int routeId, int id, UpdateTemplateRouteStepRequest 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));
if (!route.ByProject && r.ProjectRole != ProjectRole.None)
return Result.Fail<TemplateRouteStepDto>(new ValidationError(
"Роль в проекте недопустима для шага маршрута, который не учитывает проект (by_project = false)."));
entity.OrderNo = r.OrderNo;
entity.ExecutorEmail = r.ExecutorEmail;
entity.Position = r.Position;
entity.ProjectRole = r.ProjectRole;
entity.HeadType = r.HeadType;
entity.Status = r.Status;
entity.AllowReassignToOtherApprovers = r.AllowReassignToOtherApprovers;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(await ToDtoWithWarningAsync(entity, route, 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);
if (entity is null)
return Result.Fail(new NotFoundError("Шаг маршрута", id));
db.TemplateRouteSteps.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
// Для маршрута «по проекту» без единого шага с ролью в проекте DTO несёт предупреждение в поле Warning.
private async Task<TemplateRouteStepDto> ToDtoWithWarningAsync(
TemplateRouteStep entity, TemplateRoute route, CancellationToken cancellationToken)
{
string? warning = null;
if (route.ByProject)
{
var hasProjectRoleStep = await db.TemplateRouteSteps
.AnyAsync(x => x.RouteId == route.Id && x.ProjectRole != ProjectRole.None, cancellationToken);
if (!hasProjectRoleStep)
warning = "Должен быть хотя бы один шаг с ролью в проекте.";
}
return ToDto(entity) with { Warning = warning };
}
private static TemplateRouteStepDto ToDto(TemplateRouteStep e) => new(
e.Id, e.OrderNo, e.ExecutorEmail, e.Position, e.ProjectRole, e.HeadType, e.Status, e.RouteId, e.AllowReassignToOtherApprovers);
}
@@ -0,0 +1,24 @@
using FluentValidation;
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
public sealed class CreateTemplateRouteStepRequestValidator : AbstractValidator<CreateTemplateRouteStepRequest>
{
public CreateTemplateRouteStepRequestValidator()
{
RuleFor(x => x)
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None))
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
}
}
public sealed class UpdateTemplateRouteStepRequestValidator : AbstractValidator<UpdateTemplateRouteStepRequest>
{
public UpdateTemplateRouteStepRequestValidator()
{
RuleFor(x => x)
.Must(x => !(string.IsNullOrWhiteSpace(x.ExecutorEmail) && string.IsNullOrWhiteSpace(x.Position) && x.ProjectRole == ProjectRole.None))
.WithMessage("Исполнитель, должность и роль в проекте не могут быть пустыми одновременно.");
}
}
@@ -0,0 +1,25 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record ResolvedRouteStepDto(
int Id,
int OrderNo,
string ExecutorEmail,
string Position,
ProjectRole ProjectRole,
HeadType HeadType,
RouteStepStatus Status,
bool AllowReassignToOtherApprovers);
public sealed record ResolveTemplateRouteResultDto(
int RouteId,
RouteType RouteType,
bool RequiresControl,
IReadOnlyList<ResolvedRouteStepDto> Steps);
public sealed record ResolveTemplateRouteRequest(
int RequestTypeId,
string? UserEmail,
Guid? ProjectId,
RouteStepStatus Status);
@@ -0,0 +1,27 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed record TemplateRouteDto(
int Id,
RouteType RouteType,
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record CreateTemplateRouteRequest(
RouteType RouteType,
int RequestTypeId,
Guid? DepartmentId,
Guid CompanyId,
bool ByProject,
bool RequiresControl);
public sealed record UpdateTemplateRouteRequest(
RouteType RouteType,
Guid? DepartmentId,
Guid CompanyId,
bool ByProject,
bool RequiresControl);
@@ -0,0 +1,183 @@
using FluentResults;
using Microsoft.EntityFrameworkCore;
using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class TemplateRouteService(
ApprovalRouteDbContext db,
ITnsuPortalClient portalClient,
ICurrentUser currentUser)
{
public async Task<Result<IReadOnlyList<TemplateRouteDto>>> ListAsync(int? requestTypeId, CancellationToken cancellationToken)
{
var query = db.TemplateRoutes.AsNoTracking().AsQueryable();
if (requestTypeId is { } id)
query = query.Where(x => x.RequestTypeId == id);
var items = await query
.Select(x => new TemplateRouteDto(
x.Id, x.RouteType, x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject, x.RequiresControl))
.ToListAsync(cancellationToken);
return Result.Ok<IReadOnlyList<TemplateRouteDto>>(items);
}
public async Task<Result<TemplateRouteDto>> GetAsync(int id, CancellationToken cancellationToken)
{
var entity = await db.TemplateRoutes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
return entity is null
? Result.Fail<TemplateRouteDto>(new NotFoundError("Маршрут", id))
: Result.Ok(ToDto(entity));
}
public async Task<Result<TemplateRouteDto>> CreateAsync(CreateTemplateRouteRequest r, CancellationToken cancellationToken)
{
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == r.RequestTypeId, cancellationToken);
if (!requestTypeExists)
return Result.Fail<TemplateRouteDto>(new NotFoundError("Тип заявки", r.RequestTypeId));
var duplicate = await HasDuplicateAsync(r.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: null, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
var entity = new TemplateRoute
{
RouteType = r.RouteType,
RequestTypeId = r.RequestTypeId,
DepartmentId = r.DepartmentId,
CompanyId = r.CompanyId,
ByProject = r.ByProject,
RequiresControl = r.RequiresControl
};
db.TemplateRoutes.Add(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result<TemplateRouteDto>> UpdateAsync(int id, UpdateTemplateRouteRequest 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 duplicate = await HasDuplicateAsync(entity.RequestTypeId, r.DepartmentId, r.CompanyId, r.ByProject, excludeId: entity.Id, cancellationToken);
if (duplicate)
return Result.Fail<TemplateRouteDto>(new ConflictError(
"Маршрут с таким типом заявки, отделом, компанией и признаком «по проекту» уже существует."));
entity.RouteType = r.RouteType;
entity.DepartmentId = r.DepartmentId;
entity.CompanyId = r.CompanyId;
entity.ByProject = r.ByProject;
entity.RequiresControl = r.RequiresControl;
await db.SaveChangesAsync(cancellationToken);
return Result.Ok(ToDto(entity));
}
public async Task<Result> DeleteAsync(int id, CancellationToken cancellationToken)
{
var entity = await db.TemplateRoutes.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null)
return Result.Fail(new NotFoundError("Маршрут", id));
db.TemplateRoutes.Remove(entity);
await db.SaveChangesAsync(cancellationToken);
return Result.Ok();
}
public async Task<Result<ResolveTemplateRouteResultDto>> ResolveAsync(
ResolveTemplateRouteRequest request, CancellationToken cancellationToken)
{
var email = string.IsNullOrWhiteSpace(request.UserEmail) ? currentUser.Email : request.UserEmail;
if (string.IsNullOrWhiteSpace(email))
return Result.Fail<ResolveTemplateRouteResultDto>(
new ValidationError("Не удалось определить email инициатора: не передан явно и отсутствует в токене."));
var requestTypeExists = await db.RequestTypes.AnyAsync(x => x.Id == request.RequestTypeId, cancellationToken);
if (!requestTypeExists)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError("Тип заявки", request.RequestTypeId));
var employee = await portalClient.GetEmployeeByEmailAsync(email, cancellationToken);
if (employee is null)
return Result.Fail<ResolveTemplateRouteResultDto>(
new NotFoundError("Сотрудник в справочнике api.tnsu.kz по email", email));
var byProject = request.ProjectId is not null;
var route = await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == employee.DepartmentId)
.FirstOrDefaultAsync(cancellationToken)
?? await db.TemplateRoutes.AsNoTracking()
.Include(x => x.Steps)
.Where(x =>
x.RequestTypeId == request.RequestTypeId &&
x.CompanyId == employee.CompanyId &&
x.ByProject == byProject &&
x.DepartmentId == null)
.FirstOrDefaultAsync(cancellationToken);
if (route is null)
return Result.Fail<ResolveTemplateRouteResultDto>(new NotFoundError(
"Маршрут для этого типа заявки, отдела/компании инициатора и признака «по проекту»",
request.RequestTypeId));
IReadOnlyList<ProjectExecutorInfo> projectExecutors = [];
if (request.ProjectId is { } projectId)
projectExecutors = await portalClient.GetProjectExecutorsAsync(projectId, cancellationToken);
var steps = route.Steps
.Where(s => s.Status == request.Status)
.OrderBy(s => s.OrderNo)
.Select(s => ResolveStep(s, projectExecutors))
.ToList();
return Result.Ok(new ResolveTemplateRouteResultDto(route.Id, route.RouteType, route.RequiresControl, steps));
}
private Task<bool> HasDuplicateAsync(
int requestTypeId, Guid? departmentId, Guid companyId, bool byProject, int? excludeId, CancellationToken cancellationToken) =>
db.TemplateRoutes.AnyAsync(x =>
x.Id != excludeId &&
x.RequestTypeId == requestTypeId &&
x.DepartmentId == departmentId &&
x.CompanyId == companyId &&
x.ByProject == byProject,
cancellationToken);
private static ResolvedRouteStepDto ResolveStep(TemplateRouteStep step, IReadOnlyList<ProjectExecutorInfo> projectExecutors)
{
// Для шагов с ролью в проекте фактический исполнитель (email) приходит из данных проекта (api.tnsu.kz);
// должность берётся из шаблона, т.к. проект её не отдаёт. Для шагов без роли — статичный ExecutorEmail шаблона.
if (step.ProjectRole != ProjectRole.None)
{
var projectExecutor = projectExecutors.FirstOrDefault(e => e.ProjectRole == step.ProjectRole);
if (projectExecutor is not null)
return new ResolvedRouteStepDto(
step.Id, step.OrderNo, projectExecutor.Email, step.Position,
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
}
return new ResolvedRouteStepDto(
step.Id, step.OrderNo, step.ExecutorEmail, step.Position,
step.ProjectRole, step.HeadType, step.Status, step.AllowReassignToOtherApprovers);
}
private static TemplateRouteDto ToDto(TemplateRoute e) => new(
e.Id, e.RouteType, e.RequestTypeId, e.DepartmentId, e.CompanyId, e.ByProject, e.RequiresControl);
}
@@ -0,0 +1,28 @@
using FluentValidation;
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Application.TemplateRoutes;
public sealed class CreateTemplateRouteRequestValidator : AbstractValidator<CreateTemplateRouteRequest>
{
public CreateTemplateRouteRequestValidator()
{
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
}
}
public sealed class UpdateTemplateRouteRequestValidator : AbstractValidator<UpdateTemplateRouteRequest>
{
public UpdateTemplateRouteRequestValidator()
{
RuleFor(x => x.CompanyId).NotEqual(Guid.Empty).WithMessage("Компания обязательна.");
}
}
public sealed class ResolveTemplateRouteRequestValidator : AbstractValidator<ResolveTemplateRouteRequest>
{
public ResolveTemplateRouteRequestValidator()
{
RuleFor(x => x.Status).NotEqual(RouteStepStatus.None).WithMessage("Статус обязателен: Approval или Execution.");
}
}
@@ -1,8 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" /> <ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
<ProjectReference Include="..\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MediatR" /> <PackageReference Include="FluentResults" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" />
<PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,13 +1,10 @@
using System;
using System.Collections.Generic;
namespace Tnsu.ApprovalRoute.Domain.Entities; namespace Tnsu.ApprovalRoute.Domain.Entities;
public class RequestType public class RequestType
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public int Id { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
public string PageUrl { get; set; } = string.Empty; public string PageUrl { get; set; } = string.Empty;
public ICollection<Route> Routes { get; set; } = new List<Route>(); public ICollection<TemplateRoute> Routes { get; set; } = new List<TemplateRoute>();
} }
@@ -1,20 +0,0 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Domain.Entities;
public class Route
{
public Guid Id { get; set; } = Guid.NewGuid();
public RouteType RouteType { get; set; } = RouteType.Sequential;
public Guid RequestTypeId { get; set; }
// Внешние справочники (1С/ZUP) — хранятся как ссылки без FK в этой БД.
public Guid DepartmentId { get; set; }
public Guid CompanyId { get; set; }
public bool ByProject { get; set; }
public bool RequiresControl { get; set; }
public RequestType? RequestType { get; set; }
public ICollection<RouteStep> Steps { get; set; } = new List<RouteStep>();
}
@@ -0,0 +1,21 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Domain.Entities;
public class TemplateRoute
{
public int Id { get; set; }
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; }
public bool RequiresControl { get; set; }
public RequestType? RequestType { get; set; }
public ICollection<TemplateRouteStep> Steps { get; set; } = new List<TemplateRouteStep>();
}
@@ -2,19 +2,20 @@ using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Domain.Entities; namespace Tnsu.ApprovalRoute.Domain.Entities;
public class RouteStep public class TemplateRouteStep
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public int Id { get; set; }
public int OrderNo { get; set; } public int OrderNo { get; set; }
// Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users. // Внешняя ссылка на пользователя (1С/ZUP) — без локальной таблицы Users.
public string ExecutorEmail { get; set; } = string.Empty; public string ExecutorEmail { get; set; } = string.Empty;
public string Position { get; set; } = string.Empty; public string Position { get; set; } = string.Empty;
public ProjectRole ProjectRole { get; set; } = ProjectRole.None; public ProjectRole ProjectRole { get; set; } = ProjectRole.None;
public HeadType HeadType { get; set; } = HeadType.None;
public RouteStepStatus Status { get; set; } = RouteStepStatus.Draft; public RouteStepStatus Status { get; set; } = RouteStepStatus.Approval;
public Guid RouteId { get; set; } public int RouteId { get; set; }
public bool AllowReassignToOtherApprovers { get; set; } public bool AllowReassignToOtherApprovers { get; set; }
public Route? Route { get; set; } public TemplateRoute? Route { get; set; }
} }
@@ -0,0 +1,9 @@
namespace Tnsu.ApprovalRoute.Domain.Enums;
public enum HeadType
{
None,
Chief,
DirectionHead,
DirectorTansu
}
@@ -1,9 +1,12 @@
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,
Manager, ProjectManager,
Engineer, Planner,
Observer ManagerHSE,
Security
} }
@@ -3,11 +3,6 @@ namespace Tnsu.ApprovalRoute.Domain.Enums;
public enum RouteStepStatus public enum RouteStepStatus
{ {
None, None,
Draft, Approval,
OnApproval, Execution
Approved,
InExecution,
Completed,
Returned,
Closed
} }
@@ -2,7 +2,9 @@ using Extensions.Hosting.AsyncInitialization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Infrastructure; namespace Tnsu.ApprovalRoute.Infrastructure;
@@ -11,11 +13,21 @@ public static class InfrastructureServiceCollectionExtensions
public static IServiceCollection AddApprovalRouteInfrastructure( public static IServiceCollection AddApprovalRouteInfrastructure(
this IServiceCollection services, IConfiguration configuration) this IServiceCollection services, IConfiguration configuration)
{ {
services.AddHttpContextAccessor();
services.AddDbContext<ApprovalRouteDbContext>(opt => services.AddDbContext<ApprovalRouteDbContext>(opt =>
opt.UseNpgsql(configuration.GetConnectionString("Postgres")) opt.UseNpgsql(configuration.GetConnectionString("Postgres"))
.UseSnakeCaseNamingConvention()); .UseSnakeCaseNamingConvention());
services.AddAsyncInitializer<DatabaseMigrationInitializer>(); services.AddAsyncInitializer<DatabaseMigrationInitializer>();
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
services.AddHttpClient<TnsuPortalTokenProvider>();
services.AddHttpClient<ITnsuPortalClient, HttpTnsuPortalClient>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
return services; return services;
} }
} }
@@ -6,8 +6,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence;
public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options) public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> options) : DbContext(options)
{ {
public DbSet<RequestType> RequestTypes => Set<RequestType>(); public DbSet<RequestType> RequestTypes => Set<RequestType>();
public DbSet<Route> Routes => Set<Route>(); public DbSet<TemplateRoute> TemplateRoutes => Set<TemplateRoute>();
public DbSet<RouteStep> RouteSteps => Set<RouteStep>(); public DbSet<TemplateRouteStep> TemplateRouteSteps => Set<TemplateRouteStep>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -18,19 +18,21 @@ public class ApprovalRouteDbContext(DbContextOptions<ApprovalRouteDbContext> opt
e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired(); e.Property(x => x.PageUrl).HasMaxLength(512).IsRequired();
}); });
modelBuilder.Entity<Route>(e => modelBuilder.Entity<TemplateRoute>(e =>
{ {
e.HasKey(x => x.Id); e.HasKey(x => x.Id);
e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.RouteType).HasConversion<string>().HasMaxLength(32).IsRequired();
e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId); e.HasOne(x => x.RequestType).WithMany(x => x.Routes).HasForeignKey(x => x.RequestTypeId);
e.HasIndex(x => new { x.RequestTypeId, x.DepartmentId, x.CompanyId, x.ByProject }).IsUnique();
}); });
modelBuilder.Entity<RouteStep>(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).IsRequired();
e.Property(x => x.Position).HasMaxLength(128).IsRequired(); e.Property(x => x.Position).HasMaxLength(128).IsRequired();
e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32).IsRequired(); e.Property(x => x.ProjectRole).HasConversion<string>().HasMaxLength(32).IsRequired();
e.Property(x => x.HeadType).HasConversion<string>().HasMaxLength(32).IsRequired();
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("20260722130455_InitialCreate")] [Migration("20260724114550_InitialCreate")]
partial class InitialCreate partial class InitialCreate
{ {
/// <inheritdoc /> /// <inheritdoc />
@@ -27,11 +27,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{ {
b.Property<Guid>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("id"); .HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -50,13 +52,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("request_types", (string)null); b.ToTable("request_types", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.Property<Guid>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("id"); .HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject") b.Property<bool>("ByProject")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
@@ -65,12 +69,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id"); .HasColumnName("company_id");
b.Property<Guid>("DepartmentId") b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("department_id"); .HasColumnName("department_id");
b.Property<Guid>("RequestTypeId") b.Property<int>("RequestTypeId")
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("request_type_id"); .HasColumnName("request_type_id");
b.Property<bool>("RequiresControl") b.Property<bool>("RequiresControl")
@@ -84,21 +88,24 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("route_type"); .HasColumnName("route_type");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_routes"); .HasName("pk_template_routes");
b.HasIndex("RequestTypeId") b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.HasDatabaseName("ix_routes_request_type_id"); .IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("routes", (string)null); b.ToTable("template_routes", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{ {
b.Property<Guid>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("id"); .HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers") b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers"); .HasColumnName("allow_reassign_to_other_approvers");
@@ -109,6 +116,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("HeadType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo") b.Property<int>("OrderNo")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("order_no"); .HasColumnName("order_no");
@@ -125,8 +138,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("project_role"); .HasColumnName("project_role");
b.Property<Guid>("RouteId") b.Property<int>("RouteId")
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("route_id"); .HasColumnName("route_id");
b.Property<string>("Status") b.Property<string>("Status")
@@ -136,34 +149,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("status"); .HasColumnName("status");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_route_steps"); .HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo") b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_route_steps_route_id_order_no"); .HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("route_steps", (string)null); b.ToTable("template_route_steps", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes") .WithMany("Routes")
.HasForeignKey("RequestTypeId") .HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
.HasConstraintName("fk_routes_request_types_request_type_id"); .HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType"); b.Navigation("RequestType");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{ {
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route") b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps") .WithMany("Steps")
.HasForeignKey("RouteId") .HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
.HasConstraintName("fk_route_steps_routes_route_id"); .HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route"); b.Navigation("Route");
}); });
@@ -173,7 +186,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.Navigation("Routes"); b.Navigation("Routes");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.Navigation("Steps"); b.Navigation("Steps");
}); });
@@ -1,5 +1,6 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable #nullable disable
@@ -15,7 +16,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
name: "request_types", name: "request_types",
columns: table => new columns: table => new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false), name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: false),
page_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false) page_url = table.Column<string>(type: "character varying(512)", maxLength: 512, nullable: false)
}, },
@@ -25,22 +27,23 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "routes", name: "template_routes",
columns: table => new columns: table => new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false), route_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
request_type_id = table.Column<Guid>(type: "uuid", nullable: false), request_type_id = table.Column<int>(type: "integer", nullable: false),
department_id = table.Column<Guid>(type: "uuid", nullable: false), department_id = table.Column<Guid>(type: "uuid", nullable: true),
company_id = table.Column<Guid>(type: "uuid", nullable: false), company_id = table.Column<Guid>(type: "uuid", nullable: false),
by_project = table.Column<bool>(type: "boolean", nullable: false), by_project = table.Column<bool>(type: "boolean", nullable: false),
requires_control = table.Column<bool>(type: "boolean", nullable: false) requires_control = table.Column<bool>(type: "boolean", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_routes", x => x.id); table.PrimaryKey("pk_template_routes", x => x.id);
table.ForeignKey( table.ForeignKey(
name: "fk_routes_request_types_request_type_id", name: "fk_template_routes_request_types_request_type_id",
column: x => x.request_type_id, column: x => x.request_type_id,
principalTable: "request_types", principalTable: "request_types",
principalColumn: "id", principalColumn: "id",
@@ -48,48 +51,51 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
}); });
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "route_steps", name: "template_route_steps",
columns: table => new columns: table => new
{ {
id = table.Column<Guid>(type: "uuid", nullable: false), id = table.Column<int>(type: "integer", nullable: false)
.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: false),
position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false), position = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
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: false),
head_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
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<Guid>(type: "uuid", 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)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("pk_route_steps", x => x.id); table.PrimaryKey("pk_template_route_steps", x => x.id);
table.ForeignKey( table.ForeignKey(
name: "fk_route_steps_routes_route_id", name: "fk_template_route_steps_template_routes_route_id",
column: x => x.route_id, column: x => x.route_id,
principalTable: "routes", principalTable: "template_routes",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_route_steps_route_id_order_no", name: "ix_template_route_steps_route_id_order_no",
table: "route_steps", table: "template_route_steps",
columns: new[] { "route_id", "order_no" }); columns: new[] { "route_id", "order_no" });
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_routes_request_type_id", name: "ix_template_routes_request_type_id_department_id_company_id_by",
table: "routes", table: "template_routes",
column: "request_type_id"); columns: new[] { "request_type_id", "department_id", "company_id", "by_project" },
unique: true);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "route_steps"); name: "template_route_steps");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "routes"); name: "template_routes");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "request_types"); name: "request_types");
@@ -24,11 +24,13 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RequestType", b =>
{ {
b.Property<Guid>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("id"); .HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(256) .HasMaxLength(256)
@@ -47,13 +49,15 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.ToTable("request_types", (string)null); b.ToTable("request_types", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.Property<Guid>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("id"); .HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("ByProject") b.Property<bool>("ByProject")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("by_project"); .HasColumnName("by_project");
@@ -62,12 +66,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("company_id"); .HasColumnName("company_id");
b.Property<Guid>("DepartmentId") b.Property<Guid?>("DepartmentId")
.HasColumnType("uuid") .HasColumnType("uuid")
.HasColumnName("department_id"); .HasColumnName("department_id");
b.Property<Guid>("RequestTypeId") b.Property<int>("RequestTypeId")
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("request_type_id"); .HasColumnName("request_type_id");
b.Property<bool>("RequiresControl") b.Property<bool>("RequiresControl")
@@ -81,21 +85,24 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("route_type"); .HasColumnName("route_type");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_routes"); .HasName("pk_template_routes");
b.HasIndex("RequestTypeId") b.HasIndex("RequestTypeId", "DepartmentId", "CompanyId", "ByProject")
.HasDatabaseName("ix_routes_request_type_id"); .IsUnique()
.HasDatabaseName("ix_template_routes_request_type_id_department_id_company_id_by");
b.ToTable("routes", (string)null); b.ToTable("template_routes", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{ {
b.Property<Guid>("Id") b.Property<int>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("id"); .HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<bool>("AllowReassignToOtherApprovers") b.Property<bool>("AllowReassignToOtherApprovers")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("allow_reassign_to_other_approvers"); .HasColumnName("allow_reassign_to_other_approvers");
@@ -106,6 +113,12 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(256)") .HasColumnType("character varying(256)")
.HasColumnName("executor_email"); .HasColumnName("executor_email");
b.Property<string>("HeadType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("head_type");
b.Property<int>("OrderNo") b.Property<int>("OrderNo")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("order_no"); .HasColumnName("order_no");
@@ -122,8 +135,8 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnType("character varying(32)") .HasColumnType("character varying(32)")
.HasColumnName("project_role"); .HasColumnName("project_role");
b.Property<Guid>("RouteId") b.Property<int>("RouteId")
.HasColumnType("uuid") .HasColumnType("integer")
.HasColumnName("route_id"); .HasColumnName("route_id");
b.Property<string>("Status") b.Property<string>("Status")
@@ -133,34 +146,34 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
.HasColumnName("status"); .HasColumnName("status");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_route_steps"); .HasName("pk_template_route_steps");
b.HasIndex("RouteId", "OrderNo") b.HasIndex("RouteId", "OrderNo")
.HasDatabaseName("ix_route_steps_route_id_order_no"); .HasDatabaseName("ix_template_route_steps_route_id_order_no");
b.ToTable("route_steps", (string)null); b.ToTable("template_route_steps", (string)null);
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType") b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.RequestType", "RequestType")
.WithMany("Routes") .WithMany("Routes")
.HasForeignKey("RequestTypeId") .HasForeignKey("RequestTypeId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
.HasConstraintName("fk_routes_request_types_request_type_id"); .HasConstraintName("fk_template_routes_request_types_request_type_id");
b.Navigation("RequestType"); b.Navigation("RequestType");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.RouteStep", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRouteStep", b =>
{ {
b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.Route", "Route") b.HasOne("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", "Route")
.WithMany("Steps") .WithMany("Steps")
.HasForeignKey("RouteId") .HasForeignKey("RouteId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired() .IsRequired()
.HasConstraintName("fk_route_steps_routes_route_id"); .HasConstraintName("fk_template_route_steps_template_routes_route_id");
b.Navigation("Route"); b.Navigation("Route");
}); });
@@ -170,7 +183,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.Persistence.Migrations
b.Navigation("Routes"); b.Navigation("Routes");
}); });
modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.Route", b => modelBuilder.Entity("Tnsu.ApprovalRoute.Domain.Entities.TemplateRoute", b =>
{ {
b.Navigation("Steps"); b.Navigation("Steps");
}); });
@@ -1,6 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" /> <ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -12,6 +11,9 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<!-- Даёт доступ к IHttpContextAccessor из общего фреймворка ASP.NET Core (тот же тип, что в Api). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,93 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
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<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/Employee/ByEmail?email={Uri.EscapeDataString(email)}");
await AuthorizeAsync(request, 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)
return null;
return new EmployeeDirectoryInfo(employee.CompanyId ?? Guid.Empty, employee.DepartmentId);
}
public async Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, $"/Project/{projectId}");
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)
return [];
// Каждая роль проекта — email сотрудника в соответствующем поле (project_manager / planner / manager_hse / security).
var executors = new List<ProjectExecutorInfo>();
AddIfPresent(executors, ProjectRole.ProjectManager, project.ProjectManager);
AddIfPresent(executors, ProjectRole.Planner, project.Planner);
AddIfPresent(executors, ProjectRole.ManagerHSE, project.ManagerHSE);
AddIfPresent(executors, ProjectRole.Security, project.Security);
return executors;
}
private static void AddIfPresent(List<ProjectExecutorInfo> executors, ProjectRole role, string? email)
{
if (!string.IsNullOrWhiteSpace(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)
{
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(incoming))
{
request.Headers.TryAddWithoutValidation("Authorization", incoming);
return;
}
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
private sealed record EmployeeResponse(
[property: JsonPropertyName("companyId")] Guid? CompanyId,
[property: JsonPropertyName("departmentId")] Guid? DepartmentId);
private sealed record ProjectResponse(
[property: JsonPropertyName("projectManager")] string? ProjectManager,
[property: JsonPropertyName("planner")] string? Planner,
[property: JsonPropertyName("managerHSE")] string? ManagerHSE,
[property: JsonPropertyName("security")] string? Security);
}
@@ -0,0 +1,8 @@
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public interface ITnsuPortalClient
{
Task<EmployeeDirectoryInfo?> GetEmployeeByEmailAsync(string email, CancellationToken cancellationToken);
Task<IReadOnlyList<ProjectExecutorInfo>> GetProjectExecutorsAsync(Guid projectId, CancellationToken cancellationToken);
}
@@ -0,0 +1,7 @@
using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed record EmployeeDirectoryInfo(Guid CompanyId, Guid? DepartmentId);
public sealed record ProjectExecutorInfo(ProjectRole ProjectRole, string Email);
@@ -0,0 +1,10 @@
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed class TnsuPortalOptions
{
public string BaseUrl { get; set; } = "https://api.tnsu.kz";
public string TenantId { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string ClientSecret { get; set; } = string.Empty;
public string Scope { get; set; } = string.Empty;
}
@@ -0,0 +1,43 @@
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
// Сервисный (client-credentials) токен для вызова api.tnsu.kz — не связан с токеном конечного пользователя.
public sealed class TnsuPortalTokenProvider(HttpClient httpClient, IOptions<TnsuPortalOptions> options)
{
private string? _cachedToken;
private DateTimeOffset _expiresAt = DateTimeOffset.MinValue;
public async Task<string> GetAccessTokenAsync(CancellationToken cancellationToken)
{
if (_cachedToken is not null && DateTimeOffset.UtcNow < _expiresAt)
return _cachedToken;
var opts = options.Value;
var response = await httpClient.PostAsync(
$"https://login.microsoftonline.com/{opts.TenantId}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = opts.ClientId,
["client_secret"] = opts.ClientSecret,
["scope"] = opts.Scope
}),
cancellationToken);
response.EnsureSuccessStatusCode();
var payload = await response.Content.ReadFromJsonAsync<TokenResponse>(cancellationToken)
?? throw new InvalidOperationException("Не удалось получить токен для api.tnsu.kz.");
_cachedToken = payload.AccessToken;
_expiresAt = DateTimeOffset.UtcNow.AddSeconds(payload.ExpiresIn - 60);
return _cachedToken;
}
private sealed record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
}
@@ -0,0 +1,98 @@
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class RequestTypeServiceTests(PostgresDatabaseFixture fixture) : IClassFixture<PostgresDatabaseFixture>
{
private readonly RequestTypeService _service = TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService<RequestTypeService>();
[Fact(DisplayName = "Создание типа заявки и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_Then_GetAsync_ReturnsCreatedEntity()
{
// Action
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Vacation", "/vacation"), CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.Name.ShouldBe("Vacation");
created.Value.PageUrl.ShouldBe("/vacation");
// Action
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
// Assert
fetched.IsSuccess.ShouldBeTrue();
fetched.Value.Id.ShouldBe(created.Value.Id);
fetched.Value.Name.ShouldBe("Vacation");
}
[Fact(DisplayName = "Получение несуществующего типа заявки возвращает неуспешный результат")]
public async Task GetAsync_UnknownId_ReturnsFailedResult()
{
// Action
var result = await _service.GetAsync(999999, CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Обновление типа заявки изменяет имя и url страницы")]
public async Task UpdateAsync_ChangesFields()
{
// Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Old name", "/old"), CancellationToken.None);
// Action
var updated = await _service.UpdateAsync(
created.Value.Id, new UpdateRequestTypeRequest("New name", "/new"), CancellationToken.None);
// Assert
updated.IsSuccess.ShouldBeTrue();
updated.Value.Name.ShouldBe("New name");
updated.Value.PageUrl.ShouldBe("/new");
}
[Fact(DisplayName = "Обновление несуществующего типа заявки возвращает неуспешный результат")]
public async Task UpdateAsync_UnknownId_ReturnsFailedResult()
{
// Action
var result = await _service.UpdateAsync(
999999, new UpdateRequestTypeRequest("X", "/x"), CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Удаление типа заявки убирает его из базы")]
public async Task DeleteAsync_RemovesEntity()
{
// Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Temp", "/temp"), CancellationToken.None);
// Action
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
// Assert
deleted.IsSuccess.ShouldBeTrue();
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Список типов заявок включает ранее созданный тип")]
public async Task ListAsync_IncludesCreatedEntity()
{
// Arrange
var created = await _service.CreateAsync(new CreateRequestTypeRequest("Listed type", "/listed"), CancellationToken.None);
// Action
var list = await _service.ListAsync(CancellationToken.None);
// Assert
list.IsSuccess.ShouldBeTrue();
list.Value.ShouldContain(x => x.Id == created.Value.Id);
}
}
@@ -0,0 +1,211 @@
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Shouldly;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
private static readonly Guid DepartmentId = Guid.Parse("00000000-0000-0000-0000-000000000001");
private readonly ServiceProvider _services;
private readonly TemplateRouteService _service;
private readonly RequestTypeService _requestTypeService;
public TemplateRouteServiceTests(PostgresDatabaseFixture fixture)
{
var portalClient = new Mock<ITnsuPortalClient>();
portalClient
.Setup(x => x.GetEmployeeByEmailAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId));
portalClient
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
_services = TestServiceProviderFactory.Build(fixture.ConnectionString, portalClient: portalClient.Object);
_service = _services.GetRequiredService<TemplateRouteService>();
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
}
private async Task<int> CreateRequestTypeAsync(string name) =>
(await _requestTypeService.CreateAsync(new CreateRequestTypeRequest(name, $"/{name}"), CancellationToken.None)).Value.Id;
[Fact(DisplayName = "Создание маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedEntity()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Vacation");
// Action
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.RequestTypeId.ShouldBe(requestTypeId);
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsSuccess.ShouldBeTrue();
fetched.Value.CompanyId.ShouldBe(CompanyId);
}
[Fact(DisplayName = "Создание маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task CreateAsync_UnknownRequestType_ReturnsFailedResult()
{
// Action
var result = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, 999999, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Повторное создание маршрута с тем же типом заявки, отделом, компанией и признаком «по проекту» возвращает конфликт")]
public async Task CreateAsync_DuplicateRoute_ReturnsFailedResult()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Business trip");
var request = new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false);
var first = await _service.CreateAsync(request, CancellationToken.None);
first.IsSuccess.ShouldBeTrue();
// Action
var duplicate = await _service.CreateAsync(request, CancellationToken.None);
// Assert
duplicate.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Обновление маршрута изменяет тип маршрута и признак необходимости контроля")]
public async Task UpdateAsync_ChangesFields()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Sick leave");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var updated = await _service.UpdateAsync(
created.Value.Id,
new UpdateTemplateRouteRequest(RouteType.Parallel, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
// Assert
updated.IsSuccess.ShouldBeTrue();
updated.Value.RouteType.ShouldBe(RouteType.Parallel);
updated.Value.RequiresControl.ShouldBeTrue();
}
[Fact(DisplayName = "Удаление маршрута убирает его из базы")]
public async Task DeleteAsync_RemovesEntity()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Remote work");
var created = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var deleted = await _service.DeleteAsync(created.Value.Id, CancellationToken.None);
// Assert
deleted.IsSuccess.ShouldBeTrue();
var fetched = await _service.GetAsync(created.Value.Id, CancellationToken.None);
fetched.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Резолв маршрута по точному совпадению отдела возвращает шаги с нужным статусом")]
public async Task ResolveAsync_ExactDepartmentMatch_ReturnsMatchingStepsForStatus()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Exact match request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: true),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "approver@tnsu.kz", "Lead", ProjectRole.None, HeadType.Chief, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.RouteId.ShouldBe(route.Value.Id);
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
}
[Fact(DisplayName = "Резолв маршрута с ролью в проекте подставляет исполнителя из проекта")]
public async Task ResolveAsync_ByProjectRole_UsesProjectExecutorEmail()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Project request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId, CompanyId, ByProject: true, RequiresControl: false),
CancellationToken.None);
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
await stepService.CreateAsync(
route.Value.Id,
new CreateTemplateRouteStepRequest(1, "", "Lead", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", Guid.NewGuid(), RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.Steps.ShouldHaveSingleItem();
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("pm@tnsu.kz");
}
[Fact(DisplayName = "Резолв маршрута откатывается на маршрут без отдела, если точного совпадения по отделу нет")]
public async Task ResolveAsync_NoDepartmentRoute_FallsBackToCompanyWideRoute()
{
// Arrange
var requestTypeId = await CreateRequestTypeAsync("Company-wide request");
var route = await _service.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, DepartmentId: null, CompanyId, ByProject: false, RequiresControl: false),
CancellationToken.None);
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Execution),
CancellationToken.None);
// Assert
resolved.IsSuccess.ShouldBeTrue();
resolved.Value.RouteId.ShouldBe(route.Value.Id);
}
[Fact(DisplayName = "Резолв маршрута с несуществующим типом заявки возвращает неуспешный результат")]
public async Task ResolveAsync_UnknownRequestType_ReturnsFailedResult()
{
// Action
var resolved = await _service.ResolveAsync(
new ResolveTemplateRouteRequest(999999, "someone@tnsu.kz", null, RouteStepStatus.Approval),
CancellationToken.None);
// Assert
resolved.IsFailed.ShouldBeTrue();
}
}
@@ -0,0 +1,166 @@
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Tnsu.ApprovalRoute.Application.RequestTypes;
using Tnsu.ApprovalRoute.Application.TemplateRoutes;
using Tnsu.ApprovalRoute.Application.TemplateRouteSteps;
using Tnsu.ApprovalRoute.Domain.Enums;
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
public sealed class TemplateRouteStepServiceTests : IClassFixture<PostgresDatabaseFixture>
{
private static readonly Guid CompanyId = Guid.Parse("00000000-0000-0000-0000-000000000002");
private readonly TemplateRouteStepService _service;
private readonly TemplateRouteService _routeService;
private readonly RequestTypeService _requestTypeService;
public TemplateRouteStepServiceTests(PostgresDatabaseFixture fixture)
{
var services = TestServiceProviderFactory.Build(fixture.ConnectionString);
_service = services.GetRequiredService<TemplateRouteStepService>();
_routeService = services.GetRequiredService<TemplateRouteService>();
_requestTypeService = services.GetRequiredService<RequestTypeService>();
}
private async Task<int> CreateRouteAsync(string requestTypeName, bool byProject)
{
var requestTypeId = (await _requestTypeService.CreateAsync(
new CreateRequestTypeRequest(requestTypeName, $"/{requestTypeName}"), CancellationToken.None)).Value.Id;
var route = await _routeService.CreateAsync(
new CreateTemplateRouteRequest(RouteType.Sequential, requestTypeId, null, CompanyId, byProject, RequiresControl: false),
CancellationToken.None);
return route.Value.Id;
}
[Fact(DisplayName = "Создание шага для несуществующего маршрута возвращает неуспешный результат")]
public async Task CreateAsync_UnknownRoute_ReturnsFailedResult()
{
// Action
var result = await _service.CreateAsync(
999999,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Роль в проекте недопустима для шага маршрута без признака «по проекту»")]
public async Task CreateAsync_ProjectRoleOnNonProjectRoute_ReturnsFailedResult()
{
// Arrange
var routeId = await CreateRouteAsync("Non project route", byProject: false);
// Action
var result = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Assert
result.IsFailed.ShouldBeTrue();
}
[Fact(DisplayName = "Создание шага маршрута и получение его по Id возвращает сохранённые данные")]
public async Task CreateAsync_ThenGetAsync_ReturnsCreatedStep()
{
// Arrange
var routeId = await CreateRouteAsync("Simple route", byProject: false);
// Action
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
fetched.IsSuccess.ShouldBeTrue();
fetched.Value.ExecutorEmail.ShouldBe("a@b.kz");
}
[Fact(DisplayName = "Создание шага без роли в проекте на маршруте «по проекту» возвращает предупреждение")]
public async Task CreateAsync_ByProjectRouteWithoutProjectRoleStep_ReturnsWarning()
{
// Arrange
var routeId = await CreateRouteAsync("By-project route without role", byProject: true);
// Action
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.Warning.ShouldNotBeNull();
created.Value.Warning.ShouldContain("ролью в проекте");
}
[Fact(DisplayName = "Создание шага с ролью в проекте на маршруте «по проекту» не возвращает предупреждение")]
public async Task CreateAsync_ByProjectRouteWithProjectRoleStep_HasNoWarning()
{
// Arrange
var routeId = await CreateRouteAsync("By-project route with role", byProject: true);
// Action
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "", "", ProjectRole.ProjectManager, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Assert
created.IsSuccess.ShouldBeTrue();
created.Value.Warning.ShouldBeNull();
}
[Fact(DisplayName = "Обновление шага маршрута изменяет исполнителя, статус и разрешение на переназначение")]
public async Task UpdateAsync_ChangesFields()
{
// Arrange
var routeId = await CreateRouteAsync("Route to update step", byProject: false);
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var updated = await _service.UpdateAsync(
routeId,
created.Value.Id,
new UpdateTemplateRouteStepRequest(2, "b@b.kz", "Senior Engineer", ProjectRole.None, HeadType.Chief, RouteStepStatus.Execution, true),
CancellationToken.None);
// Assert
updated.IsSuccess.ShouldBeTrue();
updated.Value.ExecutorEmail.ShouldBe("b@b.kz");
updated.Value.Status.ShouldBe(RouteStepStatus.Execution);
updated.Value.AllowReassignToOtherApprovers.ShouldBeTrue();
}
[Fact(DisplayName = "Удаление шага маршрута убирает его из базы")]
public async Task DeleteAsync_RemovesStep()
{
// Arrange
var routeId = await CreateRouteAsync("Route to delete step", byProject: false);
var created = await _service.CreateAsync(
routeId,
new CreateTemplateRouteStepRequest(1, "a@b.kz", "Engineer", ProjectRole.None, HeadType.None, RouteStepStatus.Approval, false),
CancellationToken.None);
// Action
var deleted = await _service.DeleteAsync(routeId, created.Value.Id, CancellationToken.None);
// Assert
deleted.IsSuccess.ShouldBeTrue();
var fetched = await _service.GetAsync(routeId, created.Value.Id, CancellationToken.None);
fetched.IsFailed.ShouldBeTrue();
}
}
@@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Xunit;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
// Отдельная БД на один xUnit-collection (= один тестовый класс) в общем инстансе Postgres.
// Тесты внутри класса делят эту БД; разные классы (разные коллекции) получают разные БД.
public sealed class PostgresDatabaseFixture : IAsyncLifetime
{
private readonly string _databaseName = $"tnsu_test_{Guid.NewGuid():N}";
private readonly NpgsqlConnectionStringBuilder _adminConnectionBuilder = new(TestSettings.BaseConnectionString);
public string ConnectionString { get; private set; } = string.Empty;
public async Task InitializeAsync()
{
await using (var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString))
{
await adminConnection.OpenAsync();
await using var createCommand = new NpgsqlCommand($"CREATE DATABASE \"{_databaseName}\"", adminConnection);
await createCommand.ExecuteNonQueryAsync();
}
ConnectionString = new NpgsqlConnectionStringBuilder(_adminConnectionBuilder.ConnectionString)
{
Database = _databaseName
}.ConnectionString;
var options = new DbContextOptionsBuilder<ApprovalRouteDbContext>()
.UseNpgsql(ConnectionString)
.UseSnakeCaseNamingConvention()
.Options;
await using var db = new ApprovalRouteDbContext(options);
await db.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
await using var adminConnection = new NpgsqlConnection(_adminConnectionBuilder.ConnectionString);
await adminConnection.OpenAsync();
await using (var terminateCommand = new NpgsqlCommand(
"""
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = @databaseName AND pid <> pg_backend_pid();
""",
adminConnection))
{
terminateCommand.Parameters.AddWithValue("databaseName", _databaseName);
await terminateCommand.ExecuteNonQueryAsync();
}
await using var dropCommand = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{_databaseName}\"", adminConnection);
await dropCommand.ExecuteNonQueryAsync();
}
}
@@ -0,0 +1,8 @@
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
internal sealed class TestCurrentUser(string? email = null) : ICurrentUser
{
public string? Email { get; } = email;
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Tnsu.ApprovalRoute.Application;
using Tnsu.ApprovalRoute.Application.Common.Interfaces;
using Tnsu.ApprovalRoute.Infrastructure;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
// Собирает такой же DI-контейнер, как реальное приложение (те же extension-методы, что в Program.cs),
// но с тестовой БД, тестовым ICurrentUser и подменяемым (Moq) клиентом api.tnsu.kz.
internal static class TestServiceProviderFactory
{
public static ServiceProvider Build(
string connectionString,
string? currentUserEmail = "initiator@tnsu.kz",
ITnsuPortalClient? portalClient = null)
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:Postgres"] = connectionString
})
.Build();
var services = new ServiceCollection();
services.AddApprovalRouteInfrastructure(configuration);
services.AddApprovalRouteApplication();
services.AddSingleton<ICurrentUser>(new TestCurrentUser(currentUserEmail));
if (portalClient is not null)
services.AddSingleton(portalClient);
return services.BuildServiceProvider();
}
}
@@ -0,0 +1,8 @@
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
internal static class TestSettings
{
public static string BaseConnectionString =>
Environment.GetEnvironmentVariable("TEST_POSTGRES_CONNECTION")
?? "Host=localhost;Port=5435;Database=tnsu_approval_route;Username=tnsu_approval_route;Password=tnsu_approval_route";
}
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Infrastructure\Tnsu.ApprovalRoute.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Tnsu.ApprovalRoute.Application\Tnsu.ApprovalRoute.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="Shouldly" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>