Sla (#11)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
+293
@@ -0,0 +1,293 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Shouldly;
|
||||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||||
|
||||
/// <summary>
|
||||
/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA:
|
||||
/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент
|
||||
/// выводится из соседних строк — это самая хрупкая часть эндпоинта.
|
||||
///
|
||||
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||||
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
|
||||
/// </summary>
|
||||
public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture>
|
||||
{
|
||||
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
|
||||
private const string DataListTitle = "Заявка тест — данные";
|
||||
private const string ApprovalListTitle = "Заявка тест — согласование";
|
||||
|
||||
private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
private readonly ServiceProvider _services;
|
||||
private readonly RequestTypeService _requestTypeService;
|
||||
|
||||
public ReminderServiceTests(PostgresDatabaseFixture fixture)
|
||||
{
|
||||
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||||
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||||
}
|
||||
|
||||
// База у тестов класса общая, а сервис обходит ВСЕ типы с настроенным SLA —
|
||||
// поэтому у каждого теста свой список: типы соседних тестов на мок не
|
||||
// настроены, заголовок списка для них не найдётся, и в выборку они не попадут.
|
||||
private async Task<string> CreateTypeWithSlaAsync(string name, int slaHours, bool? askOverdueReason = null)
|
||||
{
|
||||
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
|
||||
await _requestTypeService.CreateAsync(
|
||||
new CreateRequestTypeRequest(name, $"/{name}", listUrl, SiteUrl, "General", slaHours, askOverdueReason),
|
||||
CancellationToken.None);
|
||||
|
||||
return listUrl;
|
||||
}
|
||||
|
||||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||||
|
||||
private static object Row(
|
||||
int id, int ticketId, int stepOrder, string status, string phase,
|
||||
string? date = null, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
||||
new
|
||||
{
|
||||
Id = id,
|
||||
TicketId = ticketId,
|
||||
StepOrder = stepOrder,
|
||||
ApprovalStatus = status,
|
||||
Phase = phase,
|
||||
Date = date,
|
||||
SLAhours = slaHours,
|
||||
AskOverdueReason = ask,
|
||||
TansuSiteApprover = new { EMail = email }
|
||||
};
|
||||
|
||||
private ReminderService BuildService(
|
||||
string listUrl, IReadOnlyList<object> allRows, DateTimeOffset requestCreated)
|
||||
{
|
||||
var sharePoint = new Mock<ISharePointClient>();
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(DataListTitle);
|
||||
|
||||
// Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
|
||||
// отвечает так же: по значению статуса, а не по признаку решённости.
|
||||
var activeRows = allRows
|
||||
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
||||
"На согласовании" or "На исполнении")
|
||||
.ToList();
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListTitleAsync(
|
||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(activeRows.Select(Json).ToList());
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListTitleAsync(
|
||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(allRows.Select(Json).ToList());
|
||||
|
||||
sharePoint
|
||||
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]);
|
||||
|
||||
return new ReminderService(
|
||||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||||
sharePoint.Object,
|
||||
Options.Create(new RemindersOptions()),
|
||||
NullLogger<ReminderService>.Instance);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")]
|
||||
public async Task FirstApprovalStep_CountsFromRequestCreated()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24);
|
||||
// Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-48));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.IsSuccess.ShouldBeTrue();
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
reminder.Email.ShouldBe("approver@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(24);
|
||||
reminder.RequestId.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
|
||||
public async Task WithinSla_ReturnsNothing()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-2));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Второй шаг: SLA отсчитывается от решения предыдущего шага, а не от создания заявки")]
|
||||
public async Task SecondStep_CountsFromPreviousStepDecision()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla second step", slaHours: 10);
|
||||
// Заявка создана давно, но предыдущий шаг решён 12 часов назад:
|
||||
// при отсчёте от создания просрочка была бы огромной, верная — 2 часа.
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
||||
Row(11, 1, 2, "На согласовании", "Согласование", email: "second@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
reminder.Email.ShouldBe("second@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Параллельный предыдущий шаг: отсчёт от ПОСЛЕДНЕГО решения, а не от первого")]
|
||||
public async Task ParallelPreviousStep_CountsFromLastDecision()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla parallel", slaHours: 10);
|
||||
// Двое делят StepOrder = 1. Следующий шаг активируется только после
|
||||
// решения второго (-12 ч), а не первого (-40 ч).
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
||||
Row(11, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-12).ToString("O")),
|
||||
Row(12, 1, 2, "На согласовании", "Согласование", email: "next@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Первый шаг исполнения: отсчёт от последнего решения фазы согласования")]
|
||||
public async Task FirstExecutionStep_CountsFromLastApprovalDecision()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla execution", slaHours: 5);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-30).ToString("O")),
|
||||
Row(11, 1, 2, "Согласовано", "Согласование", date: Now.AddHours(-8).ToString("O")),
|
||||
Row(12, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
var reminder = result.Value.ShouldHaveSingleItem();
|
||||
reminder.Email.ShouldBe("executor@tnsu.kz");
|
||||
reminder.HoursOverdue.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
|
||||
public async Task RowSla_OverridesRequestTypeSla()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla row override", slaHours: 100);
|
||||
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1)],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
|
||||
}
|
||||
|
||||
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
|
||||
// разбор спотыкался: JsonElement.TryGetInt32 на числе с десятичной точкой
|
||||
// возвращает false, поле молча становилось null и SLA строки терялся.
|
||||
// На фронтенде проблемы не было — в JavaScript 1.0 и 1 неразличимы.
|
||||
[Fact(DisplayName = "SLA строки читается, даже когда SharePoint отдаёт его дробным числом")]
|
||||
public async Task RowSla_WrittenAsDecimalNumber_IsStillRead()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla decimal", slaHours: 100);
|
||||
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
||||
var service = BuildService(listUrl,
|
||||
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
|
||||
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
|
||||
// воспроизводит.
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1.0m)],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
|
||||
public async Task AskOverdueReason_InheritsFromRequestType()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 1, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().AskOverdueReason.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
|
||||
// живым примером:
|
||||
// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11
|
||||
[Fact(DisplayName = "Ссылка на заявку собирается в том же виде, что и в портале")]
|
||||
public async Task Url_MatchesPortalFormat()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[Row(10, 11, 1, "На согласовании", "Согласование")],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().Url.ShouldBe(
|
||||
"https://tnsukz.sharepoint.com/sites/requests" +
|
||||
"?rpnav=approvals&rpmode=approve&rptype=/it-1c-ticket&rpid=11");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Просроченный шаг исполнения ведёт в раздел «На исполнении», а не в согласование")]
|
||||
public async Task Url_ForExecutionPhase_PointsToExecutionSection()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla exec nav", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "Согласовано", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
||||
Row(11, 1, 1, "На исполнении", "Исполнение", email: "executor@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddDays(-30));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().Url.ShouldContain("rpnav=execution");
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Ждущие очереди строки в напоминания не попадают")]
|
||||
public async Task QueuedRows_AreIgnored()
|
||||
{
|
||||
var listUrl = await CreateTypeWithSlaAsync("Sla queued", slaHours: 1);
|
||||
var service = BuildService(listUrl,
|
||||
[
|
||||
Row(10, 1, 1, "На согласовании", "Согласование"),
|
||||
Row(11, 1, 2, "Ждёт очереди", "Согласование", email: "waiting@tnsu.kz")
|
||||
],
|
||||
requestCreated: Now.AddHours(-6));
|
||||
|
||||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||||
|
||||
result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz");
|
||||
}
|
||||
}
|
||||
+74
@@ -181,6 +181,80 @@ public sealed class TemplateRouteServiceTests : IClassFixture<PostgresDatabaseFi
|
||||
resolved.Value.Steps[0].ExecutorEmail.ShouldBe("approver@tnsu.kz");
|
||||
}
|
||||
|
||||
// SLA приезжает в резолв уже с применённым наследованием — это осознанное
|
||||
// решение: строку списка согласования пишет request-portal, и он не должен
|
||||
// повторять у себя правило «шаг перекрывает тип».
|
||||
private async Task<int> CreateRequestTypeWithSlaAsync(string name, int? slaHours, bool? askOverdueReason) =>
|
||||
(await _requestTypeService.CreateAsync(
|
||||
new CreateRequestTypeRequest(name, $"/{name}", null, null, "General", slaHours, askOverdueReason),
|
||||
CancellationToken.None)).Value.Id;
|
||||
|
||||
private async Task<ResolvedRouteStepDto> ResolveSingleStepWithSlaAsync(
|
||||
string requestTypeName, int? typeSla, bool? typeAsk, int? stepSla, bool? stepAsk)
|
||||
{
|
||||
var requestTypeId = await CreateRequestTypeWithSlaAsync(requestTypeName, typeSla, typeAsk);
|
||||
var route = await _service.CreateAsync(
|
||||
new CreateTemplateRouteRequest(requestTypeId, DepartmentId, CompanyId, ByProject: false, RequiresControl: false),
|
||||
CancellationToken.None);
|
||||
|
||||
var stepService = _services.GetRequiredService<TemplateRouteStepService>();
|
||||
await stepService.CreateAsync(
|
||||
route.Value.Id,
|
||||
new CreateTemplateRouteStepRequest(
|
||||
1, "approver@tnsu.kz", null, null, null, RouteStepStatus.Approval, false, null, null, null,
|
||||
stepSla, stepAsk),
|
||||
CancellationToken.None);
|
||||
|
||||
var resolved = await _service.ResolveAsync(
|
||||
new ResolveTemplateRouteRequest(requestTypeId, "someone@tnsu.kz", null, RouteStepStatus.Approval),
|
||||
CancellationToken.None);
|
||||
|
||||
resolved.IsSuccess.ShouldBeTrue();
|
||||
return resolved.Value.Steps.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв: шаг без своего SLA наследует часы и флаг от типа заявки")]
|
||||
public async Task ResolveAsync_StepWithoutSla_InheritsFromRequestType()
|
||||
{
|
||||
var step = await ResolveSingleStepWithSlaAsync(
|
||||
"Sla inherit", typeSla: 48, typeAsk: true, stepSla: null, stepAsk: null);
|
||||
|
||||
step.SlaHours.ShouldBe(48);
|
||||
step.AskOverdueReason.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв: SLA шага перекрывает SLA типа заявки")]
|
||||
public async Task ResolveAsync_StepSla_OverridesRequestTypeSla()
|
||||
{
|
||||
var step = await ResolveSingleStepWithSlaAsync(
|
||||
"Sla override", typeSla: 48, typeAsk: true, stepSla: 8, stepAsk: false);
|
||||
|
||||
step.SlaHours.ShouldBe(8);
|
||||
step.AskOverdueReason.ShouldBe(false);
|
||||
}
|
||||
|
||||
// Ключевое отличие «не задано» от «ноль»: ноль на шаге — это осознанное
|
||||
// «напоминать сразу», и подменять его типовым значением нельзя.
|
||||
[Fact(DisplayName = "Резолв: заданный на шаге SLA = 0 не подменяется значением типа заявки")]
|
||||
public async Task ResolveAsync_StepSlaZero_IsNotReplacedByRequestTypeSla()
|
||||
{
|
||||
var step = await ResolveSingleStepWithSlaAsync(
|
||||
"Sla zero", typeSla: 48, typeAsk: true, stepSla: 0, stepAsk: false);
|
||||
|
||||
step.SlaHours.ShouldBe(0);
|
||||
step.AskOverdueReason.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв: SLA не настроен ни на шаге, ни на типе — в ответе null")]
|
||||
public async Task ResolveAsync_NoSlaAnywhere_ReturnsNull()
|
||||
{
|
||||
var step = await ResolveSingleStepWithSlaAsync(
|
||||
"Sla absent", typeSla: null, typeAsk: null, stepSla: null, stepAsk: null);
|
||||
|
||||
step.SlaHours.ShouldBeNull();
|
||||
step.AskOverdueReason.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact(DisplayName = "Резолв маршрута схлопывает шаги, резолвящиеся в одинаковый email, оставляя шаг с меньшим OrderNo")]
|
||||
public async Task ResolveAsync_StepsResolveToSameEmail_CollapsesToLowestOrderNo()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user