Files
tnsu-approval-route/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/ReminderServiceTests.cs
T
2026-08-12 15:12:11 +05:00

286 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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", int? 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);
sharePoint
.Setup(x => x.GetListServerRelativeUrlAsync(SiteUrl, ApprovalListTitle, It.IsAny<CancellationToken>()))
.ReturnsAsync("/sites/requests/Lists/ApprovalListRelUrl");
// Search сужает набор до активных строк (На согласовании/На исполнении) —
// мок отвечает так же, как реальный Search: по статусу строки, не по дате.
var activeRows = allRows
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
"На согласовании" or "На исполнении")
.ToList();
sharePoint
.Setup(x => x.SearchListItemIdsAsync(
SiteUrl, It.Is<string>(q => q.Contains("ApprovalStatusOWSCHCS")), It.IsAny<int>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(activeRows.Select(r => (int)r.GetType().GetProperty("Id")!.GetValue(r)!).ToList());
// Дочитывание полных полей найденных Search'ем строк — по Id. Матчер ищет
// "filter=Id eq", а не просто "Id eq" — иначе он совпал бы и с
// "TicketId eq" (та подстрока в нём есть буквально), и два setup'а
// конфликтовали бы между собой.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("filter=Id 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);
}
[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");
}
}