cfc2b393bd
- ReminderDto: базовый класс сузился до ReceiverEmail/ReceiverName/Url; RequestTypeId/RequestTypeName/RequestId/Status/RequestUniqueCode остались только в ApprovalOverdueReminderDto — ProbationEndingReminderDto они не нужны. Дискриминатор полиморфной сериализации ($type) больше не дублируется отдельным enum-полем Category. - ApprovalOverdueReminderDto: старый Email сохранён рядом с унаследованным ReceiverEmail (то же значение) — контракт можно переключать постепенно. - ApprovalRow/RowSelect: добавлен Title согласующего (TansuSiteApprover/Title) — заполняет ReceiverName для просроченных шагов, когда он есть в SharePoint. - EmployeeDirectorySnapshot.NameByEmail: обратный поиск ФИО руководителя по email — заполняет ReceiverName для напоминаний об испытательном сроке без лишнего похода в api.tnsu.kz (данные уже загружены для EmailOf). - GetProbationEndingAsync: испытательный срок не длиннее 6 месяцев, поэтому читаем только текущую и последнюю архивную партицию (не весь архив) и фильтруем строки по HireDate не старше 6 месяцев — экономим лишние запросы к SharePoint без потери актуальных заявок.
536 lines
31 KiB
C#
536 lines
31 KiB
C#
using System.Text.Json;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using Microsoft.Extensions.Options;
|
||
using Moq;
|
||
using Shouldly;
|
||
using Tnsu.ApprovalRoute.Application.ListPartitioning;
|
||
using Tnsu.ApprovalRoute.Application.Reminders;
|
||
using Tnsu.ApprovalRoute.Application.RequestTypes;
|
||
using Tnsu.ApprovalRoute.Infrastructure.Persistence;
|
||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||
using Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
||
|
||
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
|
||
|
||
/// <summary>
|
||
/// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки
|
||
/// (колонка Date — request-portal проставляет её в момент, когда строка
|
||
/// становится текущей, см. approvalList.service.ts/setActive). Сервису больше
|
||
/// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты
|
||
/// и проверяют вместо прежнего вывода даты из соседних строк.
|
||
///
|
||
/// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его
|
||
/// возвращает REST, — так тесты заодно покрывают и разбор ответа.
|
||
/// </summary>
|
||
public sealed class ReminderServiceTests : 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, 8, 12, 12, 0, 0, TimeSpan.Zero);
|
||
|
||
private readonly ServiceProvider _services;
|
||
private readonly RequestTypeService _requestTypeService;
|
||
|
||
public ReminderServiceTests(PostgresDatabaseFixture fixture)
|
||
{
|
||
_services = TestServiceProviderFactory.Build(fixture.ConnectionString);
|
||
_requestTypeService = _services.GetRequiredService<RequestTypeService>();
|
||
}
|
||
|
||
// База у тестов класса общая, а сервис обходит ВСЕ типы с настроенным SLA —
|
||
// поэтому у каждого теста свой список: типы соседних тестов на мок не
|
||
// настроены, заголовок списка для них не найдётся, и в выборку они не попадут.
|
||
private async Task<string> CreateTypeWithSlaAsync(string name, int slaHours, bool? askOverdueReason = null)
|
||
{
|
||
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
|
||
await _requestTypeService.CreateAsync(
|
||
new CreateRequestTypeRequest(name, $"/{name}", listUrl, SiteUrl, "General", slaHours, askOverdueReason),
|
||
CancellationToken.None);
|
||
|
||
return listUrl;
|
||
}
|
||
|
||
// pageUrl отдельным параметром (а не /{name}, как в CreateTypeWithSlaAsync) —
|
||
// GetProbationEndingAsync ищет тип НЕ по SLA, а именно по PageUrl (см.
|
||
// RemindersOptions.RecruitingHirePageUrl), и у каждого теста здесь свой
|
||
// уникальный PageUrl, передаваемый в опции сервиса той же строкой: так тесты
|
||
// не видят чужие «recruiting-hire»-записи, случайно оставшиеся от других
|
||
// тестов в общей БД фикстуры.
|
||
private async Task<(string ListUrl, string PageUrl)> CreateHireTypeAsync(string name)
|
||
{
|
||
var listUrl = $"/sites/requests/Lists/{name.Replace(" ", string.Empty)}";
|
||
var pageUrl = $"/{name.Replace(" ", string.Empty)}";
|
||
await _requestTypeService.CreateAsync(
|
||
new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"),
|
||
CancellationToken.None);
|
||
|
||
return (listUrl, pageUrl);
|
||
}
|
||
|
||
private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value);
|
||
|
||
private static object Row(
|
||
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,
|
||
ApprovalStatus = status,
|
||
Phase = phase,
|
||
Date = date,
|
||
SLAhours = slaHours,
|
||
AskOverdueReason = ask,
|
||
TansuSiteApprover = new { EMail = email }
|
||
};
|
||
|
||
// Сервис читает ТОЛЬКО активные строки (один $filter по ApprovalStatus) —
|
||
// мок отвечает так же: по значению статуса, а не по признаку решённости.
|
||
// Никаких запросов ни по TicketId, ни к списку данных за Created сервис
|
||
// больше не делает — если он вдруг снова начнёт, немонастроенный мок
|
||
// (Moq loose) вернёт null/пустой Task, и тест упадёт с понятной ошибкой,
|
||
// а не тихо проигнорирует лишний запрос.
|
||
private ReminderService BuildService(string listUrl, IReadOnlyList<object> rows)
|
||
{
|
||
var sharePoint = new Mock<ISharePointClient>();
|
||
|
||
sharePoint
|
||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(DataListTitle);
|
||
|
||
var activeRows = rows
|
||
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
||
"На согласовании" or "На исполнении")
|
||
.ToList();
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListTitleAsync(
|
||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(activeRows.Select(Json).ToList());
|
||
|
||
// RequestUniqueCode запрашивается по списку данных сразу для всех активных
|
||
// заявок — по умолчанию тесты его не проверяют, поэтому мок отдаёт пусто.
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListUrlAsync(
|
||
SiteUrl, listUrl, It.Is<string>(q => q.Contains("RequestUniqueCode")), It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync([]);
|
||
|
||
return new ReminderService(
|
||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||
sharePoint.Object,
|
||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||
Mock.Of<ITnsuPortalClient>(),
|
||
Options.Create(new RemindersOptions()),
|
||
NullLogger<ReminderService>.Instance);
|
||
}
|
||
|
||
[Fact(DisplayName = "SLA отсчитывается от Date активной строки")]
|
||
public async Task ActiveRow_CountsFromOwnDate()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla own date", slaHours: 24);
|
||
// Строка стала активной 48 часов назад, SLA 24 часа → просрочка ровно 24 часа.
|
||
var service = BuildService(listUrl,
|
||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.IsSuccess.ShouldBeTrue();
|
||
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>();
|
||
reminder.Email.ShouldBe("approver@tnsu.kz");
|
||
reminder.HoursOverdue.ShouldBe(24);
|
||
reminder.RequestId.ShouldBe(1);
|
||
}
|
||
|
||
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
|
||
public async Task WithinSla_ReturnsNothing()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla not due", slaHours: 24);
|
||
var service = BuildService(listUrl,
|
||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-2).ToString("O"))]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldBeEmpty();
|
||
}
|
||
|
||
[Fact(DisplayName = "Другие строки того же маршрута сервису не нужны — читается только активная")]
|
||
public async Task OtherRoutRows_AreIrrelevant()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla ignores history", slaHours: 10);
|
||
// Решённые строки маршрута заведомо не попадут в ответ на $filter по
|
||
// ApprovalStatus (мок это тоже соблюдает) — но даже будь они прочитаны,
|
||
// сервис их дату уже не использует: отсчёт идёт только от Date
|
||
// активной строки.
|
||
var service = BuildService(listUrl,
|
||
[
|
||
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().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(2);
|
||
}
|
||
|
||
[Fact(DisplayName = "Активная строка без даты (Date пуста) — пропускается, а не падает")]
|
||
public async Task ActiveRow_WithoutDate_IsSkipped()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla missing date", slaHours: 1);
|
||
var service = BuildService(listUrl,
|
||
[Row(10, 1, "На согласовании", "Согласование", date: null)]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.IsSuccess.ShouldBeTrue();
|
||
result.Value.ShouldBeEmpty();
|
||
}
|
||
|
||
[Fact(DisplayName = "SLA строки списка перекрывает SLA типа заявки")]
|
||
public async Task RowSla_OverridesRequestTypeSla()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla row override", slaHours: 100);
|
||
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
||
var service = BuildService(listUrl,
|
||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1)]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
|
||
}
|
||
|
||
// SharePoint отдаёт Number-колонки ДРОБНЫМИ («1.0», а не «1»), и на этом
|
||
// разбор спотыкался: JsonElement.TryGetInt32 на числе с десятичной точкой
|
||
// возвращает false, поле молча становилось null и SLA строки терялся.
|
||
// На фронтенде проблемы не было — в JavaScript 1.0 и 1 неразличимы.
|
||
[Fact(DisplayName = "SLA строки читается, даже когда SharePoint отдаёт его дробным числом")]
|
||
public async Task RowSla_WrittenAsDecimalNumber_IsStillRead()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla decimal", slaHours: 100);
|
||
// По типу 100 часов — просрочки не было бы; в строке 1 час — просрочка 5 часов.
|
||
var service = BuildService(listUrl,
|
||
// Именно decimal: System.Text.Json сохраняет у него хвостовой ноль и
|
||
// пишет «1.0», тогда как double 1.0 сериализуется в «1» и баг не
|
||
// воспроизводит.
|
||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"), slaHours: 1.0m)]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().HoursOverdue.ShouldBe(5);
|
||
}
|
||
|
||
[Fact(DisplayName = "Флаг опроса причины наследуется от типа заявки, если в строке не задан")]
|
||
public async Task AskOverdueReason_InheritsFromRequestType()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla ask reason", slaHours: 1, askOverdueReason: true);
|
||
var service = BuildService(listUrl,
|
||
[Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().AskOverdueReason.ShouldBeTrue();
|
||
}
|
||
|
||
// Портал — одностраничный и живёт в корне сайта; вид ссылки подтверждён
|
||
// живым примером (см. docs/routing.md в request-portal):
|
||
// https://tnsukz.sharepoint.com/sites/requests?rpnav=approvals&rpmode=approve&rptype=it-1c-ticket&rpid=11&rplist=%2Fsites%2Frequests%2FLists%2Fit-1c-ticket
|
||
[Fact(DisplayName = "Ссылка на заявку собирается в том же виде, что и в портале")]
|
||
public async Task Url_MatchesPortalFormat()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("it-1c-ticket", slaHours: 1);
|
||
var service = BuildService(listUrl,
|
||
[Row(10, 11, "На согласовании", "Согласование", date: Now.AddHours(-6).ToString("O"))]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldHaveSingleItem().Url.ShouldBe(
|
||
"https://tnsukz.sharepoint.com/sites/requests" +
|
||
"?rpnav=approvals&rpmode=approve&rptype=/it-1c-ticket&rpid=11" +
|
||
"&rplist=%2Fsites%2Frequests%2FLists%2Fit-1c-ticket");
|
||
}
|
||
|
||
[Fact(DisplayName = "Просроченный шаг исполнения ведёт в раздел «На исполнении», а не в согласование")]
|
||
public async Task Url_ForExecutionPhase_PointsToExecutionSection()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla exec nav", slaHours: 1);
|
||
var service = BuildService(listUrl,
|
||
[Row(10, 1, "На исполнении", "Исполнение", date: Now.AddHours(-6).ToString("O"), email: "executor@tnsu.kz")]);
|
||
|
||
var result = await service.GetOverdueAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldHaveSingleItem().Url.ShouldContain("rpnav=execution");
|
||
}
|
||
|
||
[Fact(DisplayName = "Ждущие очереди строки в напоминания не попадают")]
|
||
public async Task QueuedRows_AreIgnored()
|
||
{
|
||
var listUrl = await CreateTypeWithSlaAsync("Sla queued", slaHours: 1);
|
||
var service = BuildService(listUrl,
|
||
[
|
||
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);
|
||
|
||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ApprovalOverdueReminderDto>().Email.ShouldBe("approver@tnsu.kz");
|
||
}
|
||
|
||
private static object HireDataRow(
|
||
int id, string status, string? candidateName, string? hireDate, string? probationPeriod, string? code = null) =>
|
||
new
|
||
{
|
||
Id = id,
|
||
RequestStatus = status,
|
||
CandidateName = candidateName,
|
||
HireDate = hireDate,
|
||
ProbationPeriod = probationPeriod,
|
||
RequestUniqueCode = code
|
||
};
|
||
|
||
private ReminderService BuildProbationService(
|
||
string pageUrl, string listUrl, IReadOnlyList<object> rows, Mock<ITnsuPortalClient> portalClient,
|
||
int daysBeforeEnd = 3)
|
||
{
|
||
const string hireDataListTitle = "Прием на работу тест — данные";
|
||
|
||
var sharePoint = new Mock<ISharePointClient>();
|
||
sharePoint
|
||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(hireDataListTitle);
|
||
|
||
// GetProbationEndingAsync читает только «Согласовано»/«Закрыта» — SharePoint
|
||
// фильтрует это сам ($filter в запросе), мок повторяет ту же фильтрацию.
|
||
var closedRows = rows
|
||
.Where(r => (string)r.GetType().GetProperty("RequestStatus")!.GetValue(r)! is "Согласовано" or "Закрыта")
|
||
.ToList();
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListTitleAsync(
|
||
SiteUrl, hireDataListTitle, It.Is<string>(q => q.Contains("RequestStatus eq")), It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(closedRows.Select(Json).ToList());
|
||
|
||
return new ReminderService(
|
||
_services.GetRequiredService<ApprovalRouteDbContext>(),
|
||
sharePoint.Object,
|
||
_services.GetRequiredService<ListPartitionRegistry>(),
|
||
portalClient.Object,
|
||
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd }),
|
||
NullLogger<ReminderService>.Instance);
|
||
}
|
||
|
||
private static Mock<ITnsuPortalClient> BuildPortalClientMock(
|
||
string candidateFio, DateTimeOffset? dismissalDate, string? candidateEmail, string? managerEmail)
|
||
{
|
||
var portalClient = new Mock<ITnsuPortalClient>();
|
||
|
||
portalClient
|
||
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync([new EmployeeFullBaseInfo(Guid.NewGuid(), candidateFio, "ТОО TANSU", dismissalDate)]);
|
||
|
||
portalClient
|
||
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), candidateFio, candidateEmail, "ТОО TANSU")]);
|
||
|
||
if (candidateEmail is not null)
|
||
portalClient
|
||
.Setup(x => x.GetManagerAsync(candidateEmail, "chief", It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(managerEmail);
|
||
|
||
return portalClient;
|
||
}
|
||
|
||
[Fact(DisplayName = "До конца испытательного срока 3 дня — напоминание с email кандидата и его руководителя")]
|
||
public async Task ProbationEndingIn3Days_BuildsReminder()
|
||
{
|
||
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation due");
|
||
// probationEndsAt = Now.Date + 3 дня = 2026-08-15; "2 месяца" от 2026-06-15.
|
||
var rows = new[] { HireDataRow(1, "Закрыта", "Иванов Иван Иванович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||
var portalClient = BuildPortalClientMock(
|
||
"Иванов Иван Иванович", dismissalDate: null, candidateEmail: "candidate@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||
|
||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.IsSuccess.ShouldBeTrue();
|
||
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
|
||
reminder.ManagerEmail.ShouldBe("manager@tnsu.kz");
|
||
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
|
||
reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
|
||
reminder.DaysUntilProbationEnds.ShouldBe(3);
|
||
}
|
||
|
||
[Fact(DisplayName = "Кандидат уже уволен (dismissalDate заполнен) — напоминания нет")]
|
||
public async Task DismissedCandidate_IsSkipped()
|
||
{
|
||
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation dismissed");
|
||
var rows = new[] { HireDataRow(1, "Закрыта", "Петров Пётр Петрович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||
var portalClient = BuildPortalClientMock(
|
||
"Петров Пётр Петрович", dismissalDate: Now.AddDays(-1), candidateEmail: "petrov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||
|
||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldBeEmpty();
|
||
}
|
||
|
||
// Сотрудник холдинга может числиться в нескольких компаниях сразу (см.
|
||
// /Employee/FullBase — по записи на компанию). Увольнение из одной из них не
|
||
// означает, что человек ушёл: пока хоть где-то дата увольнения не стоит, он
|
||
// работает, и напоминание ему по-прежнему нужно.
|
||
[Fact(DisplayName = "Уволен из одной компании, но работает в другой — напоминание есть")]
|
||
public async Task DismissedFromOneCompanyOnly_StillBuildsReminder()
|
||
{
|
||
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation partly dismissed");
|
||
const string fio = "Романов Роман Романович";
|
||
var rows = new[] { HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца") };
|
||
|
||
var portalClient = new Mock<ITnsuPortalClient>();
|
||
portalClient
|
||
.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync(
|
||
[
|
||
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО АСТЕЛ-К", Now.AddDays(-10)),
|
||
new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО TANSU Construction", null)
|
||
]);
|
||
portalClient
|
||
.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), fio, "romanov@tnsu.kz", "ТОО TANSU Construction")]);
|
||
portalClient
|
||
.Setup(x => x.GetManagerAsync("romanov@tnsu.kz", "chief", It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync("manager@tnsu.kz");
|
||
|
||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("romanov@tnsu.kz");
|
||
}
|
||
|
||
// ФИО в заявке набирают руками — лишние пробелы и другой регистр не должны
|
||
// ломать сопоставление со справочником (см. EmployeeDirectorySnapshot.Normalize).
|
||
[Fact(DisplayName = "ФИО сопоставляется без учёта регистра и лишних пробелов")]
|
||
public async Task CandidateName_IsMatchedLoosely()
|
||
{
|
||
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation fio normalize");
|
||
var rows = new[] { HireDataRow(1, "Закрыта", " волков ВОЛК волкович ", "2026-06-15T00:00:00Z", "2 месяца") };
|
||
var portalClient = BuildPortalClientMock(
|
||
"Волков Волк Волкович", dismissalDate: null, candidateEmail: "volkov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||
|
||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>().CandidateEmail.ShouldBe("volkov@tnsu.kz");
|
||
}
|
||
|
||
[Fact(DisplayName = "Кандидат не найден в /Employee/FullBase — напоминания нет")]
|
||
public async Task CandidateNotInFullBase_IsSkipped()
|
||
{
|
||
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation unknown");
|
||
var rows = new[] { HireDataRow(1, "Закрыта", "Сидоров Сидор Сидорович", "2026-06-15T00:00:00Z", "2 месяца") };
|
||
var portalClient = new Mock<ITnsuPortalClient>();
|
||
portalClient.Setup(x => x.GetFullBaseAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||
portalClient.Setup(x => x.GetAllEmployeesAsync(It.IsAny<CancellationToken>())).ReturnsAsync([]);
|
||
|
||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldBeEmpty();
|
||
}
|
||
|
||
[Fact(DisplayName = "До конца испытательного срока не ровно 3 дня — напоминания нет")]
|
||
public async Task NotExactlyThreeDaysLeft_IsSkipped()
|
||
{
|
||
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation not due");
|
||
// "1 месяц" от 2026-06-15 → срок кончился 2026-07-15, задолго до Now.
|
||
var rows = new[] { HireDataRow(1, "Закрыта", "Кузнецов Кузьма Кузьмич", "2026-06-15T00:00:00Z", "1 месяц") };
|
||
var portalClient = BuildPortalClientMock(
|
||
"Кузнецов Кузьма Кузьмич", dismissalDate: null, candidateEmail: "kuznecov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||
|
||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldBeEmpty();
|
||
}
|
||
|
||
[Fact(DisplayName = "«Без испытательного срока» — напоминания нет")]
|
||
public async Task NoProbationPeriod_IsSkipped()
|
||
{
|
||
var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation none");
|
||
var rows = new[]
|
||
{
|
||
HireDataRow(1, "Закрыта", "Смирнов Смирн Смирнович", "2026-06-15T00:00:00Z", "Без испытательного срока")
|
||
};
|
||
var portalClient = BuildPortalClientMock(
|
||
"Смирнов Смирн Смирнович", dismissalDate: null, candidateEmail: "smirnov@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||
|
||
var service = BuildProbationService(pageUrl, listUrl, rows, portalClient);
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.Value.ShouldBeEmpty();
|
||
}
|
||
|
||
// Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption
|
||
// в ReminderService), поэтому заявка, способная дать напоминание сегодня,
|
||
// физически не может лежать раньше предпоследней партиции. Три партиции:
|
||
// самая старая (№1) содержит строку-ловушку — если сервис её прочитает, тест
|
||
// это увидит по лишнему напоминанию; предпоследняя (№2) и текущая (№3)
|
||
// читаются как обычно.
|
||
[Fact(DisplayName = "Читаются только текущая и последняя архивная партиция — более старые игнорируются")]
|
||
public async Task OnlyLastTwoPartitions_AreRead()
|
||
{
|
||
var (listUrl1, pageUrl) = await CreateHireTypeAsync("Probation partitions");
|
||
var db = _services.GetRequiredService<ApprovalRouteDbContext>();
|
||
var partitionRegistry = _services.GetRequiredService<ListPartitionRegistry>();
|
||
var type = await db.RequestTypes.FirstAsync(x => x.PageUrl == pageUrl);
|
||
|
||
// Партиция №1 — уедет в архив первой, её строки читаться не должны.
|
||
await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||
var listUrl2 = listUrl1 + "-2";
|
||
type.ListId = listUrl2;
|
||
await db.SaveChangesAsync();
|
||
|
||
// Партиция №2 — последняя архивная, должна читаться наравне с текущей.
|
||
await partitionRegistry.ArchiveCurrentAsync(type, CancellationToken.None);
|
||
var listUrl3 = listUrl1 + "-3";
|
||
type.ListId = listUrl3;
|
||
await db.SaveChangesAsync();
|
||
|
||
const string fio = "Партиция Тест Тестович";
|
||
var trapRow = HireDataRow(1, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца");
|
||
var realRow = HireDataRow(2, "Закрыта", fio, "2026-06-15T00:00:00Z", "2 месяца");
|
||
|
||
var sharePoint = new Mock<ISharePointClient>();
|
||
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl1, It.IsAny<CancellationToken>())).ReturnsAsync("list1 — данные");
|
||
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl2, It.IsAny<CancellationToken>())).ReturnsAsync("list2 — данные");
|
||
sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl3, It.IsAny<CancellationToken>())).ReturnsAsync("list3 — данные");
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync([Json(trapRow)]);
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list2 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync([Json(realRow)]);
|
||
sharePoint
|
||
.Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list3 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||
.ReturnsAsync([]);
|
||
|
||
var portalClient = BuildPortalClientMock(
|
||
fio, dismissalDate: null, candidateEmail: "partition@tnsu.kz", managerEmail: "manager@tnsu.kz");
|
||
|
||
var service = new ReminderService(
|
||
db, sharePoint.Object, partitionRegistry, portalClient.Object,
|
||
Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl }),
|
||
NullLogger<ReminderService>.Instance);
|
||
|
||
var result = await service.GetProbationEndingAsync(Now, CancellationToken.None);
|
||
|
||
result.IsSuccess.ShouldBeTrue();
|
||
result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>()
|
||
.CandidateEmail.ShouldBe("partition@tnsu.kz");
|
||
sharePoint.Verify(
|
||
x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||
Times.Never);
|
||
}
|
||
}
|