Расширение reminders #19

Merged
gorbenko.pavel merged 8 commits from remimders-extension into main 2026-08-27 11:43:45 +00:00
9 changed files with 184 additions and 83 deletions
Showing only changes of commit cfc2b393bd - Show all commits
@@ -1,6 +1,10 @@
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="Email">Согласующий/исполнитель из строки списка — кому напоминать.</param> /// <param name="Email">
/// То же значение, что и унаследованный ReceiverEmail — оставлено под старым
/// именем ради обратной совместимости с потребителями, читавшими это поле до
/// появления общего ReceiverEmail/ReceiverName в базовом классе.
/// </param>
/// <param name="HoursOverdue"> /// <param name="HoursOverdue">
/// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA). /// На сколько часов просрочен шаг: сейчас минус (активация шага + SLA).
/// Выходные не учитываются — считаются календарные часы. /// Выходные не учитываются — считаются календарные часы.
@@ -9,15 +13,23 @@ namespace Tnsu.ApprovalRoute.Application.Reminders;
/// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым /// Спрашивать ли у пользователя причину просрочки. Значение уже с применённым
/// наследованием (шаг перекрывает тип заявки). /// наследованием (шаг перекрывает тип заявки).
/// </param> /// </param>
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param>
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param>
/// <param name="RequestId">Id заявки (TicketId строки списка согласования).</param>
/// <param name="Status">Статус просроченного шага (ApprovalStatus строки списка «… — согласование»).</param>
/// <param name="RequestUniqueCode">
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у
/// заявки его нет или строка данных не найдена.
/// </param>
public sealed record ApprovalOverdueReminderDto( public sealed record ApprovalOverdueReminderDto(
string Email, string Email,
int HoursOverdue, int HoursOverdue,
bool AskOverdueReason, bool AskOverdueReason,
string Url,
int RequestTypeId, int RequestTypeId,
string RequestTypeName, string RequestTypeName,
int RequestId, int RequestId,
string Status, string Status,
string? RequestUniqueCode) string? RequestUniqueCode,
: ReminderDto( string Url,
ReminderCategory.ApprovalOverdue, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode); string? ReceiverName = null)
: ReminderDto(Email, ReceiverName, Url);
@@ -15,6 +15,7 @@ internal sealed record ApprovalRow(
string Phase, string Phase,
DateTimeOffset? Date, DateTimeOffset? Date,
string? ApproverEmail, string? ApproverEmail,
string? ApproverName,
int? SlaHours, int? SlaHours,
bool? AskOverdueReason) bool? AskOverdueReason)
{ {
@@ -23,9 +24,9 @@ internal sealed record ApprovalRow(
// TicketId — Lookup на заявку; без него строку не с чем связать. // TicketId — Lookup на заявку; без него строку не с чем связать.
if (!TryGetInt(item, "TicketId", out var ticketId)) return null; if (!TryGetInt(item, "TicketId", out var ticketId)) return null;
var approver = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object var approverObject = item.TryGetProperty("TansuSiteApprover", out var a) && a.ValueKind == JsonValueKind.Object
? GetString(a, "EMail") ? a
: null; : (JsonElement?)null;
return new ApprovalRow( return new ApprovalRow(
TryGetInt(item, "Id", out var id) ? id : 0, TryGetInt(item, "Id", out var id) ? id : 0,
@@ -35,7 +36,10 @@ internal sealed record ApprovalRow(
// трактуем как «Согласование» (так же поступает request-portal). // трактуем как «Согласование» (так же поступает request-portal).
GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval, GetString(item, "Phase") is { Length: > 0 } phase ? phase : ApprovalPhases.Approval,
TryGetDate(item, "Date"), TryGetDate(item, "Date"),
approver, approverObject is { } ao ? GetString(ao, "EMail") : null,
// Title у персонального поля — не всегда заполнен (например, у
// системных/сервисных учёток) — тогда ReceiverName в DTO остаётся null.
approverObject is { } an ? GetString(an, "Title") : null,
TryGetInt(item, "SLAhours", out var sla) ? sla : null, TryGetInt(item, "SLAhours", out var sla) ? sla : null,
TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null); TryGetBool(item, "AskOverdueReason", out var ask) ? ask : null);
} }
@@ -31,12 +31,22 @@ internal sealed class EmployeeDirectorySnapshot
{ {
private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio; private readonly ILookup<string, EmployeeFullBaseInfo> _fullBaseByFio;
private readonly ILookup<string, EmployeeContactInfo> _contactsByFio; private readonly ILookup<string, EmployeeContactInfo> _contactsByFio;
private readonly IReadOnlyDictionary<string, string> _fioByEmail;
public EmployeeDirectorySnapshot( public EmployeeDirectorySnapshot(
IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts) IReadOnlyList<EmployeeFullBaseInfo> fullBase, IReadOnlyList<EmployeeContactInfo> contacts)
{ {
_fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio)); _fullBaseByFio = fullBase.ToLookup(e => Normalize(e.Fio));
_contactsByFio = contacts.ToLookup(e => Normalize(e.Fio)); _contactsByFio = contacts.ToLookup(e => Normalize(e.Fio));
// Обратный индекс email → ФИО — нужен, чтобы подписать напоминание именем
// получателя, когда известен только его email (руководитель кандидата,
// см. GetManagerAsync). При дублях email в справочнике побеждает первая
// запись — этого достаточно для отображения имени, не для идентификации.
_fioByEmail = contacts
.Where(e => !string.IsNullOrWhiteSpace(e.Email))
.GroupBy(e => e.Email!.Trim().ToLowerInvariant())
.ToDictionary(g => g.Key, g => g.First().Fio);
} }
/// <summary> /// <summary>
@@ -64,6 +74,10 @@ internal sealed class EmployeeDirectorySnapshot
.Select(e => e.Email) .Select(e => e.Email)
.FirstOrDefault(email => !string.IsNullOrWhiteSpace(email)); .FirstOrDefault(email => !string.IsNullOrWhiteSpace(email));
/// <summary>ФИО по email — для ReceiverName, когда известен только email получателя (см. EmailOf).</summary>
public string? NameByEmail(string email) =>
_fioByEmail.GetValueOrDefault(email.Trim().ToLowerInvariant());
/// <summary> /// <summary>
/// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта /// В справочнике и в заявке ФИО набирают руками, поэтому сравниваем без учёта
/// регистра и лишних пробелов между словами (в живых данных встречаются и /// регистра и лишних пробелов между словами (в живых данных встречаются и
@@ -1,6 +1,9 @@
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="ManagerEmail">Email руководителя принимаемого сотрудника — кому напоминать.</param> /// <param name="ManagerEmail">
/// То же значение, что и унаследованный ReceiverEmail, — email руководителя
/// принимаемого сотрудника, кому адресовано напоминание.
/// </param>
/// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param> /// <param name="CandidateEmail">Email принимаемого сотрудника (см. ReminderService.GetProbationEndingAsync).</param>
/// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param> /// <param name="CandidateName">ФИО принимаемого сотрудника (колонка CandidateName списка данных).</param>
/// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param> /// <param name="ProbationPeriod">Текст испытательного срока (колонка ProbationPeriod).</param>
@@ -16,10 +19,5 @@ public sealed record ProbationEndingReminderDto(
DateTimeOffset ProbationEndsAt, DateTimeOffset ProbationEndsAt,
int DaysUntilProbationEnds, int DaysUntilProbationEnds,
string Url, string Url,
int RequestTypeId, string? ReceiverName = null)
string RequestTypeName, : ReminderDto(ManagerEmail, ReceiverName, Url);
int RequestId,
string Status,
string? RequestUniqueCode)
: ReminderDto(
ReminderCategory.ProbationEnding, Url, RequestTypeId, RequestTypeName, RequestId, Status, RequestUniqueCode);
@@ -1,23 +0,0 @@
namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <summary>
/// Категория уведомления в <see cref="ReminderDto"/>. Добавлена позже самого DTO,
/// поэтому в нём параметр необязательный со значением <see cref="None"/> —
/// «категория не указана»: ровно то, что было до появления этого поля, когда
/// эндпоинт отдавал единственный вид напоминаний, и потребители (Power Automate)
/// ни о каких категориях не знали.
///
/// На провод уходит строкой, а не числом (JsonStringEnumConverter, см. Program.cs),
/// так что числовые значения — деталь реализации, менять их порядок безопасно.
/// </summary>
public enum ReminderCategory
{
/// <summary>Категория не указана — значение по умолчанию для обратной совместимости.</summary>
None = 0,
/// <summary>Просроченный шаг согласования/исполнения (см. ReminderService.GetOverdueAsync).</summary>
ApprovalOverdue = 1,
/// <summary>Скоро заканчивается испытательный срок (см. ReminderService.GetProbationEndingAsync).</summary>
ProbationEnding = 2,
}
@@ -2,40 +2,31 @@ using System.Text.Json.Serialization;
namespace Tnsu.ApprovalRoute.Application.Reminders; namespace Tnsu.ApprovalRoute.Application.Reminders;
/// <param name="Category">Вид напоминания — один на конкретный (sealed) наследник, см. их конструкторы.</param> /// <param name="ReceiverEmail">Email того, кому адресовано напоминание.</param>
/// <param name="Url">Ссылка на заявку.</param> /// <param name="ReceiverName">
/// <param name="RequestTypeId">Id типа заявки (RequestType).</param> /// ФИО получателя, если оно известно. Не всегда доступно (например, у согласующего
/// <param name="RequestTypeName">Название типа заявки — для читаемости в письме/таблице, без похода в БД.</param> /// в старой строке списка без заполненного Title персонального поля) — тогда null.
/// <param name="RequestId">Id заявки (TicketId строки списка согласования / Id строки списка данных).</param> /// </param>
/// <param name="Status">Статус заявки/шага — из того списка, который читает конкретный вид напоминания.</param> /// <param name="Url">
/// <param name="RequestUniqueCode"> /// Ссылка на заявку. Собирается здесь, в сервисе (а не на стороне потребителя),
/// Уникальный код заявки (колонка RequestUniqueCode списка данных) — null, если у /// потому что не константна: requestId и партиция списка (server-relative url,
/// заявки его нет или строка данных не найдена. /// параметр rplist) у каждой заявки свои, и сервис их всё равно уже знает —
/// дублировать эту логику снаружи незачем.
/// </param> /// </param>
/// <remarks> /// <remarks>
/// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и /// Общая часть двух видов напоминаний — <see cref="ApprovalOverdueReminderDto"/> и
/// <see cref="ProbationEndingReminderDto"/>. У них разные поля-триггеры и разные /// <see cref="ProbationEndingReminderDto"/>. Категории заявки (RequestTypeId,
/// адресаты, общее — только то, что описано здесь (к какой заявке относится строка /// RequestId и т.п.) здесь нет: она нужна только просроченным шагам согласования
/// и как её открыть). /// (Power Automate ссылается на неё, работая со списком согласования конкретного
/// типа заявки) и объявлена прямо в ApprovalOverdueReminderDto.
/// ///
/// [JsonPolymorphic]/[JsonDerivedType] — вместо того чтобы полагаться на /// [JsonPolymorphic]/[JsonDerivedType] нужны, потому что ReminderService отдаёт
/// compile-time тип элемента (ReminderService всегда работает со списком именно /// список именно базового типа (IReadOnlyList&lt;ReminderDto&gt;) — без этих
/// этого базового типа, см. IReadOnlyList&lt;ReminderDto&gt;), System.Text.Json без /// атрибутов System.Text.Json сериализовал бы только то, что объявлено здесь,
/// этих атрибутов сериализовал бы только поля базового класса, теряя специфичные /// теряя специфичные для наследника поля. Дискриминатор — системное поле "$type"
/// для наследника (Email/HoursOverdue или ManagerEmail/CandidateEmail и т.д.). /// со значением "ApprovalOverdue"/"ProbationEnding".
/// Дискриминатор — отдельное системное поле "$type", а не Category: значение
/// Category читаемое (совпадает с именем enum-константы через уже подключенный
/// глобально JsonStringEnumConverter, см. Program.cs) и не привязано к деталям
/// сериализации.
/// </remarks> /// </remarks>
[JsonPolymorphic] [JsonPolymorphic]
[JsonDerivedType(typeof(ApprovalOverdueReminderDto), nameof(ReminderCategory.ApprovalOverdue))] [JsonDerivedType(typeof(ApprovalOverdueReminderDto), "ApprovalOverdue")]
[JsonDerivedType(typeof(ProbationEndingReminderDto), nameof(ReminderCategory.ProbationEnding))] [JsonDerivedType(typeof(ProbationEndingReminderDto), "ProbationEnding")]
public abstract record ReminderDto( public abstract record ReminderDto(string ReceiverEmail, string? ReceiverName, string Url);
ReminderCategory Category,
string Url,
int RequestTypeId,
string RequestTypeName,
int RequestId,
string Status,
string? RequestUniqueCode);
@@ -207,12 +207,13 @@ public sealed class ReminderService(
row.ApproverEmail!, row.ApproverEmail!,
(int)Math.Floor((now - deadline).TotalHours), (int)Math.Floor((now - deadline).TotalHours),
row.AskOverdueReason ?? type.AskOverdueReason ?? false, row.AskOverdueReason ?? type.AskOverdueReason ?? false,
BuildUrl(type, row, listId),
type.Id, type.Id,
type.Name, type.Name,
row.TicketId, row.TicketId,
row.Status, row.Status,
uniqueCodes.GetValueOrDefault(row.TicketId))); uniqueCodes.GetValueOrDefault(row.TicketId),
BuildUrl(type, row, listId),
row.ApproverName));
} }
return reminders; return reminders;
@@ -249,7 +250,7 @@ public sealed class ReminderService(
private const string RowSelect = private const string RowSelect =
"$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," + "$select=Id,ApprovalStatus,Phase,Date,SLAhours,AskOverdueReason,TicketId," +
"TansuSiteApprover/EMail&$expand=TansuSiteApprover&$top=5000"; "TansuSiteApprover/EMail,TansuSiteApprover/Title&$expand=TansuSiteApprover&$top=5000";
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync( private async Task<IReadOnlyList<ApprovalRow>> ReadRowsAsync(
string siteUrl, string listTitle, string filter, CancellationToken cancellationToken) string siteUrl, string listTitle, string filter, CancellationToken cancellationToken)
@@ -316,7 +317,16 @@ public sealed class ReminderService(
var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken); var archived = await partitionRegistry.GetArchivedAsync(type, cancellationToken);
var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken); var current = await partitionRegistry.BuildCurrentAsync(type, archived, cancellationToken);
var partitions = archived.Select(x => (x.ListId, x.Number)).Append((current.ListId, current.Number));
// Испытательный срок — не больше 6 месяцев (см. ProbationMonthsByOption),
// поэтому заявка, способная дать напоминание сегодня, обязана лежать в
// текущей партиции или в партиции прямо перед ней: более старая партиция
// физически не может содержать ещё актуальный испытательный срок. Читать
// весь архив, как это делает GetOverdueAsync (там заявка может висеть
// непринятой годами), здесь незачем — только лишняя нагрузка на SharePoint.
var lastArchived = archived.OrderByDescending(x => x.Number).FirstOrDefault();
var partitions = new List<(string ListId, int Number)> { (current.ListId, current.Number) };
if (lastArchived is not null) partitions.Add((lastArchived.ListId, lastArchived.Number));
// Обе базы сотрудников читаются по одному разу на весь обход партиций (а не // Обе базы сотрудников читаются по одному разу на весь обход партиций (а не
// на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны // на каждого кандидата): точечного поиска по ФИО в api.tnsu.kz нет, а нужны
@@ -356,8 +366,14 @@ public sealed class ReminderService(
var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken); var dataListTitle = await sharePoint.GetListTitleAsync(siteUrl, listId, cancellationToken);
if (string.IsNullOrWhiteSpace(dataListTitle)) return []; if (string.IsNullOrWhiteSpace(dataListTitle)) return [];
// HireDate не старше 6 месяцев — тот же смысл, что и ограничение на две
// последних партиции (см. GetProbationEndingAsync): более старый приём уже
// не может дать напоминание, читать его строки незачем даже в пределах
// партиции, где они могут лежать вперемешку со свежими.
var since = now.AddMonths(-6);
var filter = var filter =
$"$filter=RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}'"; $"$filter=(RequestStatus eq '{HireRequestStatuses.Approved}' or RequestStatus eq '{HireRequestStatuses.Closed}')" +
$" and HireDate ge datetime'{since:yyyy-MM-ddTHH:mm:ssZ}'";
var items = await sharePoint.GetItemsByListTitleAsync( var items = await sharePoint.GetItemsByListTitleAsync(
siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken); siteUrl, dataListTitle, $"{HireRowSelect}&{filter}", cancellationToken);
var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList(); var rows = items.Select(HireRow.TryParse).Where(r => r is not null).Select(r => r!).ToList();
@@ -425,11 +441,7 @@ public sealed class ReminderService(
ProbationEndsAt: probationEndsAt, ProbationEndsAt: probationEndsAt,
DaysUntilProbationEnds: daysLeft, DaysUntilProbationEnds: daysLeft,
Url: BuildProbationUrl(type, row, listId), Url: BuildProbationUrl(type, row, listId),
RequestTypeId: type.Id, ReceiverName: directory.NameByEmail(managerEmail));
RequestTypeName: type.Name,
RequestId: row.Id,
Status: row.Status,
RequestUniqueCode: row.RequestUniqueCode);
} }
/// <summary> /// <summary>
@@ -0,0 +1,32 @@
using Shouldly;
using Tnsu.ApprovalRoute.Application.Reminders;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// ApprovalOverdueReminderDto должен отдавать email получателя под двумя именами
/// сразу: старым Email (для потребителей, ещё не перешедших на новый контракт) и
/// унаследованным ReceiverEmail (общим для обоих видов напоминаний, см.
/// ReminderDto) — так переход на новый контракт можно делать постепенно, не
/// ломая существующие Power Automate-флоу разом.
/// </summary>
public sealed class ReminderDtoTests
{
[Fact(DisplayName = "ApprovalOverdueReminderDto: Email и унаследованный ReceiverEmail — одно и то же значение")]
public void ApprovalOverdue_EmailAndReceiverEmail_AreTheSameValue()
{
var dto = new ApprovalOverdueReminderDto(
Email: "approver@tnsu.kz",
HoursOverdue: 5,
AskOverdueReason: false,
RequestTypeId: 1,
RequestTypeName: "Тип",
RequestId: 10,
Status: "На согласовании",
RequestUniqueCode: "code",
Url: "https://tnsukz.sharepoint.com/sites/requests");
dto.Email.ShouldBe("approver@tnsu.kz");
dto.ReceiverEmail.ShouldBe(dto.Email);
}
}
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -142,7 +143,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
reminder.Email.ShouldBe("approver@tnsu.kz"); reminder.Email.ShouldBe("approver@tnsu.kz");
reminder.HoursOverdue.ShouldBe(24); reminder.HoursOverdue.ShouldBe(24);
reminder.RequestId.ShouldBe(1); reminder.RequestId.ShouldBe(1);
reminder.Category.ShouldBe(ReminderCategory.ApprovalOverdue);
} }
[Fact(DisplayName = "Срок ещё не вышел — напоминания нет")] [Fact(DisplayName = "Срок ещё не вышел — напоминания нет")]
@@ -356,7 +356,6 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
result.IsSuccess.ShouldBeTrue(); result.IsSuccess.ShouldBeTrue();
var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>(); var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType<ProbationEndingReminderDto>();
reminder.Category.ShouldBe(ReminderCategory.ProbationEnding);
reminder.ManagerEmail.ShouldBe("manager@tnsu.kz"); reminder.ManagerEmail.ShouldBe("manager@tnsu.kz");
reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz");
reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); reminder.CandidateName.ShouldBe("Иванов Иван Иванович");
@@ -471,4 +470,66 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
result.Value.ShouldBeEmpty(); 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);
}
} }