причесывание
This commit is contained in:
+10
-25
@@ -3,7 +3,6 @@ using Hangfire;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Tnsu.ApprovalRoute.Domain.Entities;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||||
|
|
||||||
@@ -72,16 +71,13 @@ public sealed class CreateNextListPartitionJob(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Задачу могли поставить раньше, чем реестр успел узнать про текущую
|
// Партиция, на которую сейчас указывает RequestType.ListId, прямо сейчас
|
||||||
// партицию (например, ручным запуском) — регистрируем её, иначе серия
|
// перестаёт быть текущей — записываем её в архив (request_list_partitions
|
||||||
// осталась бы без своего первого номера.
|
// хранит только архив, см. ListPartitionRegistry). До этого момента она
|
||||||
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
// нигде отдельной строкой не значилась.
|
||||||
|
var archivedCurrent = await registry.ArchiveCurrentAsync(requestType, cancellationToken);
|
||||||
|
|
||||||
var maxNumber = await db.RequestListPartitions
|
var nextNumber = archivedCurrent.Number + 1;
|
||||||
.Where(x => x.RequestTypeId == requestTypeId)
|
|
||||||
.MaxAsync(x => (int?)x.Number, cancellationToken) ?? current.Number;
|
|
||||||
|
|
||||||
var nextNumber = maxNumber + 1;
|
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
|
"Создаю партицию №{Number} для типа заявок {RequestTypeId} ({RequestTypeName}) " +
|
||||||
@@ -113,23 +109,12 @@ public sealed class CreateNextListPartitionJob(
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Реестр и переключение записи — одной транзакцией: иначе можно остаться
|
// Новая партиция строкой в request_list_partitions пока не появляется —
|
||||||
// с записанной в реестр партицией, в которую никто не пишет, или наоборот.
|
// она получит свою запись только когда САМА станет архивом, следующим
|
||||||
await using var transaction = await db.Database.BeginTransactionAsync(cancellationToken);
|
// вызовом этой же задачи (см. doc-комментарий класса ListPartitionRegistry).
|
||||||
|
// Здесь переключаем только указатель.
|
||||||
db.RequestListPartitions.Add(new RequestListPartition
|
|
||||||
{
|
|
||||||
RequestTypeId = requestTypeId,
|
|
||||||
Number = nextNumber,
|
|
||||||
ListId = created.ListId,
|
|
||||||
Title = created.Title,
|
|
||||||
CreatedAt = DateTimeOffset.UtcNow
|
|
||||||
});
|
|
||||||
|
|
||||||
requestType.ListId = created.ListId;
|
requestType.ListId = created.ListId;
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
|
"Партиция №{Number} ({ListId}) создана за {ElapsedMs} мс, запись по типу заявок " +
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
|
/// Наполнение реестра партиций. Вынесено отдельно, потому что нужно и читающему
|
||||||
/// эндпоинту, и фоновой задаче: реестр появился позже самих списков, и любой из них
|
/// эндпоинту, и фоновой задаче.
|
||||||
/// может встретить тип заявок, которого в реестре ещё нет.
|
///
|
||||||
|
/// request_list_partitions хранит ТОЛЬКО архив — партиции, которые уже перестали
|
||||||
|
/// быть текущими. Текущая партиция нигде отдельной строкой не пишется: она всегда
|
||||||
|
/// синтезируется на лету из RequestType.ListId/SiteUrl (см. BuildCurrentAsync).
|
||||||
|
/// Иначе один и тот же список оказывался бы записан дважды — и в RequestType.ListId,
|
||||||
|
/// и строкой в этой таблице — сразу же, при первом же чтении, до того как партиция
|
||||||
|
/// реально стала архивом.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ListPartitionRegistry(
|
public sealed class ListPartitionRegistry(
|
||||||
ApprovalRouteDbContext db,
|
ApprovalRouteDbContext db,
|
||||||
@@ -17,16 +23,61 @@ public sealed class ListPartitionRegistry(
|
|||||||
ILogger<ListPartitionRegistry> logger)
|
ILogger<ListPartitionRegistry> logger)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Гарантирует, что текущая партиция типа заявок есть в реестре, и возвращает её строку.
|
/// Партиции, которые УЖЕ архив — всё, что реестр знает о серии, кроме текущей.
|
||||||
///
|
///
|
||||||
/// Реестр самозаполняющийся: до этой фичи списки жили только в
|
/// Фильтр по ListId != RequestType.ListId — не оптимизация, а самовосстановление
|
||||||
/// RequestType.ListId, и переключить список руками в админке можно и сейчас.
|
/// на случай узкого окна в ArchiveCurrentAsync (см. её комментарий): если старая
|
||||||
/// Поэтому неизвестный реестру ListId дописывается следующим номером, а не
|
/// партиция уже успела записаться архивом, а переключение указателя на новую ещё
|
||||||
/// считается ошибкой.
|
/// не прошло (SharePoint упал между этими двумя шагами), без фильтра она была бы
|
||||||
///
|
/// видна СРАЗУ в обоих качествах — и как архив, и как текущая (см.
|
||||||
/// Требует заполненного ListId — вызывающая сторона это проверяет.
|
/// BuildCurrentAsync). Фильтр держит текущую ровно в одном месте, даже в этом
|
||||||
|
/// переходном состоянии.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<RequestListPartition> EnsureCurrentRegisteredAsync(
|
public async Task<IReadOnlyList<RequestListPartition>> GetArchivedAsync(
|
||||||
|
RequestType requestType, CancellationToken cancellationToken) =>
|
||||||
|
await db.RequestListPartitions.AsNoTracking()
|
||||||
|
.Where(x => x.RequestTypeId == requestType.Id && x.ListId != requestType.ListId)
|
||||||
|
.OrderBy(x => x.Number)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Синтезирует запись о ТЕКУЩЕЙ партиции — без обращения к request_list_partitions
|
||||||
|
/// на запись: строка появится там только когда эта партиция реально станет
|
||||||
|
/// архивом (см. ArchiveCurrentAsync). Номер — следующий за последним архивным
|
||||||
|
/// (или 1, если архива ещё нет); Title читается REST'ом каждый раз, а не
|
||||||
|
/// кешируется — кешировать было бы негде без строки в БД.
|
||||||
|
///
|
||||||
|
/// archived — уже загруженный список архивных партиций этого типа (см.
|
||||||
|
/// GetArchivedAsync), передаётся, а не читается заново, чтобы вызывающая
|
||||||
|
/// сторона (которой archived обычно уже нужен для остальной серии) не делала
|
||||||
|
/// один и тот же запрос дважды.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RequestListPartitionDto> BuildCurrentAsync(
|
||||||
|
RequestType requestType, IReadOnlyList<RequestListPartition> archived, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var number = archived.Count == 0 ? 1 : archived.Max(x => x.Number) + 1;
|
||||||
|
var title = await TryGetTitleAsync(requestType.SiteUrl!, requestType.ListId!, cancellationToken);
|
||||||
|
|
||||||
|
return new RequestListPartitionDto(
|
||||||
|
number, requestType.SiteUrl!, requestType.ListId!, title, IsCurrent: true, DateTimeOffset.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Записывает ТЕКУЩУЮ партицию как архивную строку — вызывать строго в момент,
|
||||||
|
/// когда она перестаёт быть текущей (единственный вызывающий —
|
||||||
|
/// CreateNextListPartitionJob, прямо перед тем как переключить
|
||||||
|
/// RequestType.ListId на следующую партицию).
|
||||||
|
///
|
||||||
|
/// Читает свой собственный, НЕфильтрованный список существующих партиций —
|
||||||
|
/// намеренно не переиспользует GetArchivedAsync(): та фильтрует по
|
||||||
|
/// ListId != RequestType.ListId, и ровно между этим вызовом и переключением
|
||||||
|
/// указателя (см. CreateNextListPartitionJob) только что записанная строка
|
||||||
|
/// на мгновение совпадает с текущим ListId — фильтр GetArchivedAsync её бы не
|
||||||
|
/// увидел, идемпотентность сломалась бы, и повтор Hangfire упал бы на
|
||||||
|
/// уникальном индексе (request_type_id, number) вместо того, чтобы найти уже
|
||||||
|
/// записанное и вернуть его.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<RequestListPartition> ArchiveCurrentAsync(
|
||||||
RequestType requestType, CancellationToken cancellationToken)
|
RequestType requestType, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var series = await db.RequestListPartitions
|
var series = await db.RequestListPartitions
|
||||||
@@ -49,14 +100,14 @@ public sealed class ListPartitionRegistry(
|
|||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Список {ListId} типа заявок {RequestTypeId} добавлен в реестр под номером {Number}.",
|
"Список {ListId} типа заявок {RequestTypeId} заархивирован под номером {Number}.",
|
||||||
entity.ListId, requestType.Id, entity.Number);
|
entity.ListId, requestType.Id, entity.Number);
|
||||||
|
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Заголовок нужен только для отображения в админке/портале — из-за его
|
// Заголовок нужен только для отображения в админке/портале — из-за его
|
||||||
// отсутствия регистрация партиции падать не должна.
|
// отсутствия чтение партиции падать не должно.
|
||||||
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
private async Task<string?> TryGetTitleAsync(string siteUrl, string listId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -44,7 +44,11 @@ public sealed class ListPartitionService(
|
|||||||
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
|
return Result.Fail<CurrentListPartitionDto>(new ValidationError(
|
||||||
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
$"У типа заявки {requestTypeId} не настроен список SharePoint."));
|
||||||
|
|
||||||
var current = await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
// Синтезируется на лету, не пишется в request_list_partitions — см.
|
||||||
|
// ListPartitionRegistry.BuildCurrentAsync: строка появится там только когда
|
||||||
|
// партиция реально станет архивом.
|
||||||
|
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
|
||||||
|
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||||
|
|
||||||
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
|
var itemCount = await TryGetItemCountAsync(requestType.SiteUrl, requestType.ListId, cancellationToken);
|
||||||
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
|
var queued = await TryEnqueueProvisioningAsync(requestType, current.Number, itemCount, cancellationToken);
|
||||||
@@ -96,14 +100,15 @@ public sealed class ListPartitionService(
|
|||||||
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
if (string.IsNullOrWhiteSpace(requestType.ListId) || string.IsNullOrWhiteSpace(requestType.SiteUrl))
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
await registry.EnsureCurrentRegisteredAsync(requestType, cancellationToken);
|
var archived = await registry.GetArchivedAsync(requestType, cancellationToken);
|
||||||
|
var current = await registry.BuildCurrentAsync(requestType, archived, cancellationToken);
|
||||||
|
|
||||||
return await db.RequestListPartitions.AsNoTracking()
|
var result = new List<RequestListPartitionDto>(archived.Count + 1);
|
||||||
.Where(x => x.RequestTypeId == requestType.Id)
|
result.AddRange(archived.Select(x =>
|
||||||
.OrderBy(x => x.Number)
|
new RequestListPartitionDto(x.Number, requestType.SiteUrl!, x.ListId, x.Title, IsCurrent: false, x.CreatedAt)));
|
||||||
.Select(x => new RequestListPartitionDto(
|
result.Add(current);
|
||||||
x.Number, requestType.SiteUrl!, x.ListId, x.Title, x.ListId == requestType.ListId, x.CreatedAt))
|
|
||||||
.ToListAsync(cancellationToken);
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -65,27 +65,26 @@ public sealed class ReminderService(
|
|||||||
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
/// списке, и не должна пропадать из напоминаний только из-за того, что для
|
||||||
/// новых заявок завели следующий список.
|
/// новых заявок завели следующий список.
|
||||||
///
|
///
|
||||||
/// EnsureCurrentRegisteredAsync — на случай, если этот тип заявок ни разу не
|
/// request_list_partitions хранит только архив (см. ListPartitionRegistry) —
|
||||||
/// читали через ListPartitionService (сам реестр партиций мог ещё не знать
|
/// текущая партиция синтезируется отдельно и добавляется к архиву здесь же,
|
||||||
/// про текущий список): без неё серия была бы пустой и напоминания молча не
|
/// а не читается из БД.
|
||||||
/// строились бы даже по текущему списку.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForTypeAsync(
|
||||||
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
RequestType type, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await partitionRegistry.EnsureCurrentRegisteredAsync(type, cancellationToken);
|
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
|
||||||
|
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
|
||||||
|
|
||||||
var partitions = await db.RequestListPartitions.AsNoTracking()
|
var partitions = archived
|
||||||
.Where(x => x.RequestTypeId == type.Id)
|
.Select(x => (x.ListId, x.Number))
|
||||||
.OrderBy(x => x.Number)
|
.Append((current.ListId, current.Number));
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var reminders = new List<ReminderDto>();
|
var reminders = new List<ReminderDto>();
|
||||||
foreach (var partition in partitions)
|
foreach (var (listId, number) in partitions)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
reminders.AddRange(await GetOverdueForPartitionAsync(type, partition, now, cancellationToken));
|
reminders.AddRange(await GetOverdueForPartitionAsync(type, listId, number, now, cancellationToken));
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
@@ -94,7 +93,7 @@ public sealed class ReminderService(
|
|||||||
// остальным партициям того же типа.
|
// остальным партициям того же типа.
|
||||||
logger.LogError(
|
logger.LogError(
|
||||||
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
|
e, "Напоминания по списку {ListId} (партиция №{Number}) типа «{Type}» не собраны",
|
||||||
partition.ListId, partition.Number, type.Name);
|
listId, number, type.Name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,20 +101,18 @@ public sealed class ReminderService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
|
private async Task<IReadOnlyList<ReminderDto>> GetOverdueForPartitionAsync(
|
||||||
RequestType type, RequestListPartition partition, DateTimeOffset now, CancellationToken cancellationToken)
|
RequestType type, string listId, int number, DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var siteUrl = type.SiteUrl!;
|
var siteUrl = type.SiteUrl!;
|
||||||
|
|
||||||
// Читаем Title ЖИВЬЁМ, а не из partition.Title (закешированного на момент
|
// Читаем Title ЖИВЬЁМ — заголовок могли переименовать вручную, а по url
|
||||||
// создания партиции) — тот же принцип, что был здесь и раньше для
|
// список найдётся всегда.
|
||||||
// единственного списка: заголовок могли переименовать вручную, и кеш
|
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
|
||||||
// тогда соврёт, а по url список найдётся всегда.
|
|
||||||
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, partition.ListId, cancellationToken);
|
|
||||||
if (string.IsNullOrWhiteSpace(dataListTitle))
|
if (string.IsNullOrWhiteSpace(dataListTitle))
|
||||||
{
|
{
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
|
"Список данных партиции №{Number} типа «{Type}» не найден по url {Url}",
|
||||||
partition.Number, type.Name, partition.ListId);
|
number, type.Name, listId);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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); // новая партиция строки ещё не получила
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user