Обходной лист (#12)
Tests / backend-test (push) Successful in 1m38s
Tests / frontend-test (push) Successful in 47s

Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/12
This commit was merged in pull request #12.
This commit is contained in:
2026-08-17 05:43:52 +00:00
parent d076f4e98d
commit 48ede1420d
37 changed files with 1351 additions and 435 deletions
@@ -13,9 +13,11 @@ using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Проверяется прежде всего вычисление даты, с которой отсчитывается SLA:
/// отдельной колонки «шаг стал активным» в списке согласования нет, и момент
/// выводится из соседних строк — это самая хрупкая часть эндпоинта.
/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки
/// (колонка Date — request-portal проставляет её в момент, когда строка
/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше
/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты
/// и проверяют вместо прежнего вывода даты из соседних строк.
///
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
@@ -53,13 +55,12 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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) =>
int id, int ticketId, string status, string phase,
string? date, string email = "approver@tnsu.kz", object? slaHours = null, bool? ask = null) =>
new
{
Id = id,
TicketId = ticketId,
StepOrder = stepOrder,
ApprovalStatus = status,
Phase = phase,
Date = date,
@@ -68,8 +69,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
TansuSiteApprover = new { EMail = email }
};
private ReminderService BuildService(
string listUrl, IReadOnlyList<object> allRows, DateTimeOffset requestCreated)
// Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) —
// мок отвечает так же: по значению статуса, а не по признаку решённости.
// Никаких запросов ни по TicketId, ни к списку данных за Created сервис
// больше не делает — если он вдруг снова начнёт, немонастроенный мок
// (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой,
// а не тихо проигнорирует лишний запрос.
private ReminderService BuildService(string listUrl, IReadOnlyList<object> rows)
{
var sharePoint = new Mock<ISharePointClient>();
@@ -77,9 +83,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
.ReturnsAsync(DataListTitle);
// Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
// отвечает так же: по значению статуса, а не по признаку решённости.
var activeRows = allRows
var activeRows = rows
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
"На согласовании" or "На исполнении")
.ToList();
@@ -88,15 +92,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
.ReturnsAsync(activeRows.Select(Json).ToList());
sharePoint
.Setup(x => x.GetItemsByListTitleAsync(
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("TicketId eq")), It.IsAny<CancellationToken>()))
.ReturnsAsync(allRows.Select(Json).ToList());
sharePoint
.Setup(x => x.GetItemsByListUrlAsync(SiteUrl, listUrl, It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync([Json(new { Id = 1, Created = requestCreated.ToString("O") })]);
return new ReminderService(
_services.GetRequiredService<ApprovalRouteDbContext>(),
sharePoint.Object,
@@ -104,14 +99,13 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
NullLogger<ReminderService>.Instance);
}
[Fact(DisplayName = "Первый шаг согласования: SLA отсчитывается от создания заявки")]
public async Task FirstApprovalStep_CountsFromRequestCreated()
[Fact(DisplayName = "SLA отсчитывается от Date активной строки")]
public async Task ActiveRow_CountsFromOwnDate()
{
var listUrl = await CreateTypeWithSlaAsync("Sla first step", slaHours: 24);
// Создана 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24);
// Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
var service = BuildService(listUrl,
[Row(10, 1, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-48));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -127,70 +121,43 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
var service = BuildService(listUrl,
[Row(10, 1, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-2));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-2).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "Второй шаг: SLA отсчитывается от решения предыдущего шага, а не от создания заявки")]
public async Task SecondStep_CountsFromPreviousStepDecision()
[Fact(DisplayName = "Другие строки того же маршрута сервису не нужны — читается только активная")]
public async Task OtherRoutRows_AreIrrelevant()
{
var listUrl = await CreateTypeWithSlaAsync("Sla second step", slaHours: 10);
// Заявка создана давно, но предыдущий шаг решён 12 часов назад:
// при отсчёте от создания просрочка была бы огромной, верная — 2 часа.
var listUrl = await CreateTypeWithSlaAsync("Sla ignores history", slaHours: 10);
// Решённые строки маршрута заведомо не попадут в ответ на $filter по
// ApprovalStatus (мок это тоже соблюдает) — но даже будь они прочитаны,
// сервис их дату уже не использует: отсчёт идёт только от Date
// активной строки.
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));
Row(10, 1, "Согласовано", "Согласование", date: Now.AddHours(-40).ToString("O")),
Row(11, 1, "На согласовании", "Согласование", date: Now.AddHours(-12).ToString("O"))
]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
result.Value.ShouldHaveSingleItem().HoursOverdue.ShouldBe(2);
}
[Fact(DisplayName = "Первый шаг исполнения: отсчёт от последнего решения фазы согласования")]
public async Task FirstExecutionStep_CountsFromLastApprovalDecision()
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
public async Task ActiveRow_WithoutDate_IsSkipped()
{
var listUrl = await CreateTypeWithSlaAsync("Sla execution", slaHours: 5);
var listUrl = await CreateTypeWithSlaAsync("Sla missing date", slaHours: 1);
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));
[Row(10, 1, "На согласовании", "Согласование", date: null)]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
var reminder = result.Value.ShouldHaveSingleItem();
reminder.Email.ShouldBe("executor@tnsu.kz");
reminder.HoursOverdue.ShouldBe(3);
result.IsSuccess.ShouldBeTrue();
result.Value.ShouldBeEmpty();
}
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
@@ -199,8 +166,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1)]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -220,8 +186,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
// воспроизводит.
[Row(10, 1, 1, "На согласовании", "Согласование", slaHours: 1.0m)],
requestCreated: Now.AddHours(-6));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1.0m)]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -233,8 +198,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
var service = BuildService(listUrl,
[Row(10, 1, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-6));
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -249,8 +213,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
var service = BuildService(listUrl,
[Row(10, 11, 1, "На согласовании", "Согласование")],
requestCreated: Now.AddHours(-6));
[Row(10, 11, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -264,11 +227,7 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
{
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));
[Row(10, 1, "На исполнении", "Исполнение", date: Now.AddHours(-6).ToString("O"), email: "executor@tnsu.kz")]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
@@ -281,10 +240,9 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
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));
Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O")),
Row(11, 1, "Ждёт очереди", "Согласование", date: null, email: "waiting@tnsu.kz")
]);
var result = await service.GetOverdueAsync(Now, CancellationToken.None);