подписка
Tests / frontend-test (pull_request) Successful in 47s
Tests / backend-test (pull_request) Successful in 2m36s

This commit is contained in:
gorbenko.pavel
2026-08-19 13:30:09 +05:00
parent cf44ae3e2e
commit a1e6c5c8b4
12 changed files with 443 additions and 21 deletions
@@ -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);
}
}