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; /// /// Блокировка провижининга — единственное, что не даёт двум фоновым задачам создать /// по партиции каждая, поэтому проверяется именно на живом Postgres: смысл здесь /// несёт атомарность условного UPDATE, а не код на C#. /// public sealed class ListPartitioningLockTests(PostgresDatabaseFixture fixture) : IClassFixture { 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() .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(); 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 TryAcquireAsync(int requestTypeId, TimeSpan? staleAfter = null) { using var scope = _provider.CreateScope(); return await scope.ServiceProvider.GetRequiredService() .TryAcquireAsync(requestTypeId, staleAfter ?? StaleAfter, CancellationToken.None); } private async Task CreateRequestTypeAsync() { using var scope = _provider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var entity = new RequestType { Name = $"Тип {Guid.NewGuid():N}", PageUrl = "/test", Section = "General" }; db.RequestTypes.Add(entity); await db.SaveChangesAsync(); return entity.Id; } }