Архивация списков (#13)
Tests / frontend-test (push) Successful in 44s
Tests / backend-test (push) Failing after 17m4s

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:
2026-08-19 09:08:36 +00:00
parent a2672042d7
commit 82512eb1db
56 changed files with 2587 additions and 51 deletions
@@ -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); // новая партиция строки ещё не получила
}
}
@@ -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;
}
}
@@ -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);
}
@@ -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);
}
}
@@ -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>();
@@ -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));