Архивация списков (#13)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
+137
@@ -0,0 +1,137 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// request_list_partitions хранит ТОЛЬКО архив — партиция, которая ни разу не
|
||||
/// архивировалась, не должна получать строку просто от того, что её прочитали
|
||||
/// (см. ListPartitionRegistry). Раньше самозаполняющийся реестр писал строку
|
||||
/// уже на первое чтение, из-за чего один и тот же список оказывался записан
|
||||
/// дважды: и в RequestType.ListId, и строкой в этой таблице.
|
||||
/// </summary>
|
||||
public sealed class ListPartitionRegistryTests(PostgresDatabaseFixture fixture) : IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private readonly ApprovalRouteDbContext _db =
|
||||
TestServiceProviderFactory.Build(fixture.ConnectionString).GetRequiredService<ApprovalRouteDbContext>();
|
||||
|
||||
private ListPartitionRegistry BuildRegistry() =>
|
||||
new(_db, Mock.Of<ISharePointClient>(), NullLogger<ListPartitionRegistry>.Instance);
|
||||
|
||||
private async Task<RequestType> CreateTypeAsync(string listId)
|
||||
{
|
||||
var type = new RequestType
|
||||
{
|
||||
Name = $"Тест {Guid.NewGuid():N}",
|
||||
PageUrl = $"/test-{Guid.NewGuid():N}",
|
||||
ListId = listId,
|
||||
SiteUrl = "https://tnsukz.sharepoint.com/sites/requests",
|
||||
Section = "General"
|
||||
};
|
||||
_db.RequestTypes.Add(type);
|
||||
await _db.SaveChangesAsync();
|
||||
return type;
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Партиция, которую ни разу не архивировали, не пишется в request_list_partitions")]
|
||||
public async Task BuildCurrentAsync_NeverArchived_DoesNotWriteRow()
|
||||
{
|
||||
// Arrange
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
|
||||
// Action
|
||||
var archived = await registry.GetArchivedAsync(type, CancellationToken.None);
|
||||
var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
archived.ShouldBeEmpty();
|
||||
current.Number.ShouldBe(1);
|
||||
current.ListId.ShouldBe(type.ListId);
|
||||
current.IsCurrent.ShouldBeTrue();
|
||||
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.CountAsync();
|
||||
rowsInDb.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ArchiveCurrentAsync пишет ровно одну строку архива под номером 1")]
|
||||
public async Task ArchiveCurrentAsync_WritesExactlyOneRow()
|
||||
{
|
||||
// Arrange
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
|
||||
// Action
|
||||
var archived = await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
archived.Number.ShouldBe(1);
|
||||
archived.ListId.ShouldBe("/sites/requests/Lists/1");
|
||||
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.ToListAsync();
|
||||
rowsInDb.Count.ShouldBe(1);
|
||||
rowsInDb[0].ListId.ShouldBe("/sites/requests/Lists/1");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "ArchiveCurrentAsync идемпотентна — повтор не создаёт вторую строку")]
|
||||
public async Task ArchiveCurrentAsync_CalledTwice_DoesNotDuplicate()
|
||||
{
|
||||
// Arrange
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
|
||||
// Action — как повторная попытка Hangfire после частичного сбоя: пока указатель
|
||||
// ещё не переключён, GetArchivedAsync только что записанную строку не увидит
|
||||
// (фильтрует по ListId != RequestType.ListId) — именно поэтому идемпотентность
|
||||
// ArchiveCurrentAsync не должна зависеть от него (см. её doc-комментарий).
|
||||
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.CountAsync();
|
||||
rowsInDb.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "После архивации и переключения текущая партиция получает следующий номер")]
|
||||
public async Task BuildCurrentAsync_AfterArchiving_ReturnsNextNumber()
|
||||
{
|
||||
// Arrange: тип с одной партицией, которую сейчас архивируем (как
|
||||
// CreateNextListPartitionJob перед переключением на следующую).
|
||||
var registry = BuildRegistry();
|
||||
var type = await CreateTypeAsync("/sites/requests/Lists/1");
|
||||
await registry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||||
|
||||
// Action: переключаем указатель на новую партицию (сама она архивной
|
||||
// строки ещё не получает — см. doc-комментарий ListPartitionRegistry).
|
||||
type.ListId = "/sites/requests/Lists/2";
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var archived = await registry.GetArchivedAsync(type, CancellationToken.None);
|
||||
var current = await registry.BuildCurrentAsync(type, archived, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
archived.Count.ShouldBe(1);
|
||||
archived[0].ListId.ShouldBe("/sites/requests/Lists/1");
|
||||
current.Number.ShouldBe(2);
|
||||
current.ListId.ShouldBe("/sites/requests/Lists/2");
|
||||
|
||||
var rowsInDb = await _db.RequestListPartitions
|
||||
.Where(x => x.RequestTypeId == type.Id)
|
||||
.CountAsync();
|
||||
rowsInDb.ShouldBe(1); // новая партиция строки ещё не получила
|
||||
}
|
||||
}
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать
|
||||
/// по партиции каждая, поэтому проверяется именно на живом Postgres: смысл здесь
|
||||
/// несёт атомарность условного UPDATE, а не код на C#.
|
||||
/// </summary>
|
||||
public sealed class ListPartitioningLockTests(PostgresDatabaseFixture fixture)
|
||||
: IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
|
||||
|
||||
private readonly ServiceProvider _provider = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||
|
||||
[Fact(DisplayName = "Повторный захват занятой блокировки не удаётся")]
|
||||
public async Task TryAcquire_WhenAlreadyHeld_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
|
||||
// Action
|
||||
var first = await TryAcquireAsync(requestTypeId);
|
||||
var second = await TryAcquireAsync(requestTypeId);
|
||||
|
||||
// Assert
|
||||
first.ShouldBeTrue();
|
||||
second.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "При одновременном захвате блокировку получает ровно одна задача")]
|
||||
public async Task TryAcquire_Concurrently_OnlyOneWins()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
|
||||
// Action — каждый захват из своего scope, то есть своим соединением к БД:
|
||||
// именно так это и происходит у параллельных задач Hangfire.
|
||||
var results = await Task.WhenAll(
|
||||
Enumerable.Range(0, 8).Select(_ => TryAcquireAsync(requestTypeId)));
|
||||
|
||||
// Assert
|
||||
results.Count(x => x).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "После снятия блокировку можно захватить снова")]
|
||||
public async Task TryAcquire_AfterRelease_Succeeds()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||
|
||||
// Action
|
||||
using (var scope = _provider.CreateScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<ListPartitioningLock>()
|
||||
.ReleaseAsync(requestTypeId, CancellationToken.None);
|
||||
}
|
||||
|
||||
// Assert
|
||||
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Просроченная блокировка перехватывается, свежая — нет")]
|
||||
public async Task TryAcquire_StaleLock_IsTakenOver()
|
||||
{
|
||||
// Arrange
|
||||
var requestTypeId = await CreateRequestTypeAsync();
|
||||
(await TryAcquireAsync(requestTypeId)).ShouldBeTrue();
|
||||
|
||||
// Свежую блокировку с тем же порогом перехватить нельзя.
|
||||
(await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeFalse();
|
||||
|
||||
// Action — отодвигаем метку в прошлое: так выглядит блокировка, которую
|
||||
// оставил упавший, не снявший её процесс.
|
||||
using (var scope = _provider.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
||||
await db.Database.ExecuteSqlInterpolatedAsync(
|
||||
$"UPDATE request_types SET list_partitioning_started_at = now() - interval '1 hour' WHERE id = {requestTypeId}");
|
||||
}
|
||||
|
||||
// Assert
|
||||
(await TryAcquireAsync(requestTypeId, TimeSpan.FromMinutes(10))).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Захват блокировки для несуществующего типа заявки не удаётся")]
|
||||
public async Task TryAcquire_UnknownRequestType_ReturnsFalse() =>
|
||||
(await TryAcquireAsync(999999)).ShouldBeFalse();
|
||||
|
||||
private async Task<bool> TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null)
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<ListPartitioningLock>()
|
||||
.TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task<int> CreateRequestTypeAsync()
|
||||
{
|
||||
using var scope = _provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApprovalRouteDbContext>();
|
||||
|
||||
var entity = new RequestType
|
||||
{
|
||||
Name = $"Тип {Guid.NewGuid():N}",
|
||||
PageUrl = "/test",
|
||||
Section = "General"
|
||||
};
|
||||
db.RequestTypes.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return entity.Id;
|
||||
}
|
||||
}
|
||||
+2
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
@@ -102,6 +103,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
||||
return new ReminderService(
|
||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||
sharePoint.Object,
|
||||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||||
Options.Create(new RemindersOptions()),
|
||||
NullLogger<ReminderService>.Instance);
|
||||
}
|
||||
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe).
|
||||
///
|
||||
/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка
|
||||
/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит
|
||||
/// провижининг — подписка следствие создания списка, а не его условие.
|
||||
/// </summary>
|
||||
public sealed class SharePointWebhookSubscriberTests
|
||||
{
|
||||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||
private const string ListTitle = "Обходной лист 3 — данные";
|
||||
|
||||
/// <summary>Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ.</summary>
|
||||
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||
{
|
||||
public HttpRequestMessage? Captured { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Captured = request;
|
||||
CallCount++;
|
||||
return Task.FromResult(respond(request));
|
||||
}
|
||||
}
|
||||
|
||||
// Токен вызывающего есть — тогда авторизатор пробрасывает его и не идёт за
|
||||
// client_credentials (у которого в тестах нет ни секрета, ни сети).
|
||||
// fromHttpContext=false — путь фоновой задачи: токен положен явно, HttpContext'а
|
||||
// нет вовсе (см. TnsuPortalCallerToken.Set / CreateNextListPartitionJob).
|
||||
private static TnsuPortalAuthorizer BuildAuthorizer(bool fromHttpContext = true)
|
||||
{
|
||||
var accessor = new HttpContextAccessor();
|
||||
var callerToken = new TnsuPortalCallerToken(accessor);
|
||||
|
||||
if (fromHttpContext)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Request.Headers.Authorization = "Bearer test-token";
|
||||
accessor.HttpContext = context;
|
||||
}
|
||||
else
|
||||
{
|
||||
callerToken.Set("Bearer test-token");
|
||||
}
|
||||
|
||||
return new TnsuPortalAuthorizer(
|
||||
callerToken,
|
||||
new TnsuPortalTokenProvider(new HttpClient(), Options.Create(new TnsuPortalOptions())));
|
||||
}
|
||||
|
||||
private static (HttpSharePointWebhookSubscriber Subscriber, StubHandler Handler) Build(
|
||||
Func<HttpRequestMessage, HttpResponseMessage> respond,
|
||||
SharePointOptions? options = null,
|
||||
bool tokenFromHttpContext = true)
|
||||
{
|
||||
var handler = new StubHandler(respond);
|
||||
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.tnsu.kz") };
|
||||
|
||||
var subscriber = new HttpSharePointWebhookSubscriber(
|
||||
httpClient,
|
||||
BuildAuthorizer(tokenFromHttpContext),
|
||||
Options.Create(options ?? new SharePointOptions()),
|
||||
NullLogger<HttpSharePointWebhookSubscriber>.Instance);
|
||||
|
||||
return (subscriber, handler);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Ok() => new(System.Net.HttpStatusCode.OK);
|
||||
|
||||
[Fact(DisplayName = "Подписка уходит POST'ом со всеми тремя параметрами в строке запроса")]
|
||||
public async Task TrySubscribeAsync_SendsPostWithQueryParameters()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, handler) = Build(_ => Ok());
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeTrue();
|
||||
handler.Captured!.Method.ShouldBe(HttpMethod.Post);
|
||||
handler.Captured.Content.ShouldBeNull(); // тела у ручки нет
|
||||
|
||||
var query = Uri.UnescapeDataString(handler.Captured.RequestUri!.Query);
|
||||
handler.Captured.RequestUri.AbsolutePath.ShouldBe("/Webhook/Subscribe");
|
||||
query.ShouldContain($"listName={ListTitle}");
|
||||
query.ShouldContain("notificationUrl=https://api.tnsu.kz/Webhook/SharePoint");
|
||||
// Токен из входящего запроса пробрасывается как есть (см. TnsuPortalAuthorizer).
|
||||
handler.Captured.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "В url уходит ПОЛНЫЙ адрес сайта, а не короткое имя")]
|
||||
public async Task TrySubscribeAsync_SendsFullSiteUrl()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, handler) = Build(_ => Ok());
|
||||
|
||||
// Action
|
||||
await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
var query = Uri.UnescapeDataString(handler.Captured!.RequestUri!.Query);
|
||||
query.ShouldContain($"url={SiteUrl}");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Токен фоновой задачи (без HttpContext) тоже уходит в заголовок")]
|
||||
public async Task TrySubscribeAsync_TokenSetExplicitly_IsSentAsAuthorization()
|
||||
{
|
||||
// Arrange: так работает провижининг — HttpContext'а нет, токен передан
|
||||
// аргументом задачи (см. CreateNextListPartitionJob).
|
||||
var (subscriber, handler) = Build(_ => Ok(), tokenFromHttpContext: false);
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeTrue();
|
||||
handler.Captured!.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Отказ портала не бросает исключение — провижининг списка не рушится")]
|
||||
public async Task TrySubscribeAsync_PortalRejects_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, _) = Build(_ => new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
Content = new StringContent("no token")
|
||||
});
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Недоступный портал (исключение в сети) тоже не бросает наружу")]
|
||||
public async Task TrySubscribeAsync_NetworkFailure_ReturnsFalseWithoutThrowing()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, _) = Build(_ => throw new HttpRequestException("портал недоступен"));
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "NotificationUrl не задан — запрос не отправляется вовсе")]
|
||||
public async Task TrySubscribeAsync_NoNotificationUrl_DoesNotCallPortal()
|
||||
{
|
||||
// Arrange: пустой адрес приёмника — единственный выключатель подписки
|
||||
// (отдельного флага Enabled нет, см. SharePointOptions.NotificationUrl).
|
||||
var (subscriber, handler) = Build(_ => Ok(), new SharePointOptions { NotificationUrl = "" });
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
handler.CallCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Пустой заголовок списка — подписывать нечего, запроса нет")]
|
||||
public async Task TrySubscribeAsync_EmptyListTitle_DoesNotCallPortal()
|
||||
{
|
||||
// Arrange
|
||||
var (subscriber, handler) = Build(_ => Ok());
|
||||
|
||||
// Action
|
||||
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, " ", CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
subscribed.ShouldBeFalse();
|
||||
handler.CallCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -31,6 +31,9 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
portalClient
|
||||
.Setup(x => x.GetProjectExecutorsAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([new ProjectExecutorInfo(ProjectRole.ProjectManager, "pm@tnsu.kz")]);
|
||||
portalClient
|
||||
.Setup(x => x.GetEmployeeByPositionAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new EmployeeLookupResult(false, null, null));
|
||||
|
||||
_services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||
_service = _services.GetRequiredService<TemplateRouteService>();
|
||||
@@ -560,7 +563,7 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
.ReturnsAsync(new EmployeeDirectoryInfo(CompanyId, DepartmentId, "ТОО TANSU Construction", "Lead Engineer"));
|
||||
portalClient
|
||||
.Setup(x => x.GetEmployeeByPositionAsync("Lead", "ТОО TANSU Construction", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("lead@tnsu.kz");
|
||||
.ReturnsAsync(new EmployeeLookupResult(true, "lead@tnsu.kz", "Иванов Иван Иванович"));
|
||||
|
||||
var services = TestServiceProviderFactory.Build(_connectionString, portalClient: portalClient.Object);
|
||||
var routeService = services.GetRequiredService<TemplateRouteService>();
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ internal static class TestServiceProviderFactory
|
||||
.Build();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddApprovalRouteInfrastructure(configuration);
|
||||
services.AddApprovalRouteApplication();
|
||||
services.AddSingleton<ICurrentUser>(new TestCurrentUser(currentUserEmail));
|
||||
|
||||
Reference in New Issue
Block a user