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

601 lines
19 KiB
C#

using System.Net;
using System.Text;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Domain.Entities.Arr.ManualImport;
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 RadarrClientTests
{
private readonly IStriker _striker;
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly FakeHttpMessageHandler _httpMessageHandler;
private readonly RadarrClient _client;
private readonly ArrInstance _arrInstance;
public RadarrClientTests()
{
var logger = Substitute.For<ILogger<ArrClient>>();
_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 RadarrClient(logger, httpClientFactory, _striker, _dryRunInterceptor);
_arrInstance = new ArrInstance
{
Name = "radarr",
Url = new Uri("http://localhost:7878/"),
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_BuildsRadarrSpecificQuery()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new QueueListResponse { TotalRecords = 0, Records = Array.Empty<QueueRecord>() })));
// Act
await _client.GetQueueItemsAsync(_arrInstance, 4);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/queue");
request.RequestUri.Query.ShouldBe("?page=4&pageSize=200&includeUnknownMovieItems=true&includeMovie=true");
}
[Fact]
public async Task DeleteQueueItemAsync_UsesV3QueuePath()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.OK);
// Act
await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(55), removeFromClient: true, changeCategory: false, DeleteReason.Stalled);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/queue/55");
}
[Fact]
public async Task HealthCheckAsync_UsesV3SystemStatus()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.OK);
// Act
await _client.HealthCheckAsync(_arrInstance);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/system/status");
}
#endregion
#region HasContentId
[Fact]
public void HasContentId_MovieIdSet_ReturnsTrue()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", MovieId = 5 };
_client.HasContentId(record).ShouldBeTrue();
}
[Fact]
public void HasContentId_MovieIdZero_ReturnsFalse()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", MovieId = 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_WithAnEmptyCommandBody_ReturnsEmpty()
{
// An arr answers some commands with an empty body.
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/command"))
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(string.Empty, Encoding.UTF8, "application/json"),
});
}
return Task.FromResult(JsonNullResponse());
});
var ids = await _client.SearchItemsAsync(_arrInstance, new HashSet<SearchItem> { new() { Id = 10 } });
ids.ShouldBeEmpty();
}
[Fact]
public async Task SearchItemsAsync_PostsMoviesSearchCommandWithAllIds()
{
// Arrange
RouteResponses(commandIdForPost: 88);
var items = new HashSet<SearchItem>
{
new SearchItem { Id = 10 },
new SearchItem { Id = 20 },
};
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBe(new long[] { 88 });
var post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post);
post.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command");
var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)];
body.ShouldNotBeNull();
body!.ShouldContain("\"Name\":\"MoviesSearch\"", Case.Insensitive);
body!.ShouldContain("\"MovieIds\":[10,20]", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_NullCommandResponseFromDryRun_ReturnsEmpty()
{
// Arrange — interceptor returns null on dry-run
_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 = 5 } };
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
[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 = 5 } };
// Act / Assert
await Should.ThrowAsync<HttpRequestException>(() => _client.SearchItemsAsync(_arrInstance, items));
}
[Fact]
public async Task SearchItemsAsync_NoCommandIdInResponse_ReturnsEmpty()
{
// Arrange — POST returns 200 with body that has no id (treated as null id)
_httpMessageHandler.SetupResponse((req, _) =>
{
if (req.Method == HttpMethod.Post)
{
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
});
}
return Task.FromResult(JsonNullResponse());
});
var items = new HashSet<SearchItem> { new() { Id = 5 } };
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
}
#endregion
#region GetCommandStatusAsync / GetCommandsAsync
[Fact]
public async Task GetCommandStatusAsync_UsesV3CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new ArrCommandStatus(42, ArrCommandState.Completed, null))));
// Act
await _client.GetCommandStatusAsync(_arrInstance, 42);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command/42");
}
[Fact]
public async Task GetCommandsAsync_UsesV3CommandPath()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new List<ArrCommandStatus> { new(1, ArrCommandState.Started, null) })));
// Act
await _client.GetCommandsAsync(_arrInstance);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command");
}
#endregion
#region StreamAllMoviesAsync / GetAllTagsAsync / GetQualityProfilesAsync / GetMovieFileScoresAsync
[Fact]
public async Task StreamAllMoviesAsync_DeserializesList()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new SearchableMovie { Id = 1, Title = "Movie", QualityProfileId = 1 },
})));
// Act
List<SearchableMovie> movies = [];
await foreach (SearchableMovie movie in _client.StreamAllMoviesAsync(_arrInstance))
{
movies.Add(movie);
}
// Assert
movies.Count.ShouldBe(1);
movies[0].Title.ShouldBe("Movie");
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/movie");
}
[Fact]
public async Task StreamAllMoviesAsync_NullBody_ReturnsEmpty()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse()));
// Act
List<SearchableMovie> movies = [];
await foreach (SearchableMovie movie in _client.StreamAllMoviesAsync(_arrInstance))
{
movies.Add(movie);
}
// Assert
movies.ShouldBeEmpty();
}
[Fact]
public async Task GetAllTagsAsync_DeserializesList()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new Tag { Id = 1, Label = "4K" },
})));
// Act
var tags = await _client.GetAllTagsAsync(_arrInstance);
// Assert
tags.Count.ShouldBe(1);
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/tag");
}
[Fact]
public async Task GetQualityProfilesAsync_DeserializesList()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new ArrQualityProfile { Id = 1, Name = "UHD", CutoffFormatScore = 100 },
})));
// Act
var profiles = await _client.GetQualityProfilesAsync(_arrInstance);
// Assert
profiles.Count.ShouldBe(1);
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/qualityprofile");
}
[Fact]
public async Task GetMovieFileScoresAsync_EmptyList_MakesNoRequests()
{
// Act
var scores = await _client.GetMovieFileScoresAsync(_arrInstance, new List<long>());
// Assert
scores.ShouldBeEmpty();
_httpMessageHandler.CapturedRequests.ShouldBeEmpty();
}
[Fact]
public async Task GetMovieFileScoresAsync_OverHundred_BatchesIntoMultipleRequests()
{
// Arrange — 250 ids should produce 3 batches (100 + 100 + 50)
var ids = Enumerable.Range(1, 250).Select(i => (long)i).ToList();
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<MediaFileScore>())));
// Act
await _client.GetMovieFileScoresAsync(_arrInstance, ids);
// Assert
_httpMessageHandler.CapturedRequests.Count.ShouldBe(3);
_httpMessageHandler.CapturedRequests.ShouldAllBe(r => r.RequestUri!.AbsolutePath == "/api/v3/moviefile");
}
[Fact]
public async Task GetMovieFileScoresAsync_MergesScoresFromResponses()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new MediaFileScore { Id = 11, CustomFormatScore = 200 },
new MediaFileScore { Id = 12, CustomFormatScore = -50 },
})));
// Act
var scores = await _client.GetMovieFileScoresAsync(_arrInstance, new List<long> { 11, 12 });
// Assert
scores.Count.ShouldBe(2);
scores[11].ShouldBe(200);
scores[12].ShouldBe(-50);
}
#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 (/movie/{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",
};
#region Force import
[Fact]
public async Task GetManualImportCandidatesAsync_ReadsTheFlatMovieIdRadarrSends()
{
// Arrange: Radarr 6 puts the movie id on the candidate, with no nested movie
const string body = """
[
{
"path": "/downloads/movie.mkv",
"relativePath": "movie.mkv",
"downloadId": "HASH",
"movieId": 1,
"indexerFlags": 0,
"releaseGroup": "GROUP",
"quality": { "quality": { "id": 3 } },
"languages": [ { "id": 1 } ],
"rejections": [ { "reason": "Unable to determine if file is a sample", "type": "permanent" } ]
}
]
""";
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
}));
// Act
List<ManualImportCandidate> candidates = await _client.GetManualImportCandidatesAsync(_arrInstance, "HASH");
// Assert
ManualImportCandidate candidate = candidates.ShouldHaveSingleItem();
candidate.Movie.ShouldBeNull();
candidate.ResolvedMovieId.ShouldBe(1);
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/manualimport");
request.RequestUri.Query.ShouldBe("?downloadId=HASH&filterExistingFiles=true");
}
[Fact]
public void MapCandidate_FlatMovieIdMatches_BuildsTheMoviePayload()
{
// Arrange
QueueRecord record = new() { MovieId = 1, DownloadId = "HASH", Title = "movie" };
ManualImportCandidate candidate = new()
{
Path = "/downloads/movie.mkv",
DownloadId = "HASH",
ReleaseGroup = "GROUP",
MovieId = 1,
};
// Act
ManualImportFile? file = _client.MapCandidate(record, candidate);
// Assert
file.ShouldNotBeNull();
file.MovieId.ShouldBe(1);
file.SeriesId.ShouldBeNull();
file.EpisodeIds.ShouldBeNull();
file.ReleaseGroup.ShouldBe("GROUP");
}
[Fact]
public void MapCandidate_NestedMovieMatches_BuildsTheMoviePayload()
{
// Arrange: an older build nests the movie
QueueRecord record = new() { MovieId = 1, DownloadId = "HASH", Title = "movie" };
ManualImportCandidate candidate = new() { Movie = new ManualImportRef { Id = 1 } };
// Act, Assert
_client.MapCandidate(record, candidate)!.MovieId.ShouldBe(1);
}
[Fact]
public void MapCandidate_OtherMovie_ReturnsNull()
{
// Arrange
QueueRecord record = new() { MovieId = 1, DownloadId = "HASH", Title = "movie" };
// Act, Assert
_client.MapCandidate(record, new ManualImportCandidate { MovieId = 2 }).ShouldBeNull();
}
[Fact]
public void MapCandidate_NoMovieId_ReturnsNull()
{
// Arrange
QueueRecord record = new() { MovieId = 1, DownloadId = "HASH", Title = "movie" };
// Act, Assert
_client.MapCandidate(record, new ManualImportCandidate()).ShouldBeNull();
}
[Fact]
public void SupportsForceImport_IsTrue()
{
_client.SupportsForceImport.ShouldBeTrue();
}
#endregion
#region GetRecentlyGrabbedMovieIdsAsync
[Fact]
public async Task GetRecentlyGrabbedMovieIdsAsync_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<object>())));
DateTimeOffset since = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Act
await _client.GetRecentlyGrabbedMovieIdsAsync(_arrInstance, since);
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/history/since");
request.RequestUri.Query.ShouldBe("?date=2026-01-01T00%3A00%3A00.0000000Z&eventType=1");
}
[Fact]
public async Task GetRecentlyGrabbedMovieIdsAsync_DropsZeroMovieIds()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new { movieId = 1 },
new { movieId = 0 },
})));
// Act
HashSet<long> ids = await _client.GetRecentlyGrabbedMovieIdsAsync(_arrInstance, DateTimeOffset.UtcNow);
// Assert
ids.ShouldBe([1L]);
}
[Fact]
public async Task GetRecentlyGrabbedMovieIdsAsync_TheArrRefused_Throws()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.InternalServerError);
// Act, Assert
await Should.ThrowAsync<HttpRequestException>(
() => _client.GetRecentlyGrabbedMovieIdsAsync(_arrInstance, DateTimeOffset.UtcNow));
}
#endregion
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
}