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.ListPartitioning; using Tnsu.ApprovalRoute.Application.Reports; 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; /// /// Сборка отчёта по вакансиям из трёх источников: заявка на подбор, её фаза /// исполнения (рекрутёр и дата закрытия) и связанные заявки на приём (кого и /// когда приняли). /// /// SharePoint подменён моком, отдающим сырой JSON ровно в том виде, в каком его /// возвращает REST, — так тесты заодно покрывают и разбор ответа (в частности /// дробные «1.0» у Number-колонок). /// public sealed class VacancyReportServiceTests : IClassFixture { private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests"; private const string DataListTitle = "Заявка на подбор — данные"; private const string ApprovalListTitle = "Заявка на подбор — согласование"; private static readonly DateOnly From = new(2026, 7, 1); private static readonly DateOnly To = new(2026, 8, 31); private readonly ServiceProvider _services; private readonly RequestTypeService _requestTypeService; public VacancyReportServiceTests(PostgresDatabaseFixture fixture) { _services = TestServiceProviderFactory.Build(fixture.ConnectionString); _requestTypeService = _services.GetRequiredService(); } // База у тестов класса общая, а сервис ищет тип заявки по PageUrl — поэтому // у каждого теста свои списки и PageUrl (оба выводятся из имени теста), и // тесты не видят записи друг друга. private static string SelectionListUrl(string name) => $"/sites/requests/Lists/Подбор-{name}"; private static string HireListUrl(string name) => $"/sites/requests/Lists/Приём-{name}"; private async Task CreateTypeAsync(string name, string listUrl, string pageUrl) => await _requestTypeService.CreateAsync( new CreateRequestTypeRequest(name, pageUrl, listUrl, SiteUrl, "General"), CancellationToken.None); private static JsonElement Json(object value) => JsonSerializer.SerializeToElement(value); /// Элемент списка «Заявка на подбор — данные» в том виде, в каком его отдаёт REST. private static object Selection( int id, string position, string? objectProject = null, double vacancies = 1, string? submitDate = "2026-07-08T00:00:00Z", string? created = "2026-07-08T00:00:00Z", string? responsible = "Ким Наталья Юрьевна", string? openReason = null, string? openReasonDetails = null) => new { Id = id, RequestStatus = "Выполнение", Position = position, DepartmentName = "офис", ObjectProject = objectProject, // Number-колонка приходит дробной («1.0») — тест держит её такой же. OpenVacanciesCount = vacancies, SubmitDate = submitDate, Created = created, OpenReason = openReason, OpenReasonDetails = openReasonDetails, Responsible = new { Title = responsible } }; /// Строка фазы «Исполнение» из списка «Заявка на подбор — согласование». private static object Execution( int ticketId, string status = "Выполнено", string? date = "2026-07-22T10:00:00Z", double stepOrder = 1, string? executor = "Сергеева Надежда Ивановна") => new { Id = ticketId * 100 + (int)stepOrder, TicketId = ticketId, ApprovalStatus = status, StepOrder = stepOrder, Date = date, TansuSiteApprover = new { Title = executor } }; /// /// Элемент списка «Прием на работу — данные». selectionListUrl — партиция, в /// которой лежит заявка на подбор: связь идёт по паре (Id, партиция), а не по /// одному Id — в разных партициях Id повторяются. /// private static object Hire( int selectionId, string selectionListUrl, string status = "Закрыта", string? hireDate = "2026-08-17T00:00:00Z", string? replaced = null) => new { Id = selectionId * 10, RequestStatus = status, SelectionRequestId = (double)selectionId, SelectionRequestListId = selectionListUrl, CandidateName = "Шакенов Анель", ReplacedEmployee = replaced, HireDate = hireDate }; private async Task<(VacancyReportService Service, Mock SharePoint)> BuildAsync( string name, IReadOnlyList selections, IReadOnlyList? executions = null, IReadOnlyList? hires = null) { var selectionList = SelectionListUrl(name); var hireList = HireListUrl(name); var selectionPage = $"/подбор-{name}"; var hirePage = $"/приём-{name}"; await CreateTypeAsync($"Подбор {name}", selectionList, selectionPage); await CreateTypeAsync($"Приём {name}", hireList, hirePage); var sharePoint = new Mock(); sharePoint .Setup(x => x.GetItemsByListUrlAsync( SiteUrl, selectionList, It.Is(q => q.Contains("OpenVacanciesCount")), It.IsAny())) .ReturnsAsync(selections.Select(Json).ToList()); sharePoint .Setup(x => x.GetListTitleAsync(SiteUrl, selectionList, It.IsAny())) .ReturnsAsync(DataListTitle); sharePoint .Setup(x => x.GetItemsByListTitleAsync( SiteUrl, ApprovalListTitle, It.Is(q => q.Contains("Исполнение")), It.IsAny())) .ReturnsAsync((executions ?? []).Select(Json).ToList()); sharePoint .Setup(x => x.GetItemsByListUrlAsync( SiteUrl, hireList, It.Is(q => q.Contains("SelectionRequestId")), It.IsAny())) .ReturnsAsync((hires ?? []).Select(Json).ToList()); var service = new VacancyReportService( _services.GetRequiredService(), sharePoint.Object, _services.GetRequiredService(), Options.Create(new ReportsOptions { RecruitingSelectionPageUrl = selectionPage, RecruitingHirePageUrl = hirePage }), NullLogger.Instance); return (service, sharePoint); } private static VacancyReportRow SingleRow(VacancyReport report) => report.Groups.ShouldHaveSingleItem().Rows.ShouldHaveSingleItem(); [Fact(DisplayName = "Закрытая вакансия: приём даёт число закрытых, «да» и дату выхода")] public async Task ClosedVacancy_FilledFromHire() { const string name = "closed"; var (service, _) = await BuildAsync( name, [Selection(1, "Зам нач.юр.отдела")], [Execution(1, date: "2026-07-08T10:00:00Z")], [Hire(1, SelectionListUrl(name))]); var result = await service.GetAsync(From, To, CancellationToken.None); result.IsSuccess.ShouldBeTrue(); var row = SingleRow(result.Value); row.Position.ShouldBe("Зам нач.юр.отдела"); row.Department.ShouldBe("офис"); row.VacanciesCount.ShouldBe(1); row.ClosedCount.ShouldBe(1); row.ClosedFlag.ShouldBe(VacancyClosedFlags.Yes); row.SubmitDate.ShouldBe(new DateOnly(2026, 7, 8)); row.ClosingDate.ShouldBe(new DateOnly(2026, 7, 8)); row.StartDate.ShouldBe(new DateOnly(2026, 8, 17)); row.Initiator.ShouldBe("Ким Н.Ю."); row.Recruiter.ShouldBe("Сергеева Надежда Ивановна"); } [Fact(DisplayName = "Приём из другой партиции не считается закрытием этой вакансии")] public async Task Hire_FromOtherPartition_NotMatched() { var (service, _) = await BuildAsync( "otherPartition", [Selection(1, "Зам нач.юр.отдела")], [Execution(1)], [Hire(1, "/sites/requests/Lists/СовсемДругаяПартиция")]); var result = await service.GetAsync(From, To, CancellationToken.None); var row = SingleRow(result.Value); row.ClosedCount.ShouldBe(0); row.ClosedFlag.ShouldBe(VacancyClosedFlags.No); row.StartDate.ShouldBeNull(); } [Fact(DisplayName = "Частично закрытая: 2 вакансии, 1 приём → «частично»")] public async Task PartiallyClosed() { const string name = "partial"; var (service, _) = await BuildAsync( name, [Selection(1, "Логист", vacancies: 2)], [Execution(1, status: "На исполнении", date: null)], [Hire(1, SelectionListUrl(name))]); var result = await service.GetAsync(From, To, CancellationToken.None); var row = SingleRow(result.Value); row.VacanciesCount.ShouldBe(2); row.ClosedCount.ShouldBe(1); row.ClosedFlag.ShouldBe(VacancyClosedFlags.Partial); } [Fact(DisplayName = "Незавершённая фаза исполнения — даты закрытия нет, рекрутёр есть")] public async Task ExecutionInProgress_NoClosingDate() { var (service, _) = await BuildAsync( "inprogress", [Selection(1, "Начальник ОИД")], [ Execution(1, status: "Выполнено", date: "2026-07-10T00:00:00Z", stepOrder: 1), Execution(1, status: "На исполнении", date: null, stepOrder: 2, executor: "Ким Дания Сериковна") ]); var result = await service.GetAsync(From, To, CancellationToken.None); var row = SingleRow(result.Value); row.ClosingDate.ShouldBeNull(); // Рекрутёр — исполнитель ПОСЛЕДНЕГО шага: вакансию доводит он. row.Recruiter.ShouldBe("Ким Дания Сериковна"); } [Fact(DisplayName = "Отклонённая заявка на приём вакансию не закрывает")] public async Task RejectedHire_DoesNotClose() { const string name = "rejected"; var (service, _) = await BuildAsync( name, [Selection(1, "Юрист")], [Execution(1)], [Hire(1, SelectionListUrl(name), status: "Отклонено")]); var result = await service.GetAsync(From, To, CancellationToken.None); var row = SingleRow(result.Value); row.ClosedCount.ShouldBe(0); row.ClosedFlag.ShouldBe(VacancyClosedFlags.No); } [Fact(DisplayName = "«Дата выхода» — самая поздняя среди нескольких приёмов")] public async Task StartDate_IsLatestHireDate() { const string name = "several"; var list = SelectionListUrl(name); var (service, _) = await BuildAsync( name, [Selection(1, "Разнорабочий", vacancies: 2)], [Execution(1)], [ Hire(1, list, hireDate: "2026-08-05T00:00:00Z"), Hire(1, list, hireDate: "2026-08-25T00:00:00Z") ]); var result = await service.GetAsync(From, To, CancellationToken.None); var row = SingleRow(result.Value); row.ClosedCount.ShouldBe(2); row.StartDate.ShouldBe(new DateOnly(2026, 8, 25)); } [Fact(DisplayName = "Пустая «Дата подачи заявки» — берётся дата создания элемента")] public async Task SubmitDateEmpty_FallsBackToCreated() { var (service, _) = await BuildAsync( "created", [Selection(1, "Прораб", submitDate: null, created: "2026-07-30T09:00:00Z")]); var result = await service.GetAsync(From, To, CancellationToken.None); SingleRow(result.Value).SubmitDate.ShouldBe(new DateOnly(2026, 7, 30)); } [Fact(DisplayName = "Пустое «Количество открытых вакансий» — это одна вакансия")] public async Task VacanciesCountEmpty_IsOne() { var (service, _) = await BuildAsync("noCount", [Selection(1, "Фельдшер", vacancies: 0)]); var result = await service.GetAsync(From, To, CancellationToken.None); SingleRow(result.Value).VacanciesCount.ShouldBe(1); } [Fact(DisplayName = "Группировка: сначала вакансии без объекта, потом объекты по алфавиту")] public async Task Groups_UnnamedFirstThenAlphabetical() { var (service, _) = await BuildAsync( "groups", [ Selection(1, "Оператор катка", objectProject: "КЭС КУРЧАТОВ"), Selection(2, "Логист"), Selection(3, "Начальник участка", objectProject: "Авиа-ангар г.Атырау") ]); var result = await service.GetAsync(From, To, CancellationToken.None); result.Value.Groups.Select(g => g.Name).ShouldBe(["", "Авиа-ангар г.Атырау", "КЭС КУРЧАТОВ"]); } [Fact(DisplayName = "«Вместо сотрудника» — из причины открытия, только при замене уволенного")] public async Task ReplacedEmployee_OnlyForReplacementReason() { var (service, _) = await BuildAsync( "replaced", [ Selection(1, "Начальник геодезии", openReason: "Замена уволенного сотрудника", openReasonDetails: "Серик"), Selection(2, "Инженер ВиВ", openReason: "Новая штатная единица", openReasonDetails: "по расширению штата") ]); var result = await service.GetAsync(From, To, CancellationToken.None); var rows = result.Value.Groups.ShouldHaveSingleItem().Rows; rows.Single(r => r.Position == "Начальник геодезии").ReplacedEmployee.ShouldBe("Серик"); rows.Single(r => r.Position == "Инженер ВиВ").ReplacedEmployee.ShouldBeNull(); } [Fact(DisplayName = "Конец периода раньше начала — ошибка, а не пустой файл")] public async Task InvertedPeriod_Fails() { var (service, _) = await BuildAsync("inverted", [Selection(1, "Логист")]); var result = await service.GetAsync(To, From, CancellationToken.None); result.IsFailed.ShouldBeTrue(); } [Fact(DisplayName = "Фильтр периода уходит в SharePoint с откатом на Created")] public async Task PeriodFilter_PassedToSharePoint() { const string name = "filter"; var (service, sharePoint) = await BuildAsync(name, [Selection(1, "Логист")]); await service.GetAsync(From, To, CancellationToken.None); sharePoint.Verify( x => x.GetItemsByListUrlAsync( SiteUrl, SelectionListUrl(name), It.Is(q => q.Contains("SubmitDate ge datetime'2026-07-01T00:00:00Z'") // Верхняя граница — начало следующего дня и строгое lt: иначе // заявки за сам последний день периода отсеклись бы. && q.Contains("SubmitDate lt datetime'2026-09-01T00:00:00Z'") && q.Contains("SubmitDate eq null and Created ge")), It.IsAny()), Times.Once); } }