mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-10-28 10:48:49 -04:00
856 lines
31 KiB
C#
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
|
|
}
|