2b8fe77fac
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
354 lines
17 KiB
C#
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("Отклонено");
|
|
}
|
|
}
|