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

856 lines
31 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.Entities.Sonarr;
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 SonarrClientTests
{
private readonly IStriker _striker;
private readonly IDryRunInterceptor _dryRunInterceptor;
private readonly FakeHttpMessageHandler _httpMessageHandler;
private readonly SonarrClient _client;
private readonly ArrInstance _arrInstance;
public SonarrClientTests()
{
var logger = Substitute.For<ILogger<SonarrClient>>();
_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 SonarrClient(logger, httpClientFactory, _striker, _dryRunInterceptor);
_arrInstance = new ArrInstance
{
Name = "sonarr",
Url = new Uri("http://localhost:8989/"),
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 (via GetQueueItemsAsync / DeleteQueueItemAsync / HealthCheckAsync)
[Fact]
public async Task GetQueueItemsAsync_BuildsSonarrSpecificQuery()
{
// 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/v3/queue");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true");
}
[Fact]
public async Task DeleteQueueItemAsync_UsesV3QueuePath()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.OK);
// Act
await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(123), removeFromClient: true, changeCategory: false, DeleteReason.FailedImport);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/queue/123");
}
[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_BothSeriesAndEpisodeSet_ReturnsTrue()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", SeriesId = 5, EpisodeId = 9 };
_client.HasContentId(record).ShouldBeTrue();
}
[Fact]
public void HasContentId_SeriesIdZero_ReturnsFalse()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", SeriesId = 0, EpisodeId = 9 };
_client.HasContentId(record).ShouldBeFalse();
}
[Fact]
public void HasContentId_EpisodeIdZero_ReturnsFalse()
{
var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", SeriesId = 5, EpisodeId = 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_SeriesSearch_PostsSeriesCommandToCommandEndpoint()
{
// Arrange
RouteResponses(commandIdForPost: 42);
var items = new HashSet<SearchItem>
{
new SeriesSearchItem { Id = 100, SeriesId = 100, SearchType = SeriesSearchType.Series },
};
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBe(new long[] { 42 });
var post = _httpMessageHandler.CapturedRequests.First(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\":\"SeriesSearch\"", Case.Insensitive);
body!.ShouldContain("\"seriesId\":100", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_SeasonSearch_PostsSeasonCommandWithSeriesAndSeason()
{
// Arrange
RouteResponses(commandIdForPost: 7);
var items = new HashSet<SearchItem>
{
new SeriesSearchItem { Id = 3, SeriesId = 100, SearchType = SeriesSearchType.Season },
};
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBe(new long[] { 7 });
var post = _httpMessageHandler.CapturedRequests.First(r => r.Method == HttpMethod.Post);
var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)];
body.ShouldNotBeNull();
body!.ShouldContain("\"name\":\"SeasonSearch\"", Case.Insensitive);
body!.ShouldContain("\"seriesId\":100", Case.Insensitive);
body!.ShouldContain("\"seasonNumber\":3", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_MultipleEpisodes_BundlesIntoSingleCommand()
{
// Arrange
RouteResponses(commandIdForPost: 99);
var items = new HashSet<SearchItem>
{
new SeriesSearchItem { Id = 1, SeriesId = 10, SearchType = SeriesSearchType.Episode },
new SeriesSearchItem { Id = 2, SeriesId = 10, SearchType = SeriesSearchType.Episode },
new SeriesSearchItem { Id = 3, SeriesId = 10, SearchType = SeriesSearchType.Episode },
};
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBe(new long[] { 99 });
var posts = _httpMessageHandler.CapturedRequests.Where(r => r.Method == HttpMethod.Post).ToList();
posts.Count.ShouldBe(1);
var bodyIndex = _httpMessageHandler.CapturedRequests.IndexOf(posts[0]);
var body = _httpMessageHandler.CapturedRequestBodies[bodyIndex];
body.ShouldNotBeNull();
body!.ShouldContain("\"name\":\"EpisodeSearch\"", Case.Insensitive);
body!.ShouldContain("\"episodeIds\":[1,2,3]", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_SeriesThenEpisode_PostsBothCommands()
{
// Arrange
RouteResponses(commandIdForPost: 11);
var items = new HashSet<SearchItem>
{
new SeriesSearchItem { Id = 10, SeriesId = 10, SearchType = SeriesSearchType.Series },
new SeriesSearchItem { Id = 1, SeriesId = 10, SearchType = SeriesSearchType.Episode },
};
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBe(new long[] { 11, 11 });
var posts = _httpMessageHandler.CapturedRequests.Where(r => r.Method == HttpMethod.Post).ToList();
posts.Count.ShouldBe(2);
var bodies = posts
.Select(p => _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(p)])
.ToList();
bodies.ShouldContain(b => b != null && b.Contains("\"name\":\"SeriesSearch\"", StringComparison.OrdinalIgnoreCase) && !b.Contains("episodeIds", StringComparison.OrdinalIgnoreCase));
bodies.ShouldContain(b => b != null && b.Contains("\"name\":\"EpisodeSearch\"", StringComparison.OrdinalIgnoreCase) && b.Contains("\"episodeIds\":[1]", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task SearchItemsAsync_EpisodesAroundOtherTypes_BundlesEpisodesIntoSingleCommand()
{
// Arrange
RouteResponses(commandIdForPost: 33);
var items = new HashSet<SearchItem>
{
new SeriesSearchItem { Id = 1, SeriesId = 10, SearchType = SeriesSearchType.Episode },
new SeriesSearchItem { Id = 4, SeriesId = 10, SearchType = SeriesSearchType.Season },
new SeriesSearchItem { Id = 2, SeriesId = 10, SearchType = SeriesSearchType.Episode },
};
// Act
await _client.SearchItemsAsync(_arrInstance, items);
// Assert
var posts = _httpMessageHandler.CapturedRequests.Where(r => r.Method == HttpMethod.Post).ToList();
posts.Count.ShouldBe(2);
var episodeBody = posts
.Select(p => _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(p)])
.Single(b => b != null && b.Contains("\"name\":\"EpisodeSearch\"", StringComparison.OrdinalIgnoreCase));
episodeBody!.ShouldContain("\"episodeIds\":[1,2]", Case.Insensitive);
}
[Fact]
public async Task SearchItemsAsync_DryRun_ReturnsEmptyAndDoesNotPost()
{
// Arrange — interceptor returns null on dry run
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns((HttpResponseMessage?)null);
// Set up GETs that ComputeCommandLogContext might fire (series lookup)
_httpMessageHandler.SetupResponse((req, _) => Task.FromResult(JsonNullResponse()));
var items = new HashSet<SearchItem>
{
new SeriesSearchItem { Id = 5, SeriesId = 5, SearchType = SeriesSearchType.Series },
};
// Act
var ids = await _client.SearchItemsAsync(_arrInstance, items);
// Assert
ids.ShouldBeEmpty();
_httpMessageHandler.CapturedRequests.ShouldNotContain(r => r.Method == HttpMethod.Post);
}
[Fact]
public async Task SearchItemsAsync_ServerErrorOnPost_ThrowsAndLogsError()
{
// Arrange — interceptor passes through; GET log-context lookups return null body; POST 500
_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 SeriesSearchItem { Id = 5, SeriesId = 5, SearchType = SeriesSearchType.Series },
};
// Act / Assert
await Should.ThrowAsync<HttpRequestException>(() => _client.SearchItemsAsync(_arrInstance, items));
}
#endregion
#region StreamAllSeriesAsync / GetAllTagsAsync / GetEpisodes / EpisodeFiles / QualityProfiles / Scores
[Fact]
public async Task StreamAllSeriesAsync_BuildsCorrectUriAndDeserializesList()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new SearchableSeries { Id = 1, Title = "Show", QualityProfileId = 2, Tags = new List<long>() },
})));
// Act
List<SearchableSeries> result = [];
await foreach (SearchableSeries series in _client.StreamAllSeriesAsync(_arrInstance))
{
result.Add(series);
}
// Assert
result.Count.ShouldBe(1);
result[0].Title.ShouldBe("Show");
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/series");
request.Headers.GetValues("x-api-key").ShouldHaveSingleItem().ShouldBe("api-key");
}
[Fact]
public async Task StreamAllSeriesAsync_NullBody_ReturnsEmpty()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse()));
// Act
List<SearchableSeries> result = [];
await foreach (SearchableSeries series in _client.StreamAllSeriesAsync(_arrInstance))
{
result.Add(series);
}
// Assert
result.ShouldBeEmpty();
}
[Fact]
public async Task GetAllTagsAsync_DeserializesListAndUsesV3Path()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new Tag { Id = 1, Label = "Anime" },
new Tag { Id = 2, Label = "HD" },
})));
// Act
var tags = await _client.GetAllTagsAsync(_arrInstance);
// Assert
tags.Count.ShouldBe(2);
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/tag");
}
[Fact]
public async Task GetEpisodesAsync_BuildsSeriesIdQuery()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<SearchableEpisode>())));
// Act
await _client.GetEpisodesAsync(_arrInstance, seriesId: 42);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/episode");
request.RequestUri.Query.ShouldBe("?seriesId=42");
}
[Fact]
public async Task GetEpisodeFilesAsync_BuildsSeriesIdQuery()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<ArrEpisodeFile>())));
// Act
await _client.GetEpisodeFilesAsync(_arrInstance, seriesId: 7);
// Assert
var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/episodefile");
request.RequestUri.Query.ShouldBe("?seriesId=7");
}
[Fact]
public async Task GetQualityProfilesAsync_DeserializesList()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new ArrQualityProfile { Id = 1, Name = "HD", 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 GetEpisodeFileScoresAsync_EmptyList_MakesNoRequests()
{
// Act
var scores = await _client.GetEpisodeFileScoresAsync(_arrInstance, new List<long>());
// Assert
scores.ShouldBeEmpty();
_httpMessageHandler.CapturedRequests.ShouldBeEmpty();
}
[Fact]
public async Task GetEpisodeFileScoresAsync_OverHundred_BatchesIntoMultipleRequests()
{
// Arrange — 150 ids should produce 2 batches (100 + 50)
var ids = Enumerable.Range(1, 150).Select(i => (long)i).ToList();
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<MediaFileScore>())));
// Act
await _client.GetEpisodeFileScoresAsync(_arrInstance, ids);
// Assert
_httpMessageHandler.CapturedRequests.Count.ShouldBe(2);
_httpMessageHandler.CapturedRequests.ShouldAllBe(r => r.RequestUri!.AbsolutePath == "/api/v3/episodefile");
}
[Fact]
public async Task GetEpisodeFileScoresAsync_MergesScoresFromResponses()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[]
{
new MediaFileScore { Id = 1, CustomFormatScore = 50 },
new MediaFileScore { Id = 2, CustomFormatScore = -30 },
})));
// Act
var scores = await _client.GetEpisodeFileScoresAsync(_arrInstance, new List<long> { 1, 2 });
// Assert
scores.Count.ShouldBe(2);
scores[1].ShouldBe(50);
scores[2].ShouldBe(-30);
}
#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 (/series/{id}, /episode?...) 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_KeepsTheDownloadIdCasing()
{
// Arrange: the arr matches the download id exactly, so a lowercased one finds nothing
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(
new[] { new { path = "/downloads/file.mkv" } })));
// Act
await _client.GetManualImportCandidatesAsync(_arrInstance, "A1CF56E76FCD1CC7");
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.Method.ShouldBe(HttpMethod.Get);
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/manualimport");
request.RequestUri.Query.ShouldBe("?downloadId=A1CF56E76FCD1CC7&filterExistingFiles=true");
request.Headers.GetValues("x-api-key").ShouldHaveSingleItem().ShouldBe("api-key");
}
[Fact]
public async Task GetManualImportCandidatesAsync_ReadsTheCandidateTheArrSent()
{
// Arrange
const string body = """
[
{
"path": "/downloads/show.mkv",
"relativePath": "show.mkv",
"folderName": null,
"downloadId": "HASH",
"indexerFlags": 0,
"releaseType": "singleEpisode",
"quality": { "quality": { "id": 3 } },
"languages": [ { "id": 1 } ],
"series": { "id": 7 },
"episodes": [ { "id": 9 } ],
"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.Path.ShouldBe("/downloads/show.mkv");
candidate.FolderName.ShouldBeNull();
candidate.Series!.Id.ShouldBe(7);
candidate.Episodes!.ShouldHaveSingleItem().Id.ShouldBe(9);
candidate.Rejections!.ShouldHaveSingleItem().Reason.ShouldBe("Unable to determine if file is a sample");
candidate.ReleaseType.ShouldBe("singleEpisode");
}
[Fact]
public async Task ForceImportAsync_PostsTheManualImportCommand()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.OK);
List<ManualImportFile> files =
[
new()
{
Path = "/downloads/show.mkv",
SeriesId = 7,
EpisodeIds = [9],
DownloadId = "HASH",
ReleaseType = "singleEpisode",
},
];
// Act
bool sent = await _client.ForceImportAsync(_arrInstance, files);
// Assert
sent.ShouldBeTrue();
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.Method.ShouldBe(HttpMethod.Post);
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command");
string body = _httpMessageHandler.CapturedRequestBodies.ShouldHaveSingleItem()!;
using JsonDocument document = JsonDocument.Parse(body);
JsonElement root = document.RootElement;
root.GetProperty("Name").GetString().ShouldBe("ManualImport");
root.GetProperty("ImportMode").GetString().ShouldBe("auto");
JsonElement file = root.GetProperty("Files").EnumerateArray().ShouldHaveSingleItem();
file.GetProperty("Path").GetString().ShouldBe("/downloads/show.mkv");
file.GetProperty("SeriesId").GetInt64().ShouldBe(7);
file.GetProperty("EpisodeIds").EnumerateArray().ShouldHaveSingleItem().GetInt64().ShouldBe(9);
file.TryGetProperty("MovieId", out _).ShouldBeFalse();
}
[Fact]
public async Task ForceImportAsync_DryRun_SendsNothing()
{
// Arrange
_dryRunInterceptor
.InterceptAsync<HttpResponseMessage>(Arg.Any<Func<Task<HttpResponseMessage>>>(), Arg.Any<string?>())
.Returns(Task.FromResult<HttpResponseMessage?>(null));
// Act
bool sent = await _client.ForceImportAsync(_arrInstance, [new ManualImportFile { Path = "/downloads/show.mkv" }]);
// Assert
sent.ShouldBeFalse();
_httpMessageHandler.CapturedRequests.ShouldBeEmpty();
}
[Fact]
public void MapCandidate_SeriesMatches_BuildsTheSeriesPayload()
{
// Arrange
QueueRecord record = new() { SeriesId = 7, EpisodeId = 9, DownloadId = "HASH", Title = "show" };
ManualImportCandidate candidate = new()
{
Path = "/downloads/show.mkv",
DownloadId = "HASH",
IndexerFlags = 2,
Series = new ManualImportRef { Id = 7 },
Episodes = [new ManualImportRef { Id = 9 }, new ManualImportRef { Id = 10 }],
};
// Act
ManualImportFile? file = _client.MapCandidate(record, candidate);
// Assert
file.ShouldNotBeNull();
file.SeriesId.ShouldBe(7);
file.EpisodeIds.ShouldBe([9, 10]);
file.MovieId.ShouldBeNull();
file.IndexerFlags.ShouldBe(2);
file.ReleaseType.ShouldBe("singleEpisode");
}
[Fact]
public void MapCandidate_OtherSeries_ReturnsNull()
{
// Arrange
QueueRecord record = new() { SeriesId = 7, DownloadId = "HASH", Title = "show" };
ManualImportCandidate candidate = new()
{
Series = new ManualImportRef { Id = 8 },
Episodes = [new ManualImportRef { Id = 9 }],
};
// Act, Assert
_client.MapCandidate(record, candidate).ShouldBeNull();
}
[Fact]
public void MapCandidate_TheArrSentNoSeries_ReturnsNull()
{
// Arrange
QueueRecord record = new() { SeriesId = 7, DownloadId = "HASH", Title = "show" };
ManualImportCandidate candidate = new() { Episodes = [new ManualImportRef { Id = 9 }] };
// Act, Assert
_client.MapCandidate(record, candidate).ShouldBeNull();
}
[Fact]
public void MapCandidate_TheArrSentNoEpisodeList_ReturnsNull()
{
// Arrange
QueueRecord record = new() { SeriesId = 7, DownloadId = "HASH", Title = "show" };
ManualImportCandidate candidate = new() { Series = new ManualImportRef { Id = 7 } };
// Act, Assert
_client.MapCandidate(record, candidate).ShouldBeNull();
}
[Fact]
public void MapCandidate_NoEpisode_ReturnsNull()
{
// Arrange
QueueRecord record = new() { SeriesId = 7, DownloadId = "HASH", Title = "show" };
ManualImportCandidate candidate = new() { Series = new ManualImportRef { Id = 7 }, Episodes = [] };
// Act, Assert
_client.MapCandidate(record, candidate).ShouldBeNull();
}
[Fact]
public void SupportsForceImport_IsTrue()
{
_client.SupportsForceImport.ShouldBeTrue();
}
#endregion
#region GetRecentlyGrabbedSeasonsAsync
[Fact]
public async Task GetRecentlyGrabbedSeasonsAsync_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty<object>())));
DateTimeOffset since = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Act
await _client.GetRecentlyGrabbedSeasonsAsync(_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&includeEpisode=true");
}
[Fact]
public async Task GetRecentlyGrabbedSeasonsAsync_DropsRowsWithoutSeriesOrEpisode()
{
// Arrange
const string body = """
[
{ "seriesId": 10, "episode": { "seasonNumber": 1 } },
{ "seriesId": 0, "episode": { "seasonNumber": 2 } },
{ "seriesId": 11, "episode": null }
]
""";
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
}));
// Act
HashSet<(long SeriesId, long SeasonNumber)> seasons = await _client.GetRecentlyGrabbedSeasonsAsync(_arrInstance, DateTimeOffset.UtcNow);
// Assert
seasons.ShouldBe([(10L, 1L)]);
}
[Fact]
public async Task GetRecentlyGrabbedSeasonsAsync_NullBody_ReturnsEmpty()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse()));
// Act
HashSet<(long SeriesId, long SeasonNumber)> seasons = await _client.GetRecentlyGrabbedSeasonsAsync(_arrInstance, DateTimeOffset.UtcNow);
// Assert
seasons.ShouldBeEmpty();
}
[Fact]
public async Task GetRecentlyGrabbedSeasonsAsync_TheArrRefused_Throws()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.InternalServerError);
// Act, Assert
await Should.ThrowAsync<HttpRequestException>(
() => _client.GetRecentlyGrabbedSeasonsAsync(_arrInstance, DateTimeOffset.UtcNow));
}
#endregion
#region GetMissingEpisodesAsync / GetCutoffUnmetEpisodesAsync
[Fact]
public async Task GetMissingEpisodesAsync_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new ArrPagedResponse<SearchableEpisode>())));
// Act
await _client.GetMissingEpisodesAsync(_arrInstance, monitored: true);
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/wanted/missing");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=1000&monitored=true");
}
[Fact]
public async Task GetCutoffUnmetEpisodesAsync_MonitoredFalse_BuildsExpectedUri()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new ArrPagedResponse<SearchableEpisode>())));
// Act
await _client.GetCutoffUnmetEpisodesAsync(_arrInstance, monitored: false);
// Assert
HttpRequestMessage request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem();
request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/wanted/cutoff");
request.RequestUri.Query.ShouldBe("?page=1&pageSize=1000&monitored=false");
}
[Fact]
public async Task GetMissingEpisodesAsync_AccumulatesPagesAndParsesSeriesId()
{
// Arrange
_httpMessageHandler.SetupResponse((_, _) =>
{
int page = _httpMessageHandler.CapturedRequests.Count;
ArrPagedResponse<SearchableEpisode> response = page == 1
? new ArrPagedResponse<SearchableEpisode>
{
TotalRecords = 2,
Records = [new SearchableEpisode { Id = 1, SeriesId = 10, SeasonNumber = 1, EpisodeNumber = 1 }],
}
: new ArrPagedResponse<SearchableEpisode>
{
TotalRecords = 2,
Records = [new SearchableEpisode { Id = 2, SeriesId = 11, SeasonNumber = 1, EpisodeNumber = 2 }],
};
return Task.FromResult(JsonResponse(response));
});
// Act
List<SearchableEpisode> episodes = await _client.GetMissingEpisodesAsync(_arrInstance, monitored: true);
// Assert
episodes.Count.ShouldBe(2);
episodes[0].SeriesId.ShouldBe(10L);
episodes[1].SeriesId.ShouldBe(11L);
_httpMessageHandler.CapturedRequests.Count.ShouldBe(2);
_httpMessageHandler.CapturedRequests[1].RequestUri!.Query.ShouldBe("?page=2&pageSize=1000&monitored=true");
}
[Fact]
public async Task GetMissingEpisodesAsync_TheArrRefused_Throws()
{
// Arrange
_httpMessageHandler.SetupResponse(HttpStatusCode.NotFound);
// Act, Assert
HttpRequestException exception = await Should.ThrowAsync<HttpRequestException>(
() => _client.GetMissingEpisodesAsync(_arrInstance, monitored: true));
exception.StatusCode.ShouldBe(HttpStatusCode.NotFound);
}
#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
}