diff --git a/src/Tests/TransmissionManager.BaseTests/HttpClient/FakeHttpMessageHandler.cs b/src/Tests/TransmissionManager.BaseTests/HttpClient/FakeHttpMessageHandler.cs index e466970..155b4a0 100644 --- a/src/Tests/TransmissionManager.BaseTests/HttpClient/FakeHttpMessageHandler.cs +++ b/src/Tests/TransmissionManager.BaseTests/HttpClient/FakeHttpMessageHandler.cs @@ -39,8 +39,8 @@ private HttpResponseMessage SendInternal(TestRequest testRequest) return response; } - // My hope here is that none of the faked endpoints are expected to return "418 I'm a teapot". - // This is done in order not to occupy an exception type which could be asserted in the tests. + // My hope here is that none of the faked endpoints is expected to return "418 I'm a teapot". + // We could throw instead, but it would occupy an exception type which could be asserted in the tests. return new() { StatusCode = (HttpStatusCode)418 }; } } diff --git a/src/Tests/TransmissionManager.TorrentWebPages.Tests/TorrentWebPageClientTests.cs b/src/Tests/TransmissionManager.TorrentWebPages.Tests/TorrentWebPageClientTests.cs index 45c7bc8..716e255 100644 --- a/src/Tests/TransmissionManager.TorrentWebPages.Tests/TorrentWebPageClientTests.cs +++ b/src/Tests/TransmissionManager.TorrentWebPages.Tests/TorrentWebPageClientTests.cs @@ -21,7 +21,7 @@ public sealed class TorrentWebPageClientTests [Test] public async Task FindMagnetUriAsync_FindsMagnetUri_IfGivenProperWebPage() { - const string magnetUri = "magnet:?xt=urn:btih:EXAMPLEHASH&dn=Example+Name"; + const string magnetUri = "magnet:?xt=urn:btih:3A81AAA70E75439D332C146ABDE899E546356BE2&dn=Example+Name"; const string webPageContentWithMagnet = $""" diff --git a/src/Tests/TransmissionManager.Transmission.Tests/SessionHeaderHandlerTests.cs b/src/Tests/TransmissionManager.Transmission.Tests/SessionHeaderHandlerTests.cs new file mode 100644 index 0000000..0499b30 --- /dev/null +++ b/src/Tests/TransmissionManager.Transmission.Tests/SessionHeaderHandlerTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using TransmissionManager.BaseTests.HttpClient; +using TransmissionManager.BaseTests.Options; +using TransmissionManager.Transmission.Options; +using TransmissionManager.Transmission.Services; + +namespace TransmissionManager.Transmission.Tests; + +[Parallelizable(ParallelScope.Self)] +public sealed class SessionHeaderHandlerTests +{ + private const string _requestUri = "http://transmission:9091/transmission/rpc"; + private const string _sessionHeaderName = "X-Transmission-Session-Id"; + private const string _sessionHeaderValue = "TestSessionHeaderValue"; + + private static readonly Dictionary _emptyHeaders = new() + { + [_sessionHeaderName] = string.Empty + }; + + private static readonly Dictionary _filledHeaders = new() + { + [_sessionHeaderName] = _sessionHeaderValue + }; + + private static readonly FakeOptionsMonitor _options = new(new() + { + SessionHeaderName = _sessionHeaderName + }); + + [Test] + public async Task SendAsync_HandlesSessionHeaderUpdate_WhenSessionHeaderIsInvalid() + { + var requestToResponseMap = new Dictionary + { + [new(HttpMethod.Get, new(_requestUri), _emptyHeaders)] = new(HttpStatusCode.Conflict, _filledHeaders), + [new(HttpMethod.Get, new(_requestUri), _filledHeaders)] = new(HttpStatusCode.OK) + }; + + var (client, provider) = CreateClientProviderPair(requestToResponseMap); + + Assert.That(provider.SessionHeaderValue, Is.Empty); + + var result = await client.GetAsync(_requestUri); + + Assert.Multiple(() => + { + Assert.That(result.IsSuccessStatusCode); + Assert.That(provider.SessionHeaderValue, Is.EqualTo(_sessionHeaderValue)); + }); + } + + private static (HttpClient client, SessionHeaderProvider provider) CreateClientProviderPair( + IReadOnlyDictionary requestToResponseMap) + { + var provider = new SessionHeaderProvider(_options); + var fakeMessageHandler = new FakeHttpMessageHandler(requestToResponseMap); + var sessionHandler = new SessionHeaderHandler(provider) { InnerHandler = fakeMessageHandler }; + return (new(sessionHandler), provider); + } +} \ No newline at end of file diff --git a/src/Tests/TransmissionManager.Transmission.Tests/TransmissionClientTests.cs b/src/Tests/TransmissionManager.Transmission.Tests/TransmissionClientTests.cs new file mode 100644 index 0000000..d4a9159 --- /dev/null +++ b/src/Tests/TransmissionManager.Transmission.Tests/TransmissionClientTests.cs @@ -0,0 +1,366 @@ +using System.Net; +using System.Text.Json; +using TransmissionManager.BaseTests.HttpClient; +using TransmissionManager.BaseTests.Options; +using TransmissionManager.Transmission.Dto; +using TransmissionManager.Transmission.Options; +using TransmissionManager.Transmission.Serialization; +using TransmissionManager.Transmission.Services; +using TorrentFields = TransmissionManager.Transmission.Dto.TransmissionTorrentGetRequestFields; + +namespace TransmissionManager.Transmission.Tests; + +[Parallelizable(ParallelScope.Self)] +public sealed class TransmissionClientTests +{ + private const string _transmissionRpcUri = "http://transmission:9091/transmission/rpc"; + + const string _twoTorrentsAllFieldsResponse = """ + { + "arguments": { + "torrents": [ + { + "downloadDir": "/tvshows", + "hashString": "0bda511316a069e86dd8ee8a3610475d2013a7fa", + "name": "TV Show 1", + "percentDone": 1, + "sizeWhenDone": 34008064679 + }, + { + "downloadDir": "/tvshows", + "hashString": "738c60cbd44f0e9457ba2afdad9e9231d76243fe", + "name": "TV Show 2", + "percentDone": 0.5, + "sizeWhenDone": 28948006785 + } + ] + }, + "result": "success" + } + """; + + const string _twoTorrentsWithNoFieldsResponse = """{"arguments":{"torrents":[{},{}]},"result":"success"}"""; + + private static readonly FakeOptionsMonitor _options = new(new() + { + BaseAddress = "http://transmission:9091", + RpcEndpointAddressSuffix = "/transmission/rpc" + }); + + [Test] + public async Task GetTorrentsAsync_GetsAllTorrentsWithAllFields_WhenNoArgumentsProvided() + { + const string expectedRequest = + """{"method":"torrent-get","arguments":{"fields":["hashString","name","sizeWhenDone","percentDone","downloadDir"]}}"""; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: _twoTorrentsAllFieldsResponse)); + + var response = await client.GetTorrentsAsync(); + + AssertUponTransmissionTorrentGetResponse(response, _twoTorrentsAllFieldsResponse); + } + + [Test] + public async Task GetTorrentsAsync_GetsTwoTorrentsWithAllFields_WhenTwoTorrentHashstringsProvided() + { + const string expectedRequest = + """{"method":"torrent-get","arguments":{"fields":["hashString","name","sizeWhenDone","percentDone","downloadDir"],"ids":["0bda511316a069e86dd8ee8a3610475d2013a7fa","738c60cbd44f0e9457ba2afdad9e9231d76243fe"]}}"""; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: _twoTorrentsAllFieldsResponse)); + + var response = await client.GetTorrentsAsync( + [ + "0bda511316a069e86dd8ee8a3610475d2013a7fa", + "738c60cbd44f0e9457ba2afdad9e9231d76243fe" + ]); + + AssertUponTransmissionTorrentGetResponse(response, _twoTorrentsAllFieldsResponse); + } + + [Test] + public async Task GetTorrentsAsync_GetsAllTorrentsWithTwoFields_WhenTwoRequestedFieldsProvided() + { + const string expectedRequest = """{"method":"torrent-get","arguments":{"fields":["hashString","name"]}}"""; + const string twoTorrentsTwoFieldsResponse = """ + { + "arguments": { + "torrents": [ + { + "hashString": "0bda511316a069e86dd8ee8a3610475d2013a7fa", + "name": "TV Show 1" + }, + { + "hashString": "738c60cbd44f0e9457ba2afdad9e9231d76243fe", + "name": "TV Show 2" + } + ] + }, + "result": "success" + } + """; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: twoTorrentsTwoFieldsResponse)); + + var response = await client.GetTorrentsAsync(requestFields: [ TorrentFields.HashString, TorrentFields.Name ]); + + AssertUponTransmissionTorrentGetResponse(response, twoTorrentsTwoFieldsResponse); + } + + [Test] + public async Task GetTorrentsAsync_GetsAllTorrentsWithNoFields_WhenNonExistingRequestedFieldsProvided() + { + const string expectedRequest = """{"method":"torrent-get","arguments":{"fields":[998,999]}}"""; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: _twoTorrentsWithNoFieldsResponse)); + + var response = await client.GetTorrentsAsync(requestFields: [(TorrentFields)998, (TorrentFields)999]); + + AssertUponTransmissionTorrentGetResponse(response, _twoTorrentsWithNoFieldsResponse); + } + + [Test] + public async Task GetTorrentsAsync_GetsAllTorrentsWithNoFields_WhenEmptyRequestedFieldsProvided() + { + const string expectedRequest = """{"method":"torrent-get","arguments":{"fields":[]}}"""; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: _twoTorrentsWithNoFieldsResponse)); + + var response = await client.GetTorrentsAsync(requestFields: []); + + AssertUponTransmissionTorrentGetResponse(response, _twoTorrentsWithNoFieldsResponse); + } + + [Test] + public async Task GetTorrentsAsync_GetsNoTorrents_WhenNonExistingHashstringProvided() + { + const string expectedRequest = + """{"method":"torrent-get","arguments":{"fields":[],"ids":["0bda511316a069e86dd8ee8a3610475d2013a7fb"]}}"""; + + const string noTorrentsResponse = """{"arguments":{"torrents":[]},"result":"success"}"""; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: noTorrentsResponse)); + + var response = await client.GetTorrentsAsync(["0bda511316a069e86dd8ee8a3610475d2013a7fb"], []); + + AssertUponTransmissionTorrentGetResponse(response, noTorrentsResponse); + } + + [Test] + public void GetTorrentsAsync_ThrowsTaskCanceledExceptions_WhenCanceledTokenProvided() + { + const string expectedRequest = + """{"method":"torrent-get","arguments":{"fields":["hashString","name","sizeWhenDone","percentDone","downloadDir"]}}"""; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK)); + + var task = client.GetTorrentsAsync(cancellationToken: new(true)); + + Assert.That(async () => await task, Throws.TypeOf()); + } + + [Test] + public async Task AddTorrentsAsync_ReturnsTorrentAdded_WhenNewMagnetAndDownloadDirProvided() + { + const string expectedRequest = + """{"method":"torrent-add","arguments":{"filename":"magnet:?xt=urn:btih:3A81AAA70E75439D332C146ABDE899E546356BE2\u0026dn=Example%20Name","download-dir":"/tvshows"}}"""; + + const string torrentAddedResponse = """ + { + "arguments": { + "torrent-added": { + "hashString": "3A81AAA70E75439D332C146ABDE899E546356BE2", + "id": 1, + "name": "Example Name" + } + }, + "result": "success" + } + """; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: torrentAddedResponse)); + + var response = await client.AddTorrentUsingMagnetUriAsync("magnet:?xt=urn:btih:3A81AAA70E75439D332C146ABDE899E546356BE2&dn=Example%20Name", "/tvshows"); + + AssertUponTransmissionTorrentAddResponse(response, torrentAddedResponse); + } + + [Test] + public async Task AddTorrentsAsync_ReturnsTorrentDuplicate_WhenExistingMagnetProvided() + { + const string expectedRequest = + """{"method":"torrent-add","arguments":{"filename":"magnet:?xt=urn:btih:3A81AAA70E75439D332C146ABDE899E546356BE2\u0026dn=Example%20Name","download-dir":"/tvshows"}}"""; + + const string torrentDuplicateResponse = """ + { + "arguments": { + "torrent-duplicate": { + "hashString": "3A81AAA70E75439D332C146ABDE899E546356BE2", + "id": 1, + "name": "Example Name" + } + }, + "result": "success" + } + """; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: torrentDuplicateResponse)); + + var response = await client.AddTorrentUsingMagnetUriAsync("magnet:?xt=urn:btih:3A81AAA70E75439D332C146ABDE899E546356BE2&dn=Example%20Name", "/tvshows"); + + AssertUponTransmissionTorrentAddResponse(response, torrentDuplicateResponse); + } + + [Test] + public void AddTorrentsAsync_ThrowsHttpRequestException_WhenInvalidMagnetProvided() + { + const string expectedRequest = + """{"method":"torrent-add","arguments":{"filename":"magnet:?xt=urn:btih:INVALIDMAGNET","download-dir":"/tvshows"}}"""; + + const string unrecognizedInfoResponse = """{"arguments":{},"result":"unrecognized info"}"""; + + const string error = "Response from Transmission does not indicate success: 'unrecognized info'"; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: unrecognizedInfoResponse)); + + var task = client.AddTorrentUsingMagnetUriAsync("magnet:?xt=urn:btih:INVALIDMAGNET", "/tvshows"); + + Assert.That(async () => await task, Throws.TypeOf().With.Message.EqualTo(error)); + } + + [Test] + public void AddTorrentsAsync_ThrowsHttpRequestException_WhenInvalidDownloadDirProvided() + { + const string expectedRequest = + """{"method":"torrent-add","arguments":{"filename":"magnet:?xt=urn:btih:3A81AAA70E75439D332C146ABDE899E546356BE2","download-dir":"^\u0026*("}}"""; + + const string unrecognizedInfoResponse = """{"arguments":{},"result":"download directory path is not absolute"}"""; + + const string error = "Response from Transmission does not indicate success: 'download directory path is not absolute'"; + + var client = CreateClient( + new(HttpMethod.Post, new(_transmissionRpcUri), Content: expectedRequest), + new(HttpStatusCode.OK, Content: unrecognizedInfoResponse)); + + var task = client.AddTorrentUsingMagnetUriAsync("magnet:?xt=urn:btih:3A81AAA70E75439D332C146ABDE899E546356BE2", "^&*("); + + Assert.That(async () => await task, Throws.TypeOf().With.Message.EqualTo(error)); + } + + private static void AssertUponTransmissionTorrentGetResponse(TransmissionTorrentGetResponse actual, string expected) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expected); + + var deserialized = JsonSerializer.Deserialize( + expected, + TransmissionJsonSerializerContext.Default.TransmissionTorrentGetResponse); + + Assert.Multiple(() => + { + Assert.That(deserialized, Is.Not.Null); + Assert.That(actual, Is.Not.Null); + }); + + Assert.Multiple(() => + { + Assert.That(actual.Result, Is.EqualTo(deserialized.Result)); + Assert.That(actual.Arguments is null, Is.EqualTo(deserialized.Arguments is null)); + Assert.That(actual.Arguments?.Torrents is null, Is.EqualTo(deserialized.Arguments?.Torrents is null)); + }); + + if (deserialized.Arguments?.Torrents is not null) + { + Assert.That(actual.Arguments.Torrents, Has.Length.EqualTo(deserialized.Arguments.Torrents.Length)); + Assert.Multiple(() => + { + for (int i = 0; i < actual.Arguments.Torrents.Length; i++) + { + var actualTorrent = actual.Arguments.Torrents[i]; + var expectedTorrent = deserialized.Arguments.Torrents[i]; + Assert.That(actualTorrent.DownloadDir, Is.EqualTo(expectedTorrent.DownloadDir)); + Assert.That(actualTorrent.HashString, Is.EqualTo(expectedTorrent.HashString)); + Assert.That(actualTorrent.Name, Is.EqualTo(expectedTorrent.Name)); + Assert.That(actualTorrent.PercentDone, Is.EqualTo(expectedTorrent.PercentDone)); + Assert.That(actualTorrent.SizeWhenDone, Is.EqualTo(expectedTorrent.SizeWhenDone)); + } + }); + } + } + + private static void AssertUponTransmissionTorrentAddResponse( + TransmissionTorrentAddResponse actual, + string expected) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expected); + + var deserialized = JsonSerializer.Deserialize( + expected, + TransmissionJsonSerializerContext.Default.TransmissionTorrentAddResponse); + + Assert.Multiple(() => + { + Assert.That(deserialized, Is.Not.Null); + Assert.That(actual, Is.Not.Null); + }); + + Assert.Multiple(() => + { + Assert.That(actual.Result, Is.EqualTo(deserialized.Result)); + Assert.That(actual.Arguments is null, Is.EqualTo(deserialized.Arguments is null)); + if (actual.Arguments is not null && deserialized.Arguments is not null) + { + AssertUponTransmissionTorrentAddResponseItem( + actual.Arguments.TorrentAdded, + deserialized.Arguments.TorrentAdded); + + AssertUponTransmissionTorrentAddResponseItem( + actual.Arguments.TorrentDuplicate, + deserialized.Arguments.TorrentDuplicate); + } + }); + + static void AssertUponTransmissionTorrentAddResponseItem( + TransmissionTorrentAddResponseItem? actual, + TransmissionTorrentAddResponseItem? expected) + { + Assert.That(actual is null, Is.EqualTo(expected is null)); + if (actual is not null && expected is not null) + { + Assert.Multiple(() => + { + Assert.That(actual.HashString, Is.EqualTo(expected.HashString)); + Assert.That(actual.Name, Is.EqualTo(expected.Name)); + }); + } + } + } + + private static TransmissionClient CreateClient(TestRequest request, TestResponse response) + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(request, response)) + { + BaseAddress = new(_options.CurrentValue.BaseAddress) + }; + + return new(_options, httpClient); + } +} diff --git a/src/Tests/TransmissionManager.Transmission.Tests/TransmissionManager.Transmission.Tests.csproj b/src/Tests/TransmissionManager.Transmission.Tests/TransmissionManager.Transmission.Tests.csproj new file mode 100644 index 0000000..1674e39 --- /dev/null +++ b/src/Tests/TransmissionManager.Transmission.Tests/TransmissionManager.Transmission.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/TransmissionManager.sln b/src/TransmissionManager.sln index 56e8e22..f156203 100644 --- a/src/TransmissionManager.sln +++ b/src/TransmissionManager.sln @@ -25,6 +25,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TransmissionManager.Torrent EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TransmissionManager.Database", "TransmissionManager.Database\TransmissionManager.Database.csproj", "{C3B1BAC1-F19E-4DBB-8520-058CBB7AEB17}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TransmissionManager.Transmission.Tests", "Tests\TransmissionManager.Transmission.Tests\TransmissionManager.Transmission.Tests.csproj", "{2B3AD0E6-45F1-46CA-966A-FAEF03D285AA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -55,6 +57,10 @@ Global {C3B1BAC1-F19E-4DBB-8520-058CBB7AEB17}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3B1BAC1-F19E-4DBB-8520-058CBB7AEB17}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3B1BAC1-F19E-4DBB-8520-058CBB7AEB17}.Release|Any CPU.Build.0 = Release|Any CPU + {2B3AD0E6-45F1-46CA-966A-FAEF03D285AA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B3AD0E6-45F1-46CA-966A-FAEF03D285AA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B3AD0E6-45F1-46CA-966A-FAEF03D285AA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B3AD0E6-45F1-46CA-966A-FAEF03D285AA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -62,6 +68,7 @@ Global GlobalSection(NestedProjects) = preSolution {ADCADB36-C5A4-4608-B419-2601E2BA0E93} = {5F7726A6-4944-4347-A591-ACB2FB3F7126} {45506A55-300F-41CB-BF68-9A1892FA498B} = {5F7726A6-4944-4347-A591-ACB2FB3F7126} + {2B3AD0E6-45F1-46CA-966A-FAEF03D285AA} = {5F7726A6-4944-4347-A591-ACB2FB3F7126} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {89CF4CEF-CBC8-411C-B159-83323BE5F85C}