Files
tnsu-approval-route/backend/tests/Tnsu.ApprovalRoute.IntegrationTests/ApplicationServices/AutoApproveServiceTests.cs
T
gorbenko.pavel 2b8fe77fac
Tests / frontend-test (push) Successful in 46s
Tests / backend-test (push) Successful in 3m39s
Auto approve (#21)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
2026-09-14 09:01:56 +00:00

354 lines
17 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.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;
/// <summary>
/// Автосогласование по SLA. SharePoint подменён моком, отдающим сырой JSON в том
/// же виде, что и REST, — как в ReminderServiceTests; записи (MERGE) проверяются
/// через Verify на моке, потому что именно записи здесь и есть результат работы
/// сервиса, а возвращает он только счётчик.
///
/// Все проверки идут через <see cref="Rows"/> — маленький «список в памяти»: мок
/// отдаёт из него и выборку кандидатов, и строки заявки, и применяет к нему
/// записи. Без этого тесты, где решение по одной строке двигает очередь, пришлось
/// бы писать на жёстко заданных последовательностях ответов, и они проверяли бы
/// порядок вызовов, а не поведение.
/// </summary>
public sealed class AutoApproveServiceTests : 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, 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<RequestTypeService>();
}
/// <summary>
/// База у тестов класса общая, а сервис обходит ВСЕ типы заявок — поэтому у
/// каждого теста свой список: типы соседних тестов на мок не настроены,
/// заголовок списка для них не найдётся, и в выборку они не попадут.
/// </summary>
private async Task<string> 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;
}
/// <summary>Строка списка согласования в том виде, в каком её отдаёт REST.</summary>
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" }
});
}
/// <summary>Записанные сервисом поля: (id элемента, имя колонки) → значение.</summary>
private sealed class Writes
{
public List<(string List, int Id, Dictionary<string, object?> Fields)> Items { get; } = [];
public Dictionary<string, object?>? For(string list, int id) =>
Items.LastOrDefault(x => x.List == list && x.Id == id).Fields;
}
private (AutoApproveService Service, Writes Writes) BuildService(
string listUrl, List<Row> rows, DateTimeOffset? now = null)
{
var sharePoint = new Mock<ISharePointClient>();
var writes = new Writes();
sharePoint
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
// Кандидаты: активные строки с включённым автосогласованием — тот же отбор,
// что делает $filter на стороне SharePoint.
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("AutoApprove eq 1")),
It.IsAny<CancellationToken>()))
.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<string>(q => q.Contains("TicketId eq")),
It.IsAny<CancellationToken>()))
.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<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.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<int>(), It.IsAny<object>(), It.IsAny<CancellationToken>()))
.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<ApprovalRouteDbContext>(),
sharePoint.Object,
_services.GetRequiredService<ListPartitionRegistry>(),
new FixedTimeProvider(now ?? Now),
Options.Create(new AutoApproveOptions()),
NullLogger<AutoApproveService>.Instance);
return (service, writes);
}
// Анонимный объект, который сервис отдаёт клиенту SharePoint, — в словарь:
// сравнивать поля по именам колонок нагляднее, чем городить ожидаемый
// анонимный тип той же формы.
private static Dictionary<string, object?> 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<Row>
{
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<Row>
{
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<Row>
{
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<Row>
{
// По типу (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<Row>
{
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<Row> { 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<Row>
{
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<Row>
{
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<Row>
{
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<Row>
{
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<Row>
{
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("Отклонено");
}
}