using System.Net; using Cleanuparr.Infrastructure.Features.Notifications.Telegram; using Cleanuparr.Shared.Helpers; using Moq; using Moq.Protected; using Xunit; namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Telegram; public class TelegramProxyTests { private readonly Mock _httpClientFactoryMock; private readonly Mock _httpMessageHandlerMock; public TelegramProxyTests() { _httpMessageHandlerMock = new Mock(); _httpClientFactoryMock = new Mock(); var httpClient = new HttpClient(_httpMessageHandlerMock.Object); _httpClientFactoryMock .Setup(f => f.CreateClient(Constants.HttpClientWithRetryName)) .Returns(httpClient); } private TelegramProxy CreateProxy() { return new TelegramProxy(_httpClientFactoryMock.Object); } private static TelegramPayload CreatePayload(string text = "Test message", string? photoUrl = null) { return new TelegramPayload { ChatId = "123456789", Text = text, PhotoUrl = photoUrl, MessageThreadId = null, DisableNotification = false }; } #region Constructor Tests [Fact] public void Constructor_WithValidFactory_CreatesInstance() { // Act var proxy = CreateProxy(); // Assert Assert.NotNull(proxy); } [Fact] public void Constructor_CreatesHttpClientWithCorrectName() { // Act _ = CreateProxy(); // Assert _httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once); } #endregion #region SendNotification Success Tests [Fact] public async Task SendNotification_WhenSuccessful_CompletesWithoutException() { // Arrange var proxy = CreateProxy(); SetupSuccessResponse(); // Act & Assert - Should not throw await proxy.SendNotification(CreatePayload(), "test-bot-token"); } [Fact] public async Task SendNotification_SendsPostRequest() { // Arrange var proxy = CreateProxy(); HttpMethod? capturedMethod = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedMethod = req.Method) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); // Act await proxy.SendNotification(CreatePayload(), "test-bot-token"); // Assert Assert.Equal(HttpMethod.Post, capturedMethod); } [Fact] public async Task SendNotification_WithoutPhoto_UseSendMessageEndpoint() { // Arrange var proxy = CreateProxy(); Uri? capturedUri = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedUri = req.RequestUri) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); // Act await proxy.SendNotification(CreatePayload(), "my-bot-token"); // Assert Assert.NotNull(capturedUri); Assert.Contains("/botmy-bot-token/sendMessage", capturedUri.ToString()); } [Fact] public async Task SendNotification_WithPhotoAndShortCaption_UsesSendPhotoEndpoint() { // Arrange var proxy = CreateProxy(); Uri? capturedUri = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedUri = req.RequestUri) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); var payload = CreatePayload(text: "Short caption", photoUrl: "https://example.com/image.jpg"); // Act await proxy.SendNotification(payload, "my-bot-token"); // Assert Assert.NotNull(capturedUri); Assert.Contains("/botmy-bot-token/sendPhoto", capturedUri.ToString()); } [Fact] public async Task SendNotification_WithPhotoAndLongCaption_UsesSendMessageEndpoint() { // Arrange var proxy = CreateProxy(); Uri? capturedUri = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedUri = req.RequestUri) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); // Caption longer than 1024 characters var payload = CreatePayload(text: new string('A', 1025), photoUrl: "https://example.com/image.jpg"); // Act await proxy.SendNotification(payload, "my-bot-token"); // Assert Assert.NotNull(capturedUri); Assert.Contains("/botmy-bot-token/sendMessage", capturedUri.ToString()); } [Fact] public async Task SendNotification_SetsJsonContentType() { // Arrange var proxy = CreateProxy(); string? capturedContentType = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback((req, _) => capturedContentType = req.Content?.Headers.ContentType?.MediaType) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); // Act await proxy.SendNotification(CreatePayload(), "test-bot-token"); // Assert Assert.Equal("application/json", capturedContentType); } [Fact] public async Task SendNotification_WithPhotoAndLongCaption_IncludesInvisibleImageLink() { // Arrange var proxy = CreateProxy(); string? capturedContent = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback(async (req, _) => capturedContent = await req.Content!.ReadAsStringAsync()) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); // Caption longer than 1024 characters var payload = CreatePayload(text: new string('A', 1025), photoUrl: "https://example.com/image.jpg"); // Act await proxy.SendNotification(payload, "test-bot-token"); // Assert Assert.NotNull(capturedContent); Assert.Contains("​", capturedContent); // Zero-width space Assert.Contains("example.com/image.jpg", capturedContent); } [Fact] public async Task SendNotification_WithoutPhoto_DisablesWebPagePreview() { // Arrange var proxy = CreateProxy(); string? capturedContent = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback(async (req, _) => capturedContent = await req.Content!.ReadAsStringAsync()) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); // Act await proxy.SendNotification(CreatePayload(), "test-bot-token"); // Assert Assert.NotNull(capturedContent); Assert.Contains("disable_web_page_preview", capturedContent); Assert.Contains("true", capturedContent); } [Fact] public async Task SendNotification_WithMessageThreadId_IncludesThreadIdInBody() { // Arrange var proxy = CreateProxy(); string? capturedContent = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback(async (req, _) => capturedContent = await req.Content!.ReadAsStringAsync()) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); var payload = new TelegramPayload { ChatId = "123456789", Text = "Test message", MessageThreadId = 42 }; // Act await proxy.SendNotification(payload, "test-bot-token"); // Assert Assert.NotNull(capturedContent); Assert.Contains("message_thread_id", capturedContent); Assert.Contains("42", capturedContent); } [Fact] public async Task SendNotification_WithDisableNotification_IncludesDisableNotificationInBody() { // Arrange var proxy = CreateProxy(); string? capturedContent = null; _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .Callback(async (req, _) => capturedContent = await req.Content!.ReadAsStringAsync()) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); var payload = new TelegramPayload { ChatId = "123456789", Text = "Test message", DisableNotification = true }; // Act await proxy.SendNotification(payload, "test-bot-token"); // Assert Assert.NotNull(capturedContent); Assert.Contains("disable_notification", capturedContent); } #endregion #region SendNotification Error Tests [Fact] public async Task SendNotification_When400_ThrowsTelegramExceptionWithRejectedMessage() { // Arrange var proxy = CreateProxy(); SetupErrorResponse(HttpStatusCode.BadRequest, "Bad Request: chat not found"); // Act & Assert var ex = await Assert.ThrowsAsync(() => proxy.SendNotification(CreatePayload(), "test-bot-token")); Assert.Contains("rejected the request", ex.Message); } [Fact] public async Task SendNotification_When401_ThrowsTelegramExceptionWithInvalidToken() { // Arrange var proxy = CreateProxy(); SetupErrorResponse(HttpStatusCode.Unauthorized, "Unauthorized"); // Act & Assert var ex = await Assert.ThrowsAsync(() => proxy.SendNotification(CreatePayload(), "test-bot-token")); Assert.Contains("bot token is invalid", ex.Message); } [Fact] public async Task SendNotification_When403_ThrowsTelegramExceptionWithPermissionDenied() { // Arrange var proxy = CreateProxy(); SetupErrorResponse(HttpStatusCode.Forbidden, "Forbidden: bot was blocked by the user"); // Act & Assert var ex = await Assert.ThrowsAsync(() => proxy.SendNotification(CreatePayload(), "test-bot-token")); Assert.Contains("permission", ex.Message); } [Fact] public async Task SendNotification_When429_ThrowsTelegramExceptionWithRateLimitMessage() { // Arrange var proxy = CreateProxy(); SetupErrorResponse((HttpStatusCode)429, "Too Many Requests"); // Act & Assert var ex = await Assert.ThrowsAsync(() => proxy.SendNotification(CreatePayload(), "test-bot-token")); Assert.Contains("Rate limited", ex.Message); } [Fact] public async Task SendNotification_WhenOtherHttpError_ThrowsTelegramExceptionWithStatusCode() { // Arrange var proxy = CreateProxy(); SetupErrorResponse(HttpStatusCode.InternalServerError, "Internal Server Error"); // Act & Assert var ex = await Assert.ThrowsAsync(() => proxy.SendNotification(CreatePayload(), "test-bot-token")); Assert.Contains("500", ex.Message); } [Fact] public async Task SendNotification_WhenNetworkError_ThrowsTelegramException() { // Arrange var proxy = CreateProxy(); _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ThrowsAsync(new HttpRequestException("Network error")); // Act & Assert var ex = await Assert.ThrowsAsync(() => proxy.SendNotification(CreatePayload(), "test-bot-token")); Assert.Contains("Unable to reach Telegram API", ex.Message); } [Fact] public async Task SendNotification_WhenErrorResponseTruncatesLongBody() { // Arrange var proxy = CreateProxy(); var longErrorBody = new string('X', 600); SetupErrorResponse(HttpStatusCode.BadRequest, longErrorBody); // Act & Assert var ex = await Assert.ThrowsAsync(() => proxy.SendNotification(CreatePayload(), "test-bot-token")); Assert.True(ex.Message.Length < 600); } #endregion #region Helper Methods private void SetupSuccessResponse() { _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)); } private void SetupErrorResponse(HttpStatusCode statusCode, string body = "") { _httpMessageHandlerMock .Protected() .Setup>( "SendAsync", ItExpr.IsAny(), ItExpr.IsAny()) .ReturnsAsync(new HttpResponseMessage(statusCode) { Content = new StringContent(body) }); } #endregion }