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
This commit was merged in pull request #21.
This commit is contained in:
+353
@@ -0,0 +1,353 @@
|
||||
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("Отклонено");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user