Архивация списков #13

Merged
gorbenko.pavel merged 16 commits from archive into main 2026-08-19 09:08:37 +00:00
12 changed files with 443 additions and 21 deletions
Showing only changes of commit a1e6c5c8b4 - Show all commits
@@ -39,7 +39,8 @@
"TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a", "TenantId": "b4c2af35-bfd6-4cff-9af9-556efb53e65a",
"ClientId": "9232d9e2-9807-4753-808f-07250e5474b0", "ClientId": "9232d9e2-9807-4753-808f-07250e5474b0",
"CertificateSettingName": "AzurePfx", "CertificateSettingName": "AzurePfx",
"CertificatePassword": "Rk.xDjccnfyjdktybz1" "CertificatePassword": "Rk.xDjccnfyjdktybz1",
"NotificationUrl": "https://api.tnsu.kz/Webhook/SharePoint"
}, },
"TnsuPortal": { "TnsuPortal": {
"BaseUrl": "https://api.tnsu.kz", "BaseUrl": "https://api.tnsu.kz",
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint; using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.ListPartitioning; namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
@@ -35,11 +36,27 @@ public sealed class CreateNextListPartitionJob(
ListPartitioningLock provisioningLock, ListPartitioningLock provisioningLock,
ListPartitionRegistry registry, ListPartitionRegistry registry,
ISharePointListProvisioner provisioner, ISharePointListProvisioner provisioner,
TnsuPortalCallerToken callerToken,
IOptions<ListPartitioningOptions> options, IOptions<ListPartitioningOptions> options,
ILogger<CreateNextListPartitionJob> logger) ILogger<CreateNextListPartitionJob> logger)
{ {
public async Task RunAsync(int requestTypeId, CancellationToken cancellationToken) /// <param name="authorization">
/// Заголовок Authorization пользователя, чей запрос вызвал провижининг (см.
/// ListPartitionService.TryEnqueueProvisioningAsync). Нужен, чтобы подписать
/// новый список на уведомления от имени этого пользователя: своего HttpContext
/// у фоновой задачи нет, а client_credentials сейчас не настроен (см.
/// TnsuPortalAuthorizer). Провижининг занимает секунды, поэтому токен не
/// успевает истечь — но на ПОВТОРЕ Hangfire (минуты спустя) он уже может быть
/// просроченным: тогда подписка вернёт 401 и упадёт мягко, не сорвав создание
/// списка (см. ISharePointWebhookSubscriber).
///
/// Учтите: как аргумент задачи он попадает в хранилище Hangfire (Postgres) и
/// виден в его дашборде.
/// </param>
public async Task RunAsync(int requestTypeId, string? authorization, CancellationToken cancellationToken)
{ {
callerToken.Set(authorization);
// Единственная настоящая защита от параллельного запуска: атомарный захват // Единственная настоящая защита от параллельного запуска: атомарный захват
// флага в строке request_types. Проверка перед постановкой в очередь — // флага в строке request_types. Проверка перед постановкой в очередь —
// только чтобы не плодить лишние задачи; гонку разруливает именно здесь. // только чтобы не плодить лишние задачи; гонку разруливает именно здесь.
@@ -7,6 +7,7 @@ using Tnsu.ApprovalRoute.Application.Common.Errors;
using Tnsu.ApprovalRoute.Domain.Entities; using Tnsu.ApprovalRoute.Domain.Entities;
using Tnsu.ApprovalRoute.Infrastructure.Persistence; using Tnsu.ApprovalRoute.Infrastructure.Persistence;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint; using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Application.ListPartitioning; namespace Tnsu.ApprovalRoute.Application.ListPartitioning;
@@ -19,6 +20,7 @@ public sealed class ListPartitionService(
ISharePointClient sharePoint, ISharePointClient sharePoint,
ListPartitionRegistry registry, ListPartitionRegistry registry,
IBackgroundJobClient backgroundJobs, IBackgroundJobClient backgroundJobs,
TnsuPortalCallerToken callerToken,
IOptions<ListPartitioningOptions> options, IOptions<ListPartitioningOptions> options,
ILogger<ListPartitionService> logger) ILogger<ListPartitionService> logger)
{ {
@@ -151,8 +153,12 @@ public sealed class ListPartitionService(
// самой задачи (см. CreateNextListPartitionJob). // самой задачи (см. CreateNextListPartitionJob).
if (requestType.ListPartitioningStartedAt is not null) return false; if (requestType.ListPartitioningStartedAt is not null) return false;
// Токен ЭТОГО запроса уезжает в задачу: подписать новый список на
// уведомления нужно от имени пользователя, а своего токена у фоновой задачи
// нет (см. CreateNextListPartitionJob.RunAsync — там же про его хранение и
// срок жизни).
backgroundJobs.Enqueue<CreateNextListPartitionJob>( backgroundJobs.Enqueue<CreateNextListPartitionJob>(
job => job.RunAsync(requestType.Id, CancellationToken.None)); job => job.RunAsync(requestType.Id, callerToken.Current, CancellationToken.None));
logger.LogInformation( logger.LogInformation(
"В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " + "В списке {ListId} типа заявок {RequestTypeId} {ItemCount} элементов (порог {Threshold}) — " +
@@ -31,6 +31,21 @@ public static class InfrastructureServiceCollectionExtensions
services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal")); services.Configure<TnsuPortalOptions>(configuration.GetSection("TnsuPortal"));
services.AddHttpClient<TnsuPortalTokenProvider>(); services.AddHttpClient<TnsuPortalTokenProvider>();
// Токен вызывающего и общая авторизация запросов к порталу — одни на всех
// его клиентов (см. TnsuPortalCallerToken/TnsuPortalAuthorizer). Именно
// Scoped: Hangfire даёт задаче свою область DI, и положенный в задаче токен
// не должен протекать в соседние.
services.AddScoped<TnsuPortalCallerToken>();
services.AddScoped<TnsuPortalAuthorizer>();
// Подписка новых списков на уведомления SharePoint. Клиент отдельный от
// ITnsuPortalClient: там кешируемые чтения справочника, здесь команда.
// Своих опций нет — адрес приёмника лежит в SharePoint:NotificationUrl.
services.AddHttpClient<ISharePointWebhookSubscriber, HttpSharePointWebhookSubscriber>((sp, client) =>
{
var options = sp.GetRequiredService<IOptions<TnsuPortalOptions>>().Value;
client.BaseAddress = new Uri(options.BaseUrl);
});
// ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут, // ITnsuPortalClient резолвится в CachingTnsuPortalClient (кеш на 5 минут,
// см. её комментарий) — сам HttpTnsuPortalClient регистрируем по // см. её комментарий) — сам HttpTnsuPortalClient регистрируем по
// конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить // конкретному типу, а не по интерфейсу, чтобы декоратор мог его получить
@@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
@@ -16,6 +17,7 @@ namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
/// </summary> /// </summary>
public sealed class SharePointListProvisioner( public sealed class SharePointListProvisioner(
ISharePointClient sharePoint, ISharePointClient sharePoint,
ISharePointWebhookSubscriber webhookSubscriber,
ILogger<SharePointListProvisioner> logger) : ISharePointListProvisioner ILogger<SharePointListProvisioner> logger) : ISharePointListProvisioner
{ {
public async Task<ProvisionedList> CreateNextListPairAsync( public async Task<ProvisionedList> CreateNextListPairAsync(
@@ -47,6 +49,17 @@ public sealed class SharePointListProvisioner(
"Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).", "Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).",
number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl); number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl);
// Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы:
// • подписывать нечего, пока списка нет;
// • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку
// согласования подписка не нужна;
// • в самом конце, а не сразу после создания списка данных: сорвись
// дублирование пары на списке согласования, подписка была бы уже
// заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же
// подписку (идемпотентна ли ручка портала — неизвестно).
// Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber).
await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, cancellationToken);
return new ProvisionedList(newData.ServerRelativeUrl, newData.Title); return new ProvisionedList(newData.ServerRelativeUrl, newData.Title);
} }
@@ -20,4 +20,15 @@ public sealed class SharePointOptions
/// это здесь не стоит — пароль от сертификата в исходниках не место. /// это здесь не стоит — пароль от сертификата в исходниках не место.
/// </summary> /// </summary>
public string CertificatePassword { get; set; } = string.Empty; public string CertificatePassword { get; set; } = string.Empty;
/// <summary>
/// Куда SharePoint будет присылать уведомления об изменениях списка — параметр
/// notificationUrl ручки POST /Webhook/Subscribe, которой новый список данных
/// подписывается на уведомления (см. HttpSharePointWebhookSubscriber).
/// Это приёмник на СТОРОНЕ ПОРТАЛА, а не адрес нашего сервиса.
///
/// Пусто — подписывать не будем вовсе: без адреса приёмника подписка
/// бессмысленна, и это же единственный способ её выключить.
/// </summary>
public string NotificationUrl { get; set; } = "https://api.tnsu.kz/Webhook/SharePoint";
} }
@@ -0,0 +1,94 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// POST {BaseUrl}/Webhook/Subscribe?listName=…&amp;url=…&amp;notificationUrl=… — просит
/// портал завести подписку SharePoint на только что созданный список данных.
///
/// Параметры уходят строкой запроса, тела у запроса нет — так описана ручка.
///
/// Отдельный клиент, а не метод в ITnsuPortalClient: тот про справочник сотрудников
/// и обёрнут кешем (CachingTnsuPortalClient), а здесь команда, которую кешировать
/// нельзя ни в каком виде. Авторизация общая с ним — см. TnsuPortalAuthorizer.
///
/// Адрес приёмника уведомлений живёт в SharePoint:NotificationUrl — в одном блоке с
/// остальными настройками SharePoint, а не в своём: подписка относится к спискам,
/// а не к порталу как таковому.
///
/// Ошибки НЕ пробрасываются наружу. Список к этому моменту уже создан, и уронить
/// провижининг из-за недоступности портала значило бы оставить партиционирование
/// нерабочим целиком (задача Hangfire ушла бы в повторы, а порог по элементам
/// продолжал бы срабатывать). Поэтому сбой — Error в лог с полным контекстом
/// (имя списка и сайт видны, подписку можно завести руками), а провижининг идёт
/// дальше. Отдельно предупреждаем про 401/403: в фоновой задаче нет входящего
/// токена, а client_credentials требует заполненных TnsuPortal:ClientSecret/Scope
/// (в конфигурации они пустые — см. TnsuPortalAuthorizer).
/// </summary>
public sealed class HttpSharePointWebhookSubscriber(
HttpClient httpClient,
TnsuPortalAuthorizer authorizer,
IOptions<SharePointOptions> options,
ILogger<HttpSharePointWebhookSubscriber> logger) : ISharePointWebhookSubscriber
{
public async Task<bool> TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken)
{
var notificationUrl = options.Value.NotificationUrl;
if (string.IsNullOrWhiteSpace(notificationUrl))
{
// Без адреса приёмника подписка бессмысленна — он же служит выключателем
// (см. SharePointOptions.NotificationUrl).
logger.LogInformation(
"SharePoint:NotificationUrl не задан — список «{ListTitle}» на уведомления не подписан.",
listTitle);
return false;
}
if (string.IsNullOrWhiteSpace(listTitle))
{
// Без имени списка ручке нечего подписывать: у неё нет другого способа
// его найти (адрес списка она не принимает).
logger.LogError("Заголовок нового списка пуст — подписка на уведомления невозможна.");
return false;
}
// Полный адрес сайта целиком («https://tnsukz.sharepoint.com/sites/requests»),
// а не короткое имя — именно это ждёт ручка.
var site = siteUrl.TrimEnd('/');
var url =
$"/Webhook/Subscribe?listName={Uri.EscapeDataString(listTitle)}" +
$"&url={Uri.EscapeDataString(site)}" +
$"&notificationUrl={Uri.EscapeDataString(notificationUrl)}";
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, url);
await authorizer.AuthorizeAsync(request, cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
// Тело читаем именно на неуспехе — в нём причина отказа портала.
var body = await response.Content.ReadAsStringAsync(cancellationToken);
logger.LogError(
"Портал отказал в подписке списка «{ListTitle}» (сайт {Site}): {StatusCode}. Ответ: {Body}",
listTitle, site, (int)response.StatusCode, body);
return false;
}
logger.LogInformation(
"Список «{ListTitle}» (сайт {Site}) подписан на уведомления SharePoint.", listTitle, site);
return true;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Не удалось подписать список «{ListTitle}» (сайт {Site}) на уведомления — подпишите вручную.",
listTitle, site);
return false;
}
}
}
@@ -1,17 +1,14 @@
using System.Net; using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Http;
using Tnsu.ApprovalRoute.Domain.Enums; using Tnsu.ApprovalRoute.Domain.Enums;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal; namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
public sealed class HttpTnsuPortalClient( public sealed class HttpTnsuPortalClient(
HttpClient httpClient, HttpClient httpClient,
TnsuPortalTokenProvider tokenProvider, TnsuPortalAuthorizer authorizer) : ITnsuPortalClient
IHttpContextAccessor httpContextAccessor) : ITnsuPortalClient
{ {
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true }; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
@@ -73,7 +70,7 @@ public sealed class HttpTnsuPortalClient(
private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class private async Task<T?> GetAsync<T>(string requestUri, CancellationToken cancellationToken) where T : class
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri); using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
await AuthorizeAsync(request, cancellationToken); await authorizer.AuthorizeAsync(request, cancellationToken);
using var response = await httpClient.SendAsync(request, cancellationToken); using var response = await httpClient.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound) if (response.StatusCode == HttpStatusCode.NotFound)
@@ -89,19 +86,6 @@ public sealed class HttpTnsuPortalClient(
executors.Add(new ProjectExecutorInfo(role, email)); executors.Add(new ProjectExecutorInfo(role, email));
} }
private async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
if (!string.IsNullOrWhiteSpace(incoming))
{
request.Headers.TryAddWithoutValidation("Authorization", incoming);
return;
}
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
private sealed record EmployeeResponse( private sealed record EmployeeResponse(
[property: JsonPropertyName("email")] string? Email, [property: JsonPropertyName("email")] string? Email,
[property: JsonPropertyName("company")] string? Company, [property: JsonPropertyName("company")] string? Company,
@@ -0,0 +1,16 @@
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Подписывает список данных SharePoint на уведомления об изменениях через портал.
/// </summary>
public interface ISharePointWebhookSubscriber
{
/// <summary>
/// Подписать список. НЕ бросает: подписка — следствие создания списка, а не его
/// условие (см. реализацию), поэтому сбой портала не должен рушить провижининг
/// партиции. Возвращает true, только если портал подтвердил подписку.
/// </summary>
/// <param name="siteUrl">Абсолютный адрес сайта, где создан список.</param>
/// <param name="listTitle">Заголовок списка данных — русское имя, как в SharePoint.</param>
Task<bool> TrySubscribeAsync(string siteUrl, string listTitle, CancellationToken cancellationToken);
}
@@ -0,0 +1,39 @@
using System.Net.Http.Headers;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Авторизация запросов к api.tnsu.kz. Вынесена из HttpTnsuPortalClient, потому что
/// к порталу ходит не только справочник сотрудников (см.
/// HttpSharePointWebhookSubscriber), а правило выбора токена должно быть одно.
///
/// Токен берётся в двух режимах, и порядок принципиален:
/// 1) Токен вызывающего пользователя (см. TnsuPortalCallerToken) — из текущего
/// HTTP-запроса либо переданный аргументом в фоновую задачу. Портал тогда
/// видит исходного пользователя, а не сервисное приложение.
/// 2) Его нет вовсе — client_credentials самого сервиса (см.
/// TnsuPortalTokenProvider).
///
/// ВАЖНО про второй путь: TnsuPortal:ClientSecret/Scope в конфигурации пусты
/// (см. appsettings.json), поэтому он сейчас нерабочий — это именно фолбэк, а не
/// штатный режим. Вызывающая сторона обязана это учитывать: см.
/// HttpSharePointWebhookSubscriber, где сбой подписки намеренно не рушит
/// провижининг списка.
/// </summary>
public sealed class TnsuPortalAuthorizer(
TnsuPortalCallerToken callerToken,
TnsuPortalTokenProvider tokenProvider)
{
public async Task AuthorizeAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var caller = callerToken.Current;
if (!string.IsNullOrWhiteSpace(caller))
{
request.Headers.TryAddWithoutValidation("Authorization", caller);
return;
}
var token = await tokenProvider.GetAccessTokenAsync(cancellationToken);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
@@ -0,0 +1,37 @@
using Microsoft.AspNetCore.Http;
namespace Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
/// <summary>
/// Authorization, с которым идти в api.tnsu.kz в пределах текущей области DI.
///
/// Два источника, и второй нужен именно фоновым задачам:
/// • обрабатывается HTTP-запрос — заголовок берётся прямо из него;
/// • выполняется задача Hangfire — своего HttpContext'а у неё нет, поэтому
/// токен инициировавшего пользователя ей передают аргументом и кладут сюда
/// через <see cref="Set"/> (см. CreateNextListPartitionJob).
///
/// Scoped: Hangfire создаёт отдельную область DI на каждое выполнение задачи,
/// поэтому положенный здесь токен не протекает между задачами.
/// </summary>
public sealed class TnsuPortalCallerToken(IHttpContextAccessor httpContextAccessor)
{
private string? _explicit;
/// <summary>
/// Заголовок целиком, вида «Bearer …». null — вызывающего токена нет
/// (тогда авторизация уходит на client_credentials, см. TnsuPortalAuthorizer).
/// </summary>
public string? Current
{
get
{
var incoming = httpContextAccessor.HttpContext?.Request.Headers.Authorization.ToString();
return string.IsNullOrWhiteSpace(incoming) ? _explicit : incoming;
}
}
/// <summary>Положить токен явно — для кода без HttpContext (фоновые задачи).</summary>
public void Set(string? authorization) =>
_explicit = string.IsNullOrWhiteSpace(authorization) ? null : authorization;
}
@@ -0,0 +1,189 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Shouldly;
using Tnsu.ApprovalRoute.Infrastructure.SharePoint;
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
namespace Tnsu.ApprovalRoute.IntegrationTests.ApplicationServices;
/// <summary>
/// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe).
///
/// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка
/// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит
/// провижининг — подписка следствие создания списка, а не его условие.
/// </summary>
public sealed class SharePointWebhookSubscriberTests
{
private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests";
private const string ListTitle = "Обходной лист 3 — данные";
/// <summary>Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ.</summary>
private sealed class StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
{
public HttpRequestMessage? Captured { get; private set; }
public int CallCount { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Captured = request;
CallCount++;
return Task.FromResult(respond(request));
}
}
// Токен вызывающего есть — тогда авторизатор пробрасывает его и не идёт за
// client_credentials (у которого в тестах нет ни секрета, ни сети).
// fromHttpContext=false — путь фоновой задачи: токен положен явно, HttpContext'а
// нет вовсе (см. TnsuPortalCallerToken.Set / CreateNextListPartitionJob).
private static TnsuPortalAuthorizer BuildAuthorizer(bool fromHttpContext = true)
{
var accessor = new HttpContextAccessor();
var callerToken = new TnsuPortalCallerToken(accessor);
if (fromHttpContext)
{
var context = new DefaultHttpContext();
context.Request.Headers.Authorization = "Bearer test-token";
accessor.HttpContext = context;
}
else
{
callerToken.Set("Bearer test-token");
}
return new TnsuPortalAuthorizer(
callerToken,
new TnsuPortalTokenProvider(new HttpClient(), Options.Create(new TnsuPortalOptions())));
}
private static (HttpSharePointWebhookSubscriber Subscriber, StubHandler Handler) Build(
Func<HttpRequestMessage, HttpResponseMessage> respond,
SharePointOptions? options = null,
bool tokenFromHttpContext = true)
{
var handler = new StubHandler(respond);
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://api.tnsu.kz") };
var subscriber = new HttpSharePointWebhookSubscriber(
httpClient,
BuildAuthorizer(tokenFromHttpContext),
Options.Create(options ?? new SharePointOptions()),
NullLogger<HttpSharePointWebhookSubscriber>.Instance);
return (subscriber, handler);
}
private static HttpResponseMessage Ok() => new(System.Net.HttpStatusCode.OK);
[Fact(DisplayName = "Подписка уходит POST'ом со всеми тремя параметрами в строке запроса")]
public async Task TrySubscribeAsync_SendsPostWithQueryParameters()
{
// Arrange
var (subscriber, handler) = Build(_ => Ok());
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeTrue();
handler.Captured!.Method.ShouldBe(HttpMethod.Post);
handler.Captured.Content.ShouldBeNull(); // тела у ручки нет
var query = Uri.UnescapeDataString(handler.Captured.RequestUri!.Query);
handler.Captured.RequestUri.AbsolutePath.ShouldBe("/Webhook/Subscribe");
query.ShouldContain($"listName={ListTitle}");
query.ShouldContain("notificationUrl=https://api.tnsu.kz/Webhook/SharePoint");
// Токен из входящего запроса пробрасывается как есть (см. TnsuPortalAuthorizer).
handler.Captured.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
}
[Fact(DisplayName = "В url уходит ПОЛНЫЙ адрес сайта, а не короткое имя")]
public async Task TrySubscribeAsync_SendsFullSiteUrl()
{
// Arrange
var (subscriber, handler) = Build(_ => Ok());
// Action
await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
var query = Uri.UnescapeDataString(handler.Captured!.RequestUri!.Query);
query.ShouldContain($"url={SiteUrl}");
}
[Fact(DisplayName = "Токен фоновой задачи (без HttpContext) тоже уходит в заголовок")]
public async Task TrySubscribeAsync_TokenSetExplicitly_IsSentAsAuthorization()
{
// Arrange: так работает провижининг — HttpContext'а нет, токен передан
// аргументом задачи (см. CreateNextListPartitionJob).
var (subscriber, handler) = Build(_ => Ok(), tokenFromHttpContext: false);
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeTrue();
handler.Captured!.Headers.Authorization?.ToString().ShouldBe("Bearer test-token");
}
[Fact(DisplayName = "Отказ портала не бросает исключение — провижининг списка не рушится")]
public async Task TrySubscribeAsync_PortalRejects_ReturnsFalseWithoutThrowing()
{
// Arrange
var (subscriber, _) = Build(_ => new HttpResponseMessage(System.Net.HttpStatusCode.Unauthorized)
{
Content = new StringContent("no token")
});
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
}
[Fact(DisplayName = "Недоступный портал (исключение в сети) тоже не бросает наружу")]
public async Task TrySubscribeAsync_NetworkFailure_ReturnsFalseWithoutThrowing()
{
// Arrange
var (subscriber, _) = Build(_ => throw new HttpRequestException("портал недоступен"));
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
}
[Fact(DisplayName = "NotificationUrl не задан — запрос не отправляется вовсе")]
public async Task TrySubscribeAsync_NoNotificationUrl_DoesNotCallPortal()
{
// Arrange: пустой адрес приёмника — единственный выключатель подписки
// (отдельного флага Enabled нет, см. SharePointOptions.NotificationUrl).
var (subscriber, handler) = Build(_ => Ok(), new SharePointOptions { NotificationUrl = "" });
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, ListTitle, CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
handler.CallCount.ShouldBe(0);
}
[Fact(DisplayName = "Пустой заголовок списка — подписывать нечего, запроса нет")]
public async Task TrySubscribeAsync_EmptyListTitle_DoesNotCallPortal()
{
// Arrange
var (subscriber, handler) = Build(_ => Ok());
// Action
var subscribed = await subscriber.TrySubscribeAsync(SiteUrl, " ", CancellationToken.None);
// Assert
subscribed.ShouldBeFalse();
handler.CallCount.ShouldBe(0);
}
}