254 lines
14 KiB
C#
254 lines
14 KiB
C#
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.ListPartitioning;
|
|
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, теперь читается прямо из активной строки
|
|
/// (колонка Date — request-portal проставляет её в момент, когда строка
|
|
/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше
|
|
/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты
|
|
/// и проверяют вместо прежнего вывода даты из соседних строк.
|
|
///
|
|
/// 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, string status, string phase,
|
|
string? date, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
|
|
new
|
|
{
|
|
Id = id,
|
|
TicketId = ticketId,
|
|
ApprovalStatus = status,
|
|
Phase = phase,
|
|
Date = date,
|
|
SLAhours = slaHours,
|
|
AskOverdueReason = ask,
|
|
TansuSiteApprover = new { EMail = email }
|
|
};
|
|
|
|
// Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) —
|
|
// мок отвечает так же: по значению статуса, а не по признаку решённости.
|
|
// Никаких запросов ни по TicketId, ни к списку данных за Created сервис
|
|
// больше не делает — если он вдруг снова начнёт, немонастроенный мок
|
|
// (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой,
|
|
// а не тихо проигнорирует лишний запрос.
|
|
private ReminderService BuildService(string listUrl, IReadOnlyList<object> rows)
|
|
{
|
|
var sharePoint = new Mock<ISharePointClient>();
|
|
|
|
sharePoint
|
|
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(DataListTitle);
|
|
|
|
var activeRows = rows
|
|
.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());
|
|
|
|
return new ReminderService(
|
|
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
|
sharePoint.Object,
|
|
_services.GetRequiredService<ListPartitionRegistry>(),
|
|
Options.Create(new RemindersOptions()),
|
|
NullLogger<ReminderService>.Instance);
|
|
}
|
|
|
|
[Fact(DisplayName = "SLA отсчитывается от Date активной строки")]
|
|
public async Task ActiveRow_CountsFromOwnDate()
|
|
{
|
|
var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24);
|
|
// Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
|
var service = BuildService(listUrl,
|
|
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
|
|
|
|
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, "На согласовании", "Согласование", date: Now.AddHours(-2).ToString("O"))]);
|
|
|
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
|
|
|
result.Value.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact(DisplayName = "Другие строки того же маршрута сервису не нужны — читается только активная")]
|
|
public async Task OtherRoutRows_AreIrrelevant()
|
|
{
|
|
var listUrl = await CreateTypeWithSlaAsync("Sla ignores history", slaHours: 10);
|
|
// Решённые строки маршрута заведомо не попадут в ответ на $filter по
|
|
// ApprovalStatus (мок это тоже соблюдает) — но даже будь они прочитаны,
|
|
// сервис их дату уже не использует: отсчёт идёт только от Date
|
|
// активной строки.
|
|
var service = BuildService(listUrl,
|
|
[
|
|
Row(10, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
|
|
Row(11, 1, "На согласовании", "Согласование", date: Now.AddHours(-12).ToString("O"))
|
|
]);
|
|
|
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
|
|
|
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
|
|
}
|
|
|
|
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
|
public async Task ActiveRow_WithoutDate_IsSkipped()
|
|
{
|
|
var listUrl = await CreateTypeWithSlaAsync("Sla missing date", slaHours: 1);
|
|
var service = BuildService(listUrl,
|
|
[Row(10, 1, "На согласовании", "Согласование", date: null)]);
|
|
|
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
|
|
|
result.IsSuccess.ShouldBeTrue();
|
|
result.Value.ShouldBeEmpty();
|
|
}
|
|
|
|
[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, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1)]);
|
|
|
|
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, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1.0m)]);
|
|
|
|
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, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
|
|
|
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, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
|
|
|
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, "На исполнении", "Исполнение", date: Now.AddHours(-6).ToString("O"), email: "executor@tnsu.kz")]);
|
|
|
|
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, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O")),
|
|
Row(11, 1, "Ждёт очереди", "Согласование", date: null, email: "waiting@tnsu.kz")
|
|
]);
|
|
|
|
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
|
|
|
result.Value.ShouldHaveSingleItem().Email.ShouldBe("approver@tnsu.kz");
|
|
}
|
|
}
|