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; /// /// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA: /// отдельной колонки «шаг стал активным» в списке согласования нет, и момент /// выводится из соседних строк — это самая хрупкая часть эндпоинта. /// /// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его /// возвращает REST, — так тесты заодно покрывают и разбор ответа. /// public sealed class ReminderServiceTests : IClassFixture { 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(); } // База у тестов класса общая, а сервис обходит ВСЕ типы с настроенным SLA — // поэтому у каждого теста свой список: типы соседних тестов на мок не // настроены, заголовок списка для них не найдётся, и в выборку они не попадут. private async Task 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 allRows, DateTimeOffset requestCreated) { var sharePoint = new Mock(); sharePoint .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) .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(q => q.Contains("ApprovalStatus eq")), It.IsAny())) .ReturnsAsync(activeRows.Select(Json).ToList()); sharePoint .Setup(x => x.GetItemsByListTitleAsync( SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("TicketId eq")), It.IsAny())) .ReturnsAsync(allRows.Select(Json).ToList()); sharePoint .Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny(), It.IsAny())) .ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]); return new ReminderService( _services.GetRequiredService(), sharePoint.Object, Options.Create(new RemindersOptions()), NullLogger.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"); } }