Files
Cleanuparr/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ReadarrClientTests.cs
T

262 lines
8.6 KiB
C#

using System.Net;
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.Queue;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Arr;
using Cleanuparr.Infrastructure.Features.ItemStriker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using NSubstitute;
using Shouldly;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Arr;
public class ReadarrClientTests
{
private readonly IStriker _striker;
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly FakeHttpMessageHandler _httpMessageHandler;
private readonly ReadarrClient _client;
private readonly ArrInstance _arrInstance;
public ReadarrClientTests()
{
var logger = Substitute.For<ILogger<ReadarrClient>>();
_striker = Substitute.For<IStriker>();
_dryRunInterceptor = Substitute.For<IDryRunInterceptor>();
_httpMessageHandler = new FakeHttpMessageHandler();
var httpClient = new HttpClient(_httpMessageHandler);
var httpClientFactory = Substitute.For<IHttpClientFactory>();
httpClientFactory.CreateClient(Arg.Any<string>()).Returns(httpClient);
_client = new ReadarrClient(logger, httpClientFactory, _striker, _dryRunInterceptor);
_arrInstance = new ArrInstance
{
Name = "readarr",
Url = new Uri("http://localhost:8787/"),
ApiKey = "api-key",
};
_dryRunInterceptor.IsDryRunEnabled().Returns(false);
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns(async ci =>
{
Func<Task<HttpResponseMessage>> action = ci.Arg<Func<Task<HttpResponseMessage>>>();
return await action();
});
}
#region Queue URL overrides
[Fact]
public async Task GetQueueItemsAsync_BuildsReadarrSpecificQuery()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new QueueListResponse { TotalRecords = 0, Records = Array.Empty<QueueRecord>() })));
// Act
await _client.GetQueueItemsAsync(_arrInstance, 1);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true");
}
[Fact]
public async Task DeleteQueueItemAsync_UsesV1QueuePath()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.OK);
// Act
await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(99), removeFromClient: false, changeCategory: false, DeleteReason.Stalled);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue/99");
}
[Fact]
public async Task HealthCheckAsync_UsesV1SystemStatus()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.OK);
// Act
await _client.HealthCheckAsync(_arrInstance);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/system/status");
}
#endregion
#region HasContentId
[Fact]
public void HasContentId_AuthorAndBookSet_ReturnsTrue()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", AuthorId = 1, BookId = 2 };
_client.HasContentId(record).ShouldBeTrue();
}
[Fact]
public void HasContentId_AuthorZero_ReturnsFalse()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", AuthorId = 0, BookId = 2 };
_client.HasContentId(record).ShouldBeFalse();
}
[Fact]
public void HasContentId_BookZero_ReturnsFalse()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", AuthorId = 1, BookId = 0 };
_client.HasContentId(record).ShouldBeFalse();
}
#endregion
#region SearchItemsAsync
[Fact]
public async Task SearchItemsAsync_NullItems_ReturnsEmpty()
{
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, null);
// Assert
ids.ShouldBeEmpty();
_httpMessageHandler.CapturedRequests.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemsAsync_EmptyItems_ReturnsEmpty()
{
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, new HashSet<SearchItem>());
// Assert
ids.ShouldBeEmpty();
_httpMessageHandler.CapturedRequests.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemsAsync_PostsBookSearchCommandWithAllIds_AndReturnsEmpty()
{
// Arrange — Readarr's SearchItemsAsync always returns [] regardless of HTTP response
RouteResponses(commandIdForPost: 22);
var items = new HashSet<SearchItem>
{
new SearchItem { Id = 100 },
new SearchItem { Id = 200 },
};
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
var post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post);
post.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command");
var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)];
body.ShouldNotBeNull();
body!.ShouldContain("\"Name\":\"BookSearch\"", Case.Insensitive);
body!.ShouldContain("\"BookIds\":[100,200]", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_ServerError_Throws()
{
// Arrange
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
}
return Task.FromResult(JsonNullResponse());
});
var items = new HashSet<SearchItem> { new() { Id = 1 } };
// Act / Assert
await Should.ThrowAsync<HttpRequestException>(() => _client.SearchItemsAsync(_arrInstance, items));
}
[Fact]
public async Task SearchItemsAsync_DryRun_DoesNotPost()
{
// Arrange — interceptor short-circuits
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns((HttpResponseMessage?)null);
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse()));
var items = new HashSet<SearchItem> { new() { Id = 1 } };
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
_httpMessageHandler.CapturedRequests.ShouldNotContain(r => r.Method == HttpMethod.Post);
}
#endregion
#region GetAllTagsAsync
[Fact]
public async Task GetAllTagsAsync_ThrowsNotImplemented()
{
await Should.ThrowAsync<NotImplementedException>(() => _client.GetAllTagsAsync(_arrInstance));
}
#endregion
#region Helpers
private void RouteResponses(long commandIdForPost)
{
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/command"))
{
return Task.FromResult(JsonResponse(new { id = commandIdForPost }));
}
// GET log-context calls (/book/{id}) return null so log context bails out
return Task.FromResult(JsonNullResponse());
});
}
private static QueueRecord BuildRecord(long id) => new()
{
Id = id,
Title = $"item-{id}",
DownloadId = id.ToString(),
Protocol = "torrent",
};
private static HttpResponseMessage JsonResponse<T>(T body) => new(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"),
};
private static HttpResponseMessage JsonNullResponse() => new(HttpStatusCode.OK)
{
Content = new StringContent("null", Encoding.UTF8, "application/json"),
};
#endregion
}