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; /// /// Подписка нового списка данных на уведомления SharePoint (POST /Webhook/Subscribe). /// /// Проверяется ровно то, что нельзя увидеть по типам: как складывается строка /// запроса (ручка принимает всё параметрами, тела нет) и что сбой портала не рушит /// провижининг — подписка следствие создания списка, а не его условие. /// public sealed class SharePointWebhookSubscriberTests { private const string SiteUrl = "https://tnsukz.sharepoint.com/sites/requests"; private const string ListTitle = "Обходной лист 3 — данные"; /// Перехватывает запрос вместо сети: запоминает его и отдаёт заданный ответ. private sealed class StubHandler(Func respond) : HttpMessageHandler { public HttpRequestMessage? Captured { get; private set; } public int CallCount { get; private set; } protected override Task 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 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.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); } }