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; /// /// Дата, с которой отсчитывается SLA, теперь читается прямо из активной строки /// (колонка Date — request-portal проставляет её в момент, когда строка /// становится текущей, см. approvalList.service.ts/setActive). Сервису больше /// не нужны ни весь маршрут заявки, ни дата её создания — эти сценарии тесты /// и проверяют вместо прежнего вывода даты из соседних строк. /// /// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его /// возвращает REST, — так тесты заодно покрывают и разбор ответа. /// public sealed class ReminderServiceTests : IClassFixture { private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests"; private const string DataListTitle = "Заявка тест — данные"; private const string ApprovalListTitle = "Заявка тест — согласование"; // Своё значение (не то, что по умолчанию в RemindersOptions) — так тест // проверяет, что ссылка действительно берётся из настроек, а не совпадает // с ожидаемой случайно. private const string ProbationReminderUrl = "https://portal.test/новая-заявка"; private static readonly DateTimeOffset Now = new(2026, 8, 12, 12, 0, 0, TimeSpan.Zero); // GetOverdueAsync/GetProbationEndingAsync по-прежнему берут now параметром — их // тесты ниже дают его напрямую. TimeProvider нужен только там, где ReminderService // сам решает, «сейчас» сколько — то есть в GetRemindersAsync (см. её doc-комментарий). private sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider { public override DateTimeOffset GetUtcNow() => now; } private static readonly TimeProvider Clock = new FixedTimeProvider(Now); private readonly ServiceProvider _services; private readonly RequestTypeService _requestTypeService; public ReminderServiceTests(PostgresDatabaseFixture fixture) { _services = TestServiceProviderFactory.Build(fixture.ConnectionString); _requestTypeService = _services.GetRequiredService(); } // База у тестов класса общая, а сервис обходит ВСЕ типы с настроенным SLA — // поэтому у каждого теста свой список: типы соседних тестов на мок не // настроены, заголовок списка для них не найдётся, и в выборку они не попадут. private async Task 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 rows) { var sharePoint = new Mock(); sharePoint .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) .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(q => q.Contains("ApprovalStatus eq")), It.IsAny())) .ReturnsAsync(activeRows.Select(Json).ToList()); // RequestUniqueCode запрашивается по списку данных сразу для всех активных // заявок — по умолчанию тесты его не проверяют, поэтому мок отдаёт пусто. sharePoint .Setup(x => x.GetItemsByListUrlAsync( SiteUrl, listUrl, It.Is(q => q.Contains("RequestUniqueCode")), It.IsAny())) .ReturnsAsync([]); return new ReminderService( _services.GetRequiredService(), sharePoint.Object, _services.GetRequiredService(), Mock.Of(), Clock, Options.Create(new RemindersOptions()), NullLogger.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(); reminder.Email.ShouldBe("approver@tnsu.kz"); reminder.HoursOverdue.ShouldBe(24); reminder.RequestId.ShouldBe(1); } [Fact(DisplayName = "GetRemindersAsync без feed — обратная совместимость, как ApprovalOverdue")] public async Task GetRemindersAsync_WithoutFeed_BehavesLikeApprovalOverdue() { var listUrl = await CreateTypeWithSlaAsync("Sla feed default", slaHours: 24); var service = BuildService(listUrl, [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]); var result = await service.GetRemindersAsync(CancellationToken.None); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldHaveSingleItem().ShouldBeOfType().HoursOverdue.ShouldBe(24); } [Fact(DisplayName = "GetRemindersAsync(feed: ProbationEnding) — просроченные шаги согласования игнорируются")] public async Task GetRemindersAsync_ProbationEndingFeed_IgnoresApprovalOverdue() { var listUrl = await CreateTypeWithSlaAsync("Sla feed probation", slaHours: 24); var service = BuildService(listUrl, [Row(10, 1, "На согласовании", "Согласование", date: Now.AddHours(-48).ToString("O"))]); var result = await service.GetRemindersAsync(ReminderFeed.ProbationEnding, CancellationToken.None); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldBeEmpty(); } [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().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().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().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().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().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 rows, Mock portalClient, int daysBeforeEnd = 3) { const string hireDataListTitle = "Прием на работу тест — данные"; var sharePoint = new Mock(); sharePoint .Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny())) .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(q => q.Contains("RequestStatus eq")), It.IsAny())) .ReturnsAsync(closedRows.Select(Json).ToList()); return new ReminderService( _services.GetRequiredService(), sharePoint.Object, _services.GetRequiredService(), portalClient.Object, Clock, Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderDaysBeforeEnd = daysBeforeEnd, ProbationReminderUrl = ProbationReminderUrl }), NullLogger.Instance); } private static Mock BuildPortalClientMock( string candidateFio, DateTimeOffset? dismissalDate, string? candidateEmail, string? managerEmail) { var portalClient = new Mock(); portalClient .Setup(x => x.GetFullBaseAsync(It.IsAny())) .ReturnsAsync([new EmployeeFullBaseInfo(Guid.NewGuid(), candidateFio, "ТОО TANSU", dismissalDate)]); portalClient .Setup(x => x.GetAllEmployeesAsync(It.IsAny())) .ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), candidateFio, candidateEmail, "ТОО TANSU")]); if (candidateEmail is not null) portalClient .Setup(x => x.GetManagerAsync(candidateEmail, "chief", It.IsAny())) .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(); reminder.ReceiverEmail.ShouldBe("manager@tnsu.kz"); reminder.CandidateEmail.ShouldBe("candidate@tnsu.kz"); reminder.CandidateName.ShouldBe("Иванов Иван Иванович"); reminder.DaysUntilProbationEnds.ShouldBe(3); // Ссылка ведёт не на саму заявку на приём, а на раздел, где заводят новую // (константа из настроек, см. RemindersOptions.ProbationReminderUrl). reminder.Url.ShouldBe(ProbationReminderUrl); } [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(); portalClient .Setup(x => x.GetFullBaseAsync(It.IsAny())) .ReturnsAsync( [ new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО АСТЕЛ-К", Now.AddDays(-10)), new EmployeeFullBaseInfo(Guid.NewGuid(), fio, "ТОО TANSU Construction", null) ]); portalClient .Setup(x => x.GetAllEmployeesAsync(It.IsAny())) .ReturnsAsync([new EmployeeContactInfo(Guid.NewGuid(), fio, "romanov@tnsu.kz", "ТОО TANSU Construction")]); portalClient .Setup(x => x.GetManagerAsync("romanov@tnsu.kz", "chief", It.IsAny())) .ReturnsAsync("manager@tnsu.kz"); var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); result.Value.ShouldHaveSingleItem().ShouldBeOfType().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().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(); portalClient.Setup(x => x.GetFullBaseAsync(It.IsAny())).ReturnsAsync([]); portalClient.Setup(x => x.GetAllEmployeesAsync(It.IsAny())).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(); } // Портал пишет дату приёма как МЕСТНУЮ полночь, переведённую в UTC, поэтому в // списке она лежит как 19:00Z предыдущего дня. Здесь 2026-06-14T19:00Z — это // 15 июня по местному календарю (см. LocalOffset в ReminderService): срок // истекает 15 августа, до него от Now (12 августа) ровно 3 дня. // Если считать по UTC, дата приёма превратилась бы в 14 июня, срок — в 14 // августа, и осталось бы 2 дня: напоминание не построилось бы вовсе. [Fact(DisplayName = "Дата приёма считается по местному календарю, а не по UTC")] public async Task HireDate_IsReadInLocalTimeZone() { var (listUrl, pageUrl) = await CreateHireTypeAsync("Probation timezone"); var rows = new[] { HireDataRow(1, "Закрыта", "Тимуров Тимур Тимурович", "2026-06-14T19:00:00Z", "2 месяца") }; var portalClient = BuildPortalClientMock( "Тимуров Тимур Тимурович", dismissalDate: null, candidateEmail: "timurov@tnsu.kz", managerEmail: "manager@tnsu.kz"); var service = BuildProbationService(pageUrl, listUrl, rows, portalClient); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); var reminder = result.Value.ShouldHaveSingleItem().ShouldBeOfType(); reminder.DaysUntilProbationEnds.ShouldBe(3); // И дата приёма, и конец срока отдаются в том же местном календаре. reminder.HireDate.Date.ShouldBe(new DateTime(2026, 6, 15)); reminder.ProbationEndsAt.Date.ShouldBe(new DateTime(2026, 8, 15)); } // Испытательный срок не бывает длиннее 6 месяцев (см. ProbationMonthsByOption // в ReminderService), поэтому заявка, способная дать напоминание сегодня, // физически не может лежать раньше предпоследней партиции. Три партиции: // самая старая (№1) содержит строку-ловушку — если сервис её прочитает, тест // это увидит по лишнему напоминанию; предпоследняя (№2) и текущая (№3) // читаются как обычно. [Fact(DisplayName = "Читаются только текущая и последняя архивная партиция — более старые игнорируются")] public async Task OnlyLastTwoPartitions_AreRead() { var (listUrl1, pageUrl) = await CreateHireTypeAsync("Probation partitions"); var db = _services.GetRequiredService(); var partitionRegistry = _services.GetRequiredService(); 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(); sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl1, It.IsAny())).ReturnsAsync("list1 — данные"); sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl2, It.IsAny())).ReturnsAsync("list2 — данные"); sharePoint.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl3, It.IsAny())).ReturnsAsync("list3 — данные"); sharePoint .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny(), It.IsAny())) .ReturnsAsync([Json(trapRow)]); sharePoint .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list2 — данные", It.IsAny(), It.IsAny())) .ReturnsAsync([Json(realRow)]); sharePoint .Setup(x => x.GetItemsByListTitleAsync(SiteUrl, "list3 — данные", It.IsAny(), It.IsAny())) .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, Clock, Options.Create(new RemindersOptions { RecruitingHirePageUrl = pageUrl, ProbationReminderUrl = ProbationReminderUrl }), NullLogger.Instance); var result = await service.GetProbationEndingAsync(Now, CancellationToken.None); result.IsSuccess.ShouldBeTrue(); result.Value.ShouldHaveSingleItem().ShouldBeOfType() .CandidateEmail.ShouldBe("partition@tnsu.kz"); sharePoint.Verify( x => x.GetItemsByListTitleAsync(SiteUrl, "list1 — данные", It.IsAny(), It.IsAny()), Times.Never); } }