заработало
This commit is contained in:
@@ -78,18 +78,3 @@ internal static class ApprovalStatuses
|
|||||||
public const string OnApproval = "На согласовании";
|
public const string OnApproval = "На согласовании";
|
||||||
public const string OnExecution = "На исполнении";
|
public const string OnExecution = "На исполнении";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Managed property статуса строки маршрута для KQL — ApprovalStatusOWSCHCS.
|
|
||||||
/// Имя нестандартное (суффикс OWSCHCS у Choice-поля, префикс группы колонок
|
|
||||||
/// сайта у самого имени) — совпадает с MP_STATUS в
|
|
||||||
/// request-portal/src/webparts/requestPortal/features/myRequests/myRequests.service.ts,
|
|
||||||
/// это тот же список и та же колонка, имя проверено там же.
|
|
||||||
/// </summary>
|
|
||||||
internal static class SlaStatus
|
|
||||||
{
|
|
||||||
private const string ManagedProperty = "ApprovalStatusOWSCHCS";
|
|
||||||
|
|
||||||
public static readonly string OnApprovalProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnApproval}\"";
|
|
||||||
public static readonly string OnExecutionProperty = $"{ManagedProperty}:\"{ApprovalStatuses.OnExecution}\"";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ public sealed class ReminderService(
|
|||||||
// на большом списке получится неподъёмной длины URL.
|
// на большом списке получится неподъёмной длины URL.
|
||||||
private const int IdChunkSize = 50;
|
private const int IdChunkSize = 50;
|
||||||
|
|
||||||
// Верхняя граница на число одновременно активных шагов по типу заявки —
|
// $filter по Choice-колонке — сравнение на равенство, а не текстовый поиск,
|
||||||
// запас с большим избытком (это не общий объём заявок, а только те, что
|
// поэтому кавычки вокруг значения достаточно (в самих статусах нет апострофов).
|
||||||
// прямо сейчас на согласовании/исполнении).
|
private const string ActiveStatusFilter =
|
||||||
private const int SearchRowLimit = 5000;
|
$"$filter=ApprovalStatus eq '{ApprovalStatuses.OnApproval}' or ApprovalStatus eq '{ApprovalStatuses.OnExecution}'";
|
||||||
|
|
||||||
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
public async Task<Result<IReadOnlyList<ReminderDto>>> GetOverdueAsync(
|
||||||
DateTimeOffset now, CancellationToken cancellationToken)
|
DateTimeOffset now, CancellationToken cancellationToken)
|
||||||
@@ -81,37 +81,17 @@ public sealed class ReminderService(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Шаг 1. Активные строки — через Search, а не через $filter: дело не в
|
// Шаг 1. Активные строки — прямым $filter по полю ApprovalStatus.
|
||||||
// кириллице конкретно, а в том, что $filter по Choice-колонке в
|
|
||||||
// SharePoint REST в принципе ненадёжен (молча возвращает 0 строк вместо
|
|
||||||
// ошибки). Managed property ApprovalStatusOWSCHCS для этой же колонки
|
|
||||||
// этого же списка уже проверена и используется в request-portal
|
|
||||||
// (см. MP_STATUS в myRequests.service.ts) — тот же приём годится и тут.
|
|
||||||
//
|
//
|
||||||
// Так же решается вопрос объёма: без Search пришлось бы читать ВСЕ
|
// Ранее здесь стоял Search по managed property (ApprovalStatusOWSCHCS) —
|
||||||
// нерешённые строки списка (queued + active), чтобы отобрать активные
|
// тот же приём, что в request-portal (см. MP_STATUS). На практике он
|
||||||
// на клиенте — при большом архиве заявок это росло бы неограниченно.
|
// строки не нашёл: индекс мог не подхватить именно эту managed property
|
||||||
// Search сужает набор на стороне SharePoint до тех, что реально нужны.
|
// для этого списка (она формируется автоматически, и после провижининга
|
||||||
var relativeUrl = await sharePoint.GetListServerRelativeUrlAsync(siteUrl, approvalListTitle, cancellationToken);
|
// колонок SLA переиндексация могла ещё не пройти) — сама причина не
|
||||||
if (string.IsNullOrWhiteSpace(relativeUrl))
|
// подтверждена, а вот результат — нет. Прямой $filter по полю сработал:
|
||||||
{
|
// он не отстаёт от реального состояния списка (не зависит от индексации
|
||||||
logger.LogWarning("Список согласования «{List}» не найден", approvalListTitle);
|
// вовсе), и подтверждённой на практике проблемы с ним на этом списке нет.
|
||||||
return [];
|
var active = await ReadRowsAsync(siteUrl, approvalListTitle, ActiveStatusFilter, cancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
var pathClause = $"Path:\"{siteUrl.TrimEnd('/')}{relativeUrl}*\"";
|
|
||||||
var statusClause = $"({SlaStatus.OnApprovalProperty} OR {SlaStatus.OnExecutionProperty})";
|
|
||||||
var activeIds = await sharePoint.SearchListItemIdsAsync(
|
|
||||||
siteUrl, $"{pathClause} AND {statusClause}", SearchRowLimit, cancellationToken);
|
|
||||||
if (activeIds.Count == 0) return [];
|
|
||||||
|
|
||||||
// Search отдаёт только Id — реальные поля (email, SlaHours, TicketId и
|
|
||||||
// т.д.) дочитываем REST'ом, как и везде в этом сервисе (см. ReadCreatedAsync).
|
|
||||||
// Заодно и статус здесь берётся из свежих данных, а не из индекса, который
|
|
||||||
// мог успеть отстать (та же оговорка, что в request-portal).
|
|
||||||
var active = (await ReadRowsByIdsAsync(siteUrl, approvalListTitle, activeIds, cancellationToken))
|
|
||||||
.Where(r => r.Status is ApprovalStatuses.OnApproval or ApprovalStatuses.OnExecution)
|
|
||||||
.ToList();
|
|
||||||
if (active.Count == 0) return [];
|
if (active.Count == 0) return [];
|
||||||
|
|
||||||
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
|
// Шаг 2. По заявкам с активными строками нужны ВСЕ строки маршрута —
|
||||||
@@ -239,20 +219,6 @@ public sealed class ReminderService(
|
|||||||
return Parse(items);
|
return Parse(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Дочитать полные поля строк по Id, найденным через Search (см. GetOverdueForTypeAsync).</summary>
|
|
||||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByIdsAsync(
|
|
||||||
string siteUrl, string listTitle, IReadOnlyList<int> ids, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var rows = new List<ApprovalRow>();
|
|
||||||
foreach (var chunk in ids.Chunk(IdChunkSize))
|
|
||||||
{
|
|
||||||
var filter = "$filter=" + string.Join(" or ", chunk.Select(id => $"Id eq {id}"));
|
|
||||||
rows.AddRange(await ReadRowsAsync(siteUrl, listTitle, filter, cancellationToken));
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
|
private async Task<IReadOnlyList<ApprovalRow>> ReadRowsByTicketsAsync(
|
||||||
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
string siteUrl, string listTitle, IReadOnlyList<int> ticketIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,12 +12,6 @@ public interface ISharePointClient
|
|||||||
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
||||||
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Серверный относительный URL списка, найденного по заголовку — нужен, чтобы
|
|
||||||
/// построить условие Path в KQL-запросе (см. SearchListItemIdsAsync).
|
|
||||||
/// </summary>
|
|
||||||
Task<string?> GetListServerRelativeUrlAsync(string siteUrl, string listTitle, CancellationToken cancellationToken);
|
|
||||||
|
|
||||||
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
|
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
|
||||||
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
||||||
@@ -25,17 +19,6 @@ public interface ISharePointClient
|
|||||||
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
|
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
|
||||||
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
|
||||||
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Id элементов, найденных Search'ем по KQL-запросу. Именно так, а не $filter,
|
|
||||||
/// сужается набор строк списка согласования по статусу (Choice-колонка) —
|
|
||||||
/// $filter по ней в SharePoint REST ненадёжен: см. подробный разбор в
|
|
||||||
/// request-portal/myRequests.service.ts (MP_STATUS/ApprovalStatusOWSCHCS,
|
|
||||||
/// тот же список, тот же managed property, подтверждено рабочим). Значения
|
|
||||||
/// колонок не возвращает — только Id, дочитывать поля нужно отдельным REST-запросом.
|
|
||||||
/// </summary>
|
|
||||||
Task<IReadOnlyList<int>> SearchListItemIdsAsync(
|
|
||||||
string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -67,81 +50,6 @@ public sealed class SharePointClient(
|
|||||||
return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
|
return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> GetListServerRelativeUrlAsync(
|
|
||||||
string siteUrl, string listTitle, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')" +
|
|
||||||
"?$select=RootFolder/ServerRelativeUrl&$expand=RootFolder";
|
|
||||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
|
||||||
|
|
||||||
return ParseServerRelativeUrl(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IReadOnlyList<int>> SearchListItemIdsAsync(
|
|
||||||
string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var url = $"{siteUrl.TrimEnd('/')}/_api/search/query" +
|
|
||||||
$"?querytext='{Uri.EscapeDataString(queryText)}'" +
|
|
||||||
"&selectproperties='ListItemID'" +
|
|
||||||
$"&rowlimit={rowLimit}" +
|
|
||||||
"&trimduplicates=false";
|
|
||||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
|
||||||
|
|
||||||
return ParseSearchListItemIds(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Разбор вынесен в чистые статические функции — они не ходят в сеть и не
|
|
||||||
// требуют токена, поэтому их (в отличие от остального клиента) можно
|
|
||||||
// проверить тестом без живого SharePoint. Это самая непроверенная часть
|
|
||||||
// клиента: форма ответа Search REST зафиксирована документацией, но без
|
|
||||||
// реального тенанта убедиться в точном соответствии нельзя.
|
|
||||||
internal static string? ParseServerRelativeUrl(JsonElement json) =>
|
|
||||||
json.TryGetProperty("RootFolder", out var rootFolder) &&
|
|
||||||
rootFolder.TryGetProperty("ServerRelativeUrl", out var relUrl)
|
|
||||||
? relUrl.GetString()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
internal static IReadOnlyList<int> ParseSearchListItemIds(JsonElement json)
|
|
||||||
{
|
|
||||||
// Путь до строк результата — фиксированная форма ответа Search REST,
|
|
||||||
// не зависит от запроса. С odata=nometadata Cells приходит уже "голым"
|
|
||||||
// массивом (без обёртки .results) — но на случай, если сервер всё равно
|
|
||||||
// завернёт его (разные версии API отвечали по-разному на практике в этом
|
|
||||||
// проекте, см. комментарии в request-portal про managed properties),
|
|
||||||
// читаем и то, и то (см. Unwrap).
|
|
||||||
if (!json.TryGetProperty("PrimaryQueryResult", out var primary)) return [];
|
|
||||||
if (!primary.TryGetProperty("RelevantResults", out var relevant)) return [];
|
|
||||||
if (!relevant.TryGetProperty("Table", out var table)) return [];
|
|
||||||
if (!table.TryGetProperty("Rows", out var rowsElement)) return [];
|
|
||||||
|
|
||||||
var ids = new List<int>();
|
|
||||||
|
|
||||||
foreach (var row in Unwrap(rowsElement))
|
|
||||||
{
|
|
||||||
if (!row.TryGetProperty("Cells", out var cellsElement)) continue;
|
|
||||||
|
|
||||||
foreach (var cell in Unwrap(cellsElement))
|
|
||||||
{
|
|
||||||
if (!cell.TryGetProperty("Key", out var key) || key.GetString() != "ListItemID") continue;
|
|
||||||
if (!cell.TryGetProperty("Value", out var value)) continue;
|
|
||||||
if (int.TryParse(value.GetString(), out var id)) ids.Add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
// odata=nometadata отдаёт коллекции внутри Search-ответа "голым" массивом;
|
|
||||||
// без этого заголовка (или в старых версиях API) — обёрткой { results: [...] }.
|
|
||||||
private static IEnumerable<JsonElement> Unwrap(JsonElement element)
|
|
||||||
{
|
|
||||||
if (element.ValueKind == JsonValueKind.Array) return element.EnumerateArray();
|
|
||||||
|
|
||||||
return element.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array
|
|
||||||
? results.EnumerateArray()
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
public Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
||||||
GetItemsAsync(
|
GetItemsAsync(
|
||||||
|
|||||||
@@ -2,13 +2,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
<ProjectReference Include="..\Tnsu.ApprovalRoute.Domain\Tnsu.ApprovalRoute.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<!-- Даёт тестам доступ к internal-функциям чистого разбора JSON в SharePointClient
|
|
||||||
(SharePointClientParsingTests) — сам клиент не потестировать без живого
|
|
||||||
тенанта (аутентификация по сертификату ходит в Azure AD), а разбор ответа
|
|
||||||
можно и нужно проверять без сети. -->
|
|
||||||
<InternalsVisibleTo Include="Tnsu.ApprovalRoute.IntegrationTests" />
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). -->
|
<!-- ClientCertificateCredential — вход в SharePoint по сертификату (см. SharePoint/). -->
|
||||||
<PackageReference Include="Azure.Identity" />
|
<PackageReference Include="Azure.Identity" />
|
||||||
|
|||||||
+3
-16
@@ -77,28 +77,15 @@ public sealed class ReminderServiceTests : IClassFixture<PostgresDatabaseFixture
|
|||||||
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
.Setup(x => x.GetListTitleAsync(SiteUrl, listUrl, It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(DataListTitle);
|
.ReturnsAsync(DataListTitle);
|
||||||
|
|
||||||
sharePoint
|
// Активные строки сервис отбирает прямым $filter по ApprovalStatus — мок
|
||||||
.Setup(x => x.GetListServerRelativeUrlAsync(SiteUrl, ApprovalListTitle, It.IsAny<CancellationToken>()))
|
// отвечает так же: по значению статуса, а не по признаку решённости.
|
||||||
.ReturnsAsync("/sites/requests/Lists/ApprovalListRelUrl");
|
|
||||||
|
|
||||||
// Search сужает набор до активных строк (На согласовании/На исполнении) —
|
|
||||||
// мок отвечает так же, как реальный Search: по статусу строки, не по дате.
|
|
||||||
var activeRows = allRows
|
var activeRows = allRows
|
||||||
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
.Where(r => (string)r.GetType().GetProperty("ApprovalStatus")!.GetValue(r)! is
|
||||||
"На согласовании" or "На исполнении")
|
"На согласовании" or "На исполнении")
|
||||||
.ToList();
|
.ToList();
|
||||||
sharePoint
|
|
||||||
.Setup(x => x.SearchListItemIdsAsync(
|
|
||||||
SiteUrl, It.Is<string>(q => q.Contains("ApprovalStatusOWSCHCS")), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
|
||||||
.ReturnsAsync(activeRows.Select(r => (int)r.GetType().GetProperty("Id")!.GetValue(r)!).ToList());
|
|
||||||
|
|
||||||
// Дочитывание полных полей найденных Search'ем строк — по Id. Матчер ищет
|
|
||||||
// "filter=Id eq", а не просто "Id eq" — иначе он совпал бы и с
|
|
||||||
// "TicketId eq" (та подстрока в нём есть буквально), и два setup'а
|
|
||||||
// конфликтовали бы между собой.
|
|
||||||
sharePoint
|
sharePoint
|
||||||
.Setup(x => x.GetItemsByListTitleAsync(
|
.Setup(x => x.GetItemsByListTitleAsync(
|
||||||
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("filter=Id eq")), It.IsAny<CancellationToken>()))
|
SiteUrl, ApprovalListTitle, It.Is<string>(q => q.Contains("ApprovalStatus eq")), It.IsAny<CancellationToken>()))
|
||||||
.ReturnsAsync(activeRows.Select(Json).ToList());
|
.ReturnsAsync(activeRows.Select(Json).ToList());
|
||||||
|
|
||||||
sharePoint
|
sharePoint
|
||||||
|
|||||||
-99
@@ -1,99 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Shouldly;
|
|
||||||
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
|
||||||
|
|
||||||
namespace Tnsu.ApprovalRoute.IntegrationTests.Infrastructure;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Разбор ответов SharePoint REST — единственная часть SharePointClient, которую
|
|
||||||
/// можно проверить без живого тенанта: сам HTTP-вызов неотделим от аутентификации
|
|
||||||
/// по сертификату (ClientCertificateCredential ходит в Azure AD за токеном перед
|
|
||||||
/// каждым запросом), а вот разбор уже полученного JSON — чистая функция.
|
|
||||||
///
|
|
||||||
/// Это самое непроверенное место всей интеграции: форма ответа Search REST
|
|
||||||
/// (_api/search/query) задокументирована, но подтвердить её точное соответствие
|
|
||||||
/// без реального тенанта нельзя — только этими тестами на образцах ответа.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class SharePointClientParsingTests
|
|
||||||
{
|
|
||||||
private static JsonElement Parse(string json) => JsonDocument.Parse(json).RootElement;
|
|
||||||
|
|
||||||
[Fact(DisplayName = "Search: с odata=nometadata Rows/Cells приходят голыми массивами")]
|
|
||||||
public void ParseSearchListItemIds_BareArrays_ReturnsIds()
|
|
||||||
{
|
|
||||||
const string response = """
|
|
||||||
{
|
|
||||||
"PrimaryQueryResult": {
|
|
||||||
"RelevantResults": {
|
|
||||||
"RowCount": 2,
|
|
||||||
"Table": {
|
|
||||||
"Rows": [
|
|
||||||
{ "Cells": [ { "Key": "Rank", "Value": "1" }, { "Key": "ListItemID", "Value": "11" } ] },
|
|
||||||
{ "Cells": [ { "Key": "ListItemID", "Value": "27" }, { "Key": "Rank", "Value": "2" } ] }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
var ids = SharePointClient.ParseSearchListItemIds(Parse(response));
|
|
||||||
|
|
||||||
ids.ShouldBe([11, 27]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact(DisplayName = "Search: без odata=nometadata Rows/Cells завёрнуты в {results: [...]}")]
|
|
||||||
public void ParseSearchListItemIds_WrappedResults_ReturnsIds()
|
|
||||||
{
|
|
||||||
const string response = """
|
|
||||||
{
|
|
||||||
"PrimaryQueryResult": {
|
|
||||||
"RelevantResults": {
|
|
||||||
"Table": {
|
|
||||||
"Rows": {
|
|
||||||
"results": [
|
|
||||||
{ "Cells": { "results": [ { "Key": "ListItemID", "Value": "42" } ] } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
var ids = SharePointClient.ParseSearchListItemIds(Parse(response));
|
|
||||||
|
|
||||||
ids.ShouldBe([42]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact(DisplayName = "Search: нет совпадений — пустой список, а не исключение")]
|
|
||||||
public void ParseSearchListItemIds_NoRows_ReturnsEmpty()
|
|
||||||
{
|
|
||||||
const string response = """{ "PrimaryQueryResult": { "RelevantResults": { "RowCount": 0, "Table": { "Rows": [] } } } } """;
|
|
||||||
|
|
||||||
SharePointClient.ParseSearchListItemIds(Parse(response)).ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact(DisplayName = "Search: неожиданная форма ответа не роняет разбор")]
|
|
||||||
public void ParseSearchListItemIds_UnexpectedShape_ReturnsEmpty()
|
|
||||||
{
|
|
||||||
SharePointClient.ParseSearchListItemIds(Parse("""{ "odata.error": "что-то не так" }""")).ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact(DisplayName = "RootFolder/ServerRelativeUrl разбирается для условия Path в KQL")]
|
|
||||||
public void ParseServerRelativeUrl_ReturnsValue()
|
|
||||||
{
|
|
||||||
const string response = """
|
|
||||||
{ "RootFolder": { "ServerRelativeUrl": "/sites/requests/Lists/Заявка тест — согласование" } }
|
|
||||||
""";
|
|
||||||
|
|
||||||
SharePointClient.ParseServerRelativeUrl(Parse(response))
|
|
||||||
.ShouldBe("/sites/requests/Lists/Заявка тест — согласование");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact(DisplayName = "Список без RootFolder в ответе — null, а не исключение")]
|
|
||||||
public void ParseServerRelativeUrl_Missing_ReturnsNull()
|
|
||||||
{
|
|
||||||
SharePointClient.ParseServerRelativeUrl(Parse("{}")).ShouldBeNull();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user