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.Approvals; using Tnsu.ApprovalRoute.Application.ListPartitioning; 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, — как в ReminderServiceTests; записи (MERGE) проверяются /// через Verify на моке, потому что именно записи здесь и есть результат работы /// сервиса, а возвращает он только счётчик. /// /// Все проверки идут через — маленький «список в памяти»: мок /// отдаёт из него и выборку кандидатов, и строки заявки, и применяет к нему /// записи. Без этого тесты, где решение по одной строке двигает очередь, пришлось /// бы писать на жёстко заданных последовательностях ответов, и они проверяли бы /// порядок вызовов, а не поведение. /// public sealed class AutoApproveServiceTests : 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, 9, 11, 12, 0, 0, TimeSpan.Zero); private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } private readonly ServiceProvider _services; private readonly RequestTypeService _requestTypeService; public AutoApproveServiceTests(PostgresDatabaseFixture fixture) { _services = TestServiceProviderFactory.Build(fixture.ConnectionString); _requestTypeService = _services.GetRequiredService(); } /// /// База у тестов класса общая, а сервис обходит ВСЕ типы заявок — поэтому у /// каждого теста свой список: типы соседних тестов на мок не настроены, /// заголовок списка для них не найдётся, и в выборку они не попадут. /// private async Task CreateTypeAsync(string name, int? slaHours = null) { var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}"; await _requestTypeService.CreateAsync( new CreateRequestTypeRequest(name, $"/{name}", listUrl, SiteUrl, "General", slaHours), CancellationToken.None); return listUrl; } /// Строка списка согласования в том виде, в каком её отдаёт REST. private sealed record Row( int Id, int TicketId, string Status, double Order, string Phase = "Согласование", string? Date = null, int? SlaHours = null, bool AutoApprove = false) { public JsonElement ToJson() => JsonSerializer.SerializeToElement(new { Id, TicketId, ApprovalStatus = Status, Phase, Date, SLAhours = SlaHours, AutoApprove, StepOrder = Order, TansuSiteApprover = new { EMail = "approver@tnsu.kz" } }); } /// Записанные сервисом поля: (id элемента, имя колонки) → значение. private sealed class Writes { public List<(string List, int Id, Dictionary Fields)> Items { get; } = []; public Dictionary? For(string list, int id) => Items.LastOrDefault(x => x.List == list && x.Id == id).Fields; } private (AutoApproveService Service, Writes Writes) BuildService( string listUrl, List rows, DateTimeOffset? now = null) { var sharePoint = new Mock(); var writes = new Writes(); sharePoint .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) .ReturnsAsync(DataListTitle); // Кандидаты: активные строки с включённым автосогласованием — тот же отбор, // что делает $filter на стороне SharePoint. sharePoint .Setup(x => x.GetItemsByListTitleAsync( SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("AutoApprove eq 1")), It.IsAny())) .ReturnsAsync(() => rows .Where(r => r.AutoApprove && r.Status is "На согласовании" or "На исполнении") .Select(r => r.ToJson()).ToList()); // Строки одной заявки — $filter=TicketId eq N. sharePoint .Setup(x => x.GetItemsByListTitleAsync( SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("TicketId eq")), It.IsAny())) .ReturnsAsync((string _, string _, string query, CancellationToken _) => { var ticketId = int.Parse(query.Split("TicketId eq ")[1].Split('&')[0].Trim()); return rows.Where(r => r.TicketId == ticketId).Select(r => r.ToJson()).ToList(); }); // Запись в строку маршрута применяется к самому списку rows — чтобы // следующее чтение видело уже изменённое состояние, как в SharePoint. sharePoint .Setup(x => x.UpdateItemByListTitleAsync( SiteUrl, ApprovalListTitle, It.IsAny(), It.IsAny(), It.IsAny())) .Callback((string _, string list, int id, object fields, CancellationToken _) => { var map = ToMap(fields); writes.Items.Add((list, id, map)); var index = rows.FindIndex(r => r.Id == id); if (index >= 0 && map.TryGetValue("ApprovalStatus", out var status)) rows[index] = rows[index] with { Status = (string)status! }; }) .Returns(Task.CompletedTask); sharePoint .Setup(x => x.UpdateItemByListUrlAsync( SiteUrl, listUrl, It.IsAny(), It.IsAny(), It.IsAny())) .Callback((string _, string list, int id, object fields, CancellationToken _) => writes.Items.Add((list, id, ToMap(fields)))) .Returns(Task.CompletedTask); var service = new AutoApproveService( _services.GetRequiredService(), sharePoint.Object, _services.GetRequiredService(), new FixedTimeProvider(now ?? Now), Options.Create(new AutoApproveOptions()), NullLogger.Instance); return (service, writes); } // Анонимный объект, который сервис отдаёт клиенту SharePoint, — в словарь: // сравнивать поля по именам колонок нагляднее, чем городить ожидаемый // анонимный тип той же формы. private static Dictionary ToMap(object fields) => fields.GetType().GetProperties().ToDictionary(p => p.Name, p => p.GetValue(fields)); [Fact(DisplayName = "SLA истёк — шаг согласуется, очередь двигается, статус заявки пересчитан")] public async Task Overdue_ApprovesAndAdvancesQueue() { var listUrl = await CreateTypeAsync("Auto basic", slaHours: 24); var rows = new List { new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true), new(11, 1, "Ждёт очереди", 2) }; var (service, writes) = BuildService(listUrl, rows); var approved = await service.RunAsync(CancellationToken.None); approved.ShouldBe(1); var decided = writes.For(ApprovalListTitle, 10).ShouldNotBeNull(); decided["ApprovalStatus"].ShouldBe("Согласовано"); decided["Date"].ShouldBe(Now.UtcDateTime); decided["Comment"].ShouldBe(new AutoApproveOptions().Comment); // Следующий шаг активирован — со своей датой активации, иначе от него не // отсчитать ни напоминание, ни собственное автосогласование. var activated = writes.For(ApprovalListTitle, 11).ShouldNotBeNull(); activated["ApprovalStatus"].ShouldBe("На согласовании"); activated["Date"].ShouldBe(Now.UtcDateTime); writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании"); } [Fact(DisplayName = "Срок ещё не вышел — строка не трогается")] public async Task WithinSla_DoesNothing() { var listUrl = await CreateTypeAsync("Auto not due", slaHours: 24); var rows = new List { new(10, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O"), AutoApprove: true) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(0); writes.Items.ShouldBeEmpty(); } [Fact(DisplayName = "Флаг выключен — просроченный шаг остаётся ждать человека")] public async Task WithoutFlag_DoesNothing() { var listUrl = await CreateTypeAsync("Auto flag off", slaHours: 24); var rows = new List { new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O")) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(0); writes.Items.ShouldBeEmpty(); } [Fact(DisplayName = "SLA шага перекрывает SLA типа заявки")] public async Task RowSla_OverridesTypeSla() { var listUrl = await CreateTypeAsync("Auto row sla", slaHours: 100); var rows = new List { // По типу (100 ч) срок ещё не вышел, по шагу (1 ч) — вышел. new(10, 1, "На согласовании", 1, Date: Now.AddHours(-5).ToString("O"), SlaHours: 1, AutoApprove: true) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(1); writes.For(ApprovalListTitle, 10).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Согласовано"); } [Fact(DisplayName = "SLA не задан нигде — шаг пропущен, а не согласован немедленно")] public async Task WithoutSla_Skipped() { var listUrl = await CreateTypeAsync("Auto no sla"); var rows = new List { new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(0); writes.Items.ShouldBeEmpty(); } [Fact(DisplayName = "Дата активации не заполнена — отсчитывать не от чего, шаг пропущен")] public async Task WithoutDate_Skipped() { var listUrl = await CreateTypeAsync("Auto no date", slaHours: 24); var rows = new List { new(10, 1, "На согласовании", 1, AutoApprove: true) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(0); writes.Items.ShouldBeEmpty(); } [Fact(DisplayName = "Последний шаг согласования без исполнителей — заявка «Согласовано»")] public async Task LastApprovalStep_ClosesRequestAsApproved() { var listUrl = await CreateTypeAsync("Auto last step", slaHours: 24); var rows = new List { new(10, 1, "Согласовано", 1), new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(1); writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Согласовано"); } [Fact(DisplayName = "Согласование пройдено — активируется фаза исполнения, заявка «Выполнение»")] public async Task LastApprovalStep_ActivatesExecutionPhase() { var listUrl = await CreateTypeAsync("Auto to execution", slaHours: 24); var rows = new List { new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true), new(11, 1, "Ждёт очереди", 2, Phase: "Исполнение") }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(1); writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("На исполнении"); writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Выполнение"); } [Fact(DisplayName = "Шаг исполнения закрывается как «Выполнено», заявка — «Закрыта»")] public async Task ExecutionStep_MarkedExecuted() { var listUrl = await CreateTypeAsync("Auto execution", slaHours: 24); var rows = new List { new(10, 1, "Согласовано", 1), new(11, 1, "На исполнении", 2, Phase: "Исполнение", Date: Now.AddHours(-48).ToString("O"), AutoApprove: true) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(1); writes.For(ApprovalListTitle, 11).ShouldNotBeNull()["ApprovalStatus"].ShouldBe("Выполнено"); writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Закрыта"); } [Fact(DisplayName = "Параллельный шаг: решён только один — очередь не двигается")] public async Task ParallelStep_DoesNotAdvanceWhileSiblingPending() { var listUrl = await CreateTypeAsync("Auto parallel", slaHours: 24); var rows = new List { new(10, 1, "На согласовании", 1, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true), // Второй согласующий того же шага (общий StepOrder) решения ещё не принял. new(11, 1, "На согласовании", 1, Date: Now.AddHours(-2).ToString("O")), new(12, 1, "Ждёт очереди", 2) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(1); writes.For(ApprovalListTitle, 12).ShouldBeNull(); writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("На согласовании"); } [Fact(DisplayName = "Шаг отклонён человеком — очередь не двигается, статус заявки «Отклонено»")] public async Task RejectedSibling_StopsQueue() { var listUrl = await CreateTypeAsync("Auto rejected", slaHours: 24); var rows = new List { new(10, 1, "Отклонено", 1), new(11, 1, "На согласовании", 2, Date: Now.AddHours(-48).ToString("O"), AutoApprove: true), new(12, 1, "Ждёт очереди", 3) }; var (service, writes) = BuildService(listUrl, rows); (await service.RunAsync(CancellationToken.None)).ShouldBe(1); writes.For(ApprovalListTitle, 12).ShouldBeNull(); writes.For(listUrl, 1).ShouldNotBeNull()["RequestStatus"].ShouldBe("Отклонено"); } }