mirror of
https://github.com/Prowlarr/Prowlarr
synced 2026-05-09 05:22:09 +02:00
Refactor Listenarr schema handling and tests
Updated Listenarr schema parsing to preserve the 'implementations' array as a list instead of expanding it into multiple schema objects. Adjusted related logic in Listenarr.cs to support case-insensitive matching for both 'Implementation' and 'Implementations'. Simplified ListenarrV1Proxy by removing fallback plural endpoint logic and unused methods. Updated tests and models to reflect these changes and improve clarity.
This commit is contained in:
parent
22f582af07
commit
62b1e259fe
5 changed files with 74 additions and 275 deletions
|
|
@ -17,7 +17,7 @@
|
|||
namespace NzbDrone.Core.Test.Applications.Listenarr
|
||||
{
|
||||
[TestFixture]
|
||||
public class ListenarrFixture : CoreTest<NzbDrone.Core.Applications.Listenarr.Listenarr>
|
||||
public class ListenarrFixture : CoreTest<Core.Applications.Listenarr.Listenarr>
|
||||
{
|
||||
[SetUp]
|
||||
public void Setup()
|
||||
|
|
@ -37,17 +37,16 @@ public void Setup()
|
|||
|
||||
// Ensure cache calls execute factory each time during tests to avoid stale cached values
|
||||
// Use ICached<List<ListenarrIndexer>> mock via dynamic mocking to avoid depending on concrete implementation
|
||||
var cached = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
|
||||
cached.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
var cached = new Mock<ICached<List<ListenarrIndexer>>>();
|
||||
cached.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
|
||||
Mocker.GetMock<ICacheManager>().Setup(m => m.GetCache<System.Collections.Generic.List<ListenarrIndexer>>(It.IsAny<Type>())).Returns(cached.Object);
|
||||
Mocker.GetMock<ICacheManager>().Setup(m => m.GetCache<List<ListenarrIndexer>>(It.IsAny<Type>())).Returns(cached.Object);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowlarr()
|
||||
{
|
||||
// Arrange
|
||||
var indexer = new ListenarrIndexer
|
||||
{
|
||||
Id = 99,
|
||||
|
|
@ -61,10 +60,8 @@ public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowl
|
|||
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { indexer });
|
||||
|
||||
// Act
|
||||
var mappings = Subject.GetIndexerMappings();
|
||||
|
||||
// Assert
|
||||
mappings.Should().HaveCount(1);
|
||||
mappings[0].IndexerId.Should().Be(45);
|
||||
mappings[0].RemoteIndexerId.Should().Be(99);
|
||||
|
|
@ -73,7 +70,6 @@ public void GetIndexerMappings_should_return_mappings_when_baseUrl_matches_prowl
|
|||
[Test]
|
||||
public void GetIndexerMappings_should_skip_non_matching_api_key_and_baseurl()
|
||||
{
|
||||
// Arrange
|
||||
var indexer = new ListenarrIndexer
|
||||
{
|
||||
Id = 100,
|
||||
|
|
@ -87,17 +83,14 @@ public void GetIndexerMappings_should_skip_non_matching_api_key_and_baseurl()
|
|||
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { indexer });
|
||||
|
||||
// Act
|
||||
var mappings = Subject.GetIndexerMappings();
|
||||
|
||||
// Assert
|
||||
mappings.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Test_should_call_testconnection_and_return_success_when_valid()
|
||||
{
|
||||
// Arrange
|
||||
var schema = new List<ListenarrIndexer>
|
||||
{
|
||||
new ListenarrIndexer
|
||||
|
|
@ -116,16 +109,13 @@ public void Test_should_call_testconnection_and_return_success_when_valid()
|
|||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).Returns((FluentValidation.Results.ValidationFailure)null).Verifiable();
|
||||
|
||||
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
|
||||
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
var cachedForTest = new Mock<ICached<List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
|
||||
// Act
|
||||
var result = Subject.Test();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Verify(m => m.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>()), Times.Once);
|
||||
}
|
||||
|
|
@ -133,7 +123,6 @@ public void Test_should_call_testconnection_and_return_success_when_valid()
|
|||
[Test]
|
||||
public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incomplete()
|
||||
{
|
||||
// Arrange
|
||||
var cachedSchema = new List<ListenarrIndexer>
|
||||
{
|
||||
new ListenarrIndexer
|
||||
|
|
@ -161,21 +150,17 @@ public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incompl
|
|||
}
|
||||
};
|
||||
|
||||
// Cache returns stale schema
|
||||
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => cachedSchema);
|
||||
var cachedForTest = new Mock<ICached<List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => cachedSchema);
|
||||
|
||||
typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
|
||||
// Proxy will return fresh schema when direct fetched
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(freshSchema);
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).Returns((FluentValidation.Results.ValidationFailure)null);
|
||||
|
||||
// Act
|
||||
var result = Subject.Test();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeTrue();
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Verify(m => m.GetIndexerSchema(It.IsAny<ListenarrSettings>()), Times.AtLeastOnce);
|
||||
}
|
||||
|
|
@ -183,7 +168,6 @@ public void Test_should_retry_and_use_fresh_schema_when_cached_schema_is_incompl
|
|||
[Test]
|
||||
public void Test_should_handle_exception_from_testconnection()
|
||||
{
|
||||
// Arrange
|
||||
var schema = new List<ListenarrIndexer>
|
||||
{
|
||||
new ListenarrIndexer
|
||||
|
|
@ -202,16 +186,13 @@ public void Test_should_handle_exception_from_testconnection()
|
|||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.TestConnection(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).Throws(new Exception("boom"));
|
||||
|
||||
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
|
||||
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
var cachedForTest = new Mock<ICached<List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
|
||||
// Act
|
||||
var result = Subject.Test();
|
||||
|
||||
// Assert
|
||||
result.IsValid.Should().BeFalse();
|
||||
result.Errors.Should().ContainSingle(e => e.ErrorMessage.Contains("Unable to complete application test"));
|
||||
ExceptionVerification.ExpectedWarns(1);
|
||||
|
|
@ -220,11 +201,9 @@ public void Test_should_handle_exception_from_testconnection()
|
|||
[Test]
|
||||
public void Test_should_fail_when_schema_missing()
|
||||
{
|
||||
// Arrange
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer>());
|
||||
|
||||
// Act & Assert - call private BuildListenarrIndexer to assert it throws
|
||||
var method = typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetMethod("BuildListenarrIndexer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var method = typeof(Core.Applications.Listenarr.Listenarr).GetMethod("BuildListenarrIndexer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
var indexerDef = new IndexerDefinition { Id = 1, Name = "Test", Protocol = DownloadProtocol.Usenet, Capabilities = new IndexerCapabilities() };
|
||||
|
||||
var ex = Assert.Throws<System.Reflection.TargetInvocationException>(() => method.Invoke(Subject, new object[] { indexerDef, indexerDef.Capabilities, DownloadProtocol.Usenet, 0 }));
|
||||
|
|
@ -236,7 +215,6 @@ public void Test_should_fail_when_schema_missing()
|
|||
[Test]
|
||||
public void Test_should_fail_when_schema_missing_required_fields()
|
||||
{
|
||||
// Arrange
|
||||
var schema = new List<ListenarrIndexer>
|
||||
{
|
||||
new ListenarrIndexer
|
||||
|
|
@ -251,13 +229,11 @@ public void Test_should_fail_when_schema_missing_required_fields()
|
|||
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
|
||||
|
||||
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
|
||||
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
var cachedForTest = new Mock<ICached<List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
|
||||
// Act & Assert
|
||||
try
|
||||
{
|
||||
var result = Subject.Test();
|
||||
|
|
@ -266,7 +242,6 @@ public void Test_should_fail_when_schema_missing_required_fields()
|
|||
}
|
||||
finally
|
||||
{
|
||||
// Consume expected warnings even if Subject.Test throws or an assert fails so teardown does not fail
|
||||
ExceptionVerification.IgnoreWarns();
|
||||
}
|
||||
}
|
||||
|
|
@ -274,7 +249,6 @@ public void Test_should_fail_when_schema_missing_required_fields()
|
|||
[Test]
|
||||
public void AddIndexer_should_insert_app_indexer_mapping_on_success()
|
||||
{
|
||||
// Arrange
|
||||
var indexerDefinition = new IndexerDefinition
|
||||
{
|
||||
Id = 12,
|
||||
|
|
@ -285,7 +259,6 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success()
|
|||
AppProfile = new LazyLoaded<AppSyncProfile>(new AppSyncProfile { EnableRss = true, EnableAutomaticSearch = true, EnableInteractiveSearch = true })
|
||||
};
|
||||
|
||||
// Add a category that matches Settings.SyncCategories
|
||||
indexerDefinition.Capabilities.Categories.AddCategoryMapping(1, NewznabStandardCategory.AudioAudiobook);
|
||||
|
||||
var mockIndexer = new Mock<IIndexer>();
|
||||
|
|
@ -311,19 +284,15 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success()
|
|||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.AddIndexer(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>())).Returns(new ListenarrIndexer { Id = 501 });
|
||||
|
||||
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
|
||||
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
var cachedForTest = new Mock<ICached<List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
|
||||
// pre-check
|
||||
indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty();
|
||||
|
||||
// Act
|
||||
Subject.AddIndexer(indexerDefinition);
|
||||
|
||||
// Assert
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Verify(m => m.AddIndexer(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>()), Times.Once());
|
||||
Mocker.GetMock<IAppIndexerMapService>().Verify(m => m.Insert(It.Is<AppIndexerMap>(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once());
|
||||
}
|
||||
|
|
@ -331,7 +300,6 @@ public void AddIndexer_should_insert_app_indexer_mapping_on_success()
|
|||
[Test]
|
||||
public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches()
|
||||
{
|
||||
// Arrange
|
||||
var indexerDefinition = new IndexerDefinition
|
||||
{
|
||||
Id = 12,
|
||||
|
|
@ -364,7 +332,6 @@ public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches()
|
|||
}
|
||||
};
|
||||
|
||||
// Existing remote indexer with matching baseUrl
|
||||
var existing = new ListenarrIndexer
|
||||
{
|
||||
Id = 501,
|
||||
|
|
@ -381,19 +348,15 @@ public void AddIndexer_should_use_existing_remote_indexer_if_baseUrl_matches()
|
|||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexerSchema(It.IsAny<ListenarrSettings>())).Returns(schema);
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Setup(c => c.GetIndexers(It.IsAny<ListenarrSettings>())).Returns(new List<ListenarrIndexer> { existing });
|
||||
|
||||
// Ensure the private schema cache will execute the factory so it invokes our mocked proxy
|
||||
var cachedForTest = new Mock<ICached<System.Collections.Generic.List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<System.Collections.Generic.List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<System.Collections.Generic.List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(NzbDrone.Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
var cachedForTest = new Mock<ICached<List<ListenarrIndexer>>>();
|
||||
cachedForTest.Setup(c => c.Get(It.IsAny<string>(), It.IsAny<Func<List<ListenarrIndexer>>>(), It.IsAny<TimeSpan>()))
|
||||
.Returns<string, Func<List<ListenarrIndexer>>, TimeSpan>((k, f, t) => f());
|
||||
typeof(Core.Applications.Listenarr.Listenarr).GetField("_schemaCache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).SetValue(Subject, cachedForTest.Object);
|
||||
|
||||
// pre-check
|
||||
indexerDefinition.Capabilities.Categories.SupportedCategories(((ListenarrSettings)Subject.Definition.Settings).SyncCategories.ToArray()).Should().NotBeEmpty();
|
||||
|
||||
// Act
|
||||
Subject.AddIndexer(indexerDefinition);
|
||||
|
||||
// Assert - AddIndexer should not be called because remote already exists
|
||||
Mocker.GetMock<IListenarrV1Proxy>().Verify(m => m.AddIndexer(It.IsAny<ListenarrIndexer>(), It.IsAny<ListenarrSettings>()), Times.Never());
|
||||
Mocker.GetMock<IAppIndexerMapService>().Verify(m => m.Insert(It.Is<AppIndexerMap>(a => a.IndexerId == 12 && a.RemoteIndexerId == 501)), Times.Once());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
using Moq;
|
||||
using NUnit.Framework;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Serializer;
|
||||
using NzbDrone.Core.Applications.Listenarr;
|
||||
using NzbDrone.Test.Common;
|
||||
|
||||
|
|
@ -15,10 +16,22 @@ public class ListenarrV1ProxyFixture : TestBase<ListenarrV1Proxy>
|
|||
[Test]
|
||||
public void GetIndexers_should_deserialize_json_and_set_api_key_header()
|
||||
{
|
||||
// Arrange
|
||||
var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" };
|
||||
|
||||
var json = "[ { \"id\": 42, \"name\": \"Test\", \"implementation\": \"Newznab\", \"fields\": [ { \"name\": \"baseUrl\", \"value\": \"http://localhost:4545/1/api\" }, { \"name\": \"apiKey\", \"value\": \"x\" } ] } ]";
|
||||
var responseJson = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
Id = "42",
|
||||
Name = "Test",
|
||||
Implementation = "Newznab",
|
||||
Fields = new[]
|
||||
{
|
||||
new { name = "baseUrl", value = "http://localhost:4545/1/api" },
|
||||
new { name = "apiKey", value = "x" },
|
||||
}
|
||||
}
|
||||
}.ToJson();
|
||||
|
||||
HttpRequest capturedRequest = null;
|
||||
|
||||
|
|
@ -27,38 +40,31 @@ public void GetIndexers_should_deserialize_json_and_set_api_key_header()
|
|||
.Returns<HttpRequest>(req =>
|
||||
{
|
||||
capturedRequest = req;
|
||||
return new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0"));
|
||||
return new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), System.Text.Encoding.UTF8.GetBytes(responseJson), 0, HttpStatusCode.OK, new Version("1.0"));
|
||||
});
|
||||
|
||||
// Act
|
||||
var result = Subject.GetIndexers(settings);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Count.Should().Be(1);
|
||||
capturedRequest.Headers.GetSingleValue("X-Api-Key").Should().Be("abc123");
|
||||
|
||||
// Accept either singular /api/v1/indexer or legacy /api/indexer in requests
|
||||
(capturedRequest.Url.ToString().Contains("/api/indexer") || capturedRequest.Url.ToString().Contains("/api/v1/indexer")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetIndexerSchema_should_handle_single_object_response_with_fields_object()
|
||||
{
|
||||
// Arrange
|
||||
var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" };
|
||||
|
||||
// Schema returned as an object with fields as an object (name -> definition)
|
||||
var json = "{ \"id\": 1, \"implementation\": \"Newznab\", \"fields\": { \"baseUrl\": { \"type\": \"text\" }, \"apiKey\": { \"type\": \"text\" } } }";
|
||||
var json = "[ { \"id\": 1, \"implementation\": \"Newznab\", \"fields\": { \"baseUrl\": { \"type\": \"text\" }, \"apiKey\": { \"type\": \"text\" } } } ]";
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Execute(It.IsAny<HttpRequest>()))
|
||||
.Returns<HttpRequest>(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0")));
|
||||
|
||||
// Act
|
||||
var result = Subject.GetIndexerSchema(settings);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Count.Should().Be(1);
|
||||
result[0].Fields.Should().NotBeNull();
|
||||
|
|
@ -68,41 +74,36 @@ public void GetIndexerSchema_should_handle_single_object_response_with_fields_ob
|
|||
}
|
||||
|
||||
[Test]
|
||||
public void GetIndexerSchema_should_expand_implementations_array_into_multiple_schemas()
|
||||
public void GetIndexerSchema_should_preserve_implementations_array_as_list()
|
||||
{
|
||||
// Arrange
|
||||
var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "abc123" };
|
||||
|
||||
var json = "{ \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" } ], \"implementations\": [\"Newznab\",\"Torznab\"] }";
|
||||
var json = "[ { \"fields\": [ { \"name\": \"baseUrl\", \"type\": \"text\" } ], \"implementations\": [\"Newznab\",\"Torznab\"] } ]";
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Execute(It.IsAny<HttpRequest>()))
|
||||
.Returns<HttpRequest>(req => new HttpResponse(req, new HttpHeader { { "Content-Type", "application/json" } }, new CookieCollection(), json, 0, HttpStatusCode.OK, new Version("1.0")));
|
||||
|
||||
// Act
|
||||
var result = Subject.GetIndexerSchema(settings);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Count.Should().Be(2);
|
||||
result.Should().Contain(r => r.Implementation == "Newznab");
|
||||
result.Should().Contain(r => r.Implementation == "Torznab");
|
||||
result.Count.Should().Be(1);
|
||||
result[0].Implementations.Should().NotBeNull();
|
||||
result[0].Implementations.Should().Contain("Newznab");
|
||||
result[0].Implementations.Should().Contain("Torznab");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Execute_should_throw_application_exception_when_unauthorized()
|
||||
{
|
||||
// Arrange
|
||||
var settings = new ListenarrSettings { BaseUrl = "http://localhost:4545", ApiKey = "bad" };
|
||||
|
||||
Mocker.GetMock<IHttpClient>()
|
||||
.Setup(c => c.Execute(It.IsAny<HttpRequest>()))
|
||||
.Throws(new HttpException(new HttpResponse(new HttpRequest("http://localhost/"), new HttpHeader(), new CookieCollection(), "unauthorized", 0, HttpStatusCode.Unauthorized, new Version("1.0"))));
|
||||
|
||||
// Act / Assert
|
||||
Assert.Throws<NzbDrone.Common.Http.HttpException>(() => Subject.GetIndexers(settings));
|
||||
|
||||
// No warning is logged by GetIndexers on unauthorized (it throws before the API-key-specific log path)
|
||||
ExceptionVerification.ExpectedWarns(0);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -416,8 +416,8 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe
|
|||
syncFields.AddRange(new List<string> { "additionalParameters" });
|
||||
}
|
||||
|
||||
var newznab = schemas.FirstOrDefault(i => i.Implementation == "Newznab");
|
||||
var torznab = schemas.FirstOrDefault(i => i.Implementation == "Torznab");
|
||||
var newznab = schemas.FirstOrDefault(i => string.Equals(i.Implementation, "Newznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Newznab", StringComparison.InvariantCultureIgnoreCase))));
|
||||
var torznab = schemas.FirstOrDefault(i => string.Equals(i.Implementation, "Torznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Torznab", StringComparison.InvariantCultureIgnoreCase))));
|
||||
|
||||
if (newznab == null && torznab == null)
|
||||
{
|
||||
|
|
@ -464,8 +464,8 @@ private ListenarrIndexer BuildListenarrIndexer(IndexerDefinition indexer, Indexe
|
|||
|
||||
if (freshSchemas != null && freshSchemas.Any())
|
||||
{
|
||||
var freshNewznab = freshSchemas.FirstOrDefault(i => i.Implementation == "Newznab");
|
||||
var freshTorznab = freshSchemas.FirstOrDefault(i => i.Implementation == "Torznab");
|
||||
var freshNewznab = freshSchemas.FirstOrDefault(i => string.Equals(i.Implementation, "Newznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Newznab", StringComparison.InvariantCultureIgnoreCase))));
|
||||
var freshTorznab = freshSchemas.FirstOrDefault(i => string.Equals(i.Implementation, "Torznab", StringComparison.InvariantCultureIgnoreCase) || (i.Implementations != null && i.Implementations.Any(s => string.Equals(s, "Torznab", StringComparison.InvariantCultureIgnoreCase))));
|
||||
|
||||
var freshSchema = protocol == DownloadProtocol.Usenet ? freshNewznab ?? freshTorznab : freshTorznab ?? freshNewznab;
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ public class ListenarrIndexer
|
|||
public string Name { get; set; }
|
||||
public string ImplementationName { get; set; }
|
||||
public string Implementation { get; set; }
|
||||
public List<string> Implementations { get; set; }
|
||||
public string ConfigContract { get; set; }
|
||||
public string InfoLink { get; set; }
|
||||
public int? DownloadClientId { get; set; }
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ namespace NzbDrone.Core.Applications.Listenarr
|
|||
{
|
||||
public interface IListenarrV1Proxy
|
||||
{
|
||||
ListenarrStatus GetStatus(ListenarrSettings settings);
|
||||
ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings);
|
||||
List<ListenarrIndexer> GetIndexers(ListenarrSettings settings);
|
||||
ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings);
|
||||
|
|
@ -39,34 +38,19 @@ public ListenarrV1Proxy(IHttpClient httpClient, Logger logger)
|
|||
_logger = logger;
|
||||
}
|
||||
|
||||
public ListenarrStatus GetStatus(ListenarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppApiRoute}/system/status", HttpMethod.Get);
|
||||
return Execute<ListenarrStatus>(request);
|
||||
}
|
||||
|
||||
public List<ListenarrIndexer> GetIndexers(ListenarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}", HttpMethod.Get);
|
||||
|
||||
try
|
||||
{
|
||||
return Execute<List<ListenarrIndexer>>(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Fallback to plural resource if the app exposes /indexers
|
||||
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers", HttpMethod.Get);
|
||||
return Execute<List<ListenarrIndexer>>(fallback);
|
||||
}
|
||||
return Execute<List<ListenarrIndexer>>(request);
|
||||
}
|
||||
|
||||
public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Get);
|
||||
return Execute<ListenarrIndexer>(fallback);
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Get);
|
||||
return Execute<ListenarrIndexer>(request);
|
||||
}
|
||||
catch (HttpException)
|
||||
{
|
||||
|
|
@ -77,41 +61,26 @@ public ListenarrIndexer GetIndexer(int indexerId, ListenarrSettings settings)
|
|||
public void RemoveIndexer(int indexerId, ListenarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/{indexerId}", HttpMethod.Delete);
|
||||
|
||||
try
|
||||
{
|
||||
_httpClient.Execute(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Try plural endpoint as fallback
|
||||
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexerId}", HttpMethod.Delete);
|
||||
_httpClient.Execute(fallback);
|
||||
}
|
||||
_httpClient.Execute(request);
|
||||
}
|
||||
|
||||
public List<ListenarrIndexer> GetIndexerSchema(ListenarrSettings settings)
|
||||
{
|
||||
var request = BuildRequest(settings, $"{AppIndexerApiRoute}/schema", HttpMethod.Get);
|
||||
|
||||
try
|
||||
var response = _httpClient.Execute(request);
|
||||
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
var response = _httpClient.Execute(request);
|
||||
throw new HttpException(response);
|
||||
}
|
||||
|
||||
if ((int)response.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(response);
|
||||
}
|
||||
var token = Newtonsoft.Json.Linq.JToken.Parse(response.Content);
|
||||
|
||||
// Parse and normalize flexible schema responses
|
||||
var token = Newtonsoft.Json.Linq.JToken.Parse(response.Content);
|
||||
|
||||
// If the schema is a single object, wrap into a list (and expand implementations arrays)
|
||||
if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object)
|
||||
{
|
||||
if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object)
|
||||
{
|
||||
var obj = (Newtonsoft.Json.Linq.JObject)token;
|
||||
|
||||
// Normalize fields when they are returned as an object instead of an array
|
||||
if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj)
|
||||
{
|
||||
var fieldsArray = new Newtonsoft.Json.Linq.JArray();
|
||||
|
|
@ -135,27 +104,15 @@ public List<ListenarrIndexer> GetIndexerSchema(ListenarrSettings settings)
|
|||
obj["fields"] = fieldsArray;
|
||||
}
|
||||
|
||||
// If implementations is an array of strings, expand into separate schema entries
|
||||
if (obj["implementations"] is Newtonsoft.Json.Linq.JArray implsArray && implsArray.Count > 0)
|
||||
{
|
||||
var results = new List<ListenarrIndexer>();
|
||||
|
||||
foreach (var impl in implsArray)
|
||||
{
|
||||
var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone();
|
||||
copy.Property("implementations")?.Remove();
|
||||
copy["implementation"] = impl;
|
||||
results.Add(copy.ToObject<ListenarrIndexer>());
|
||||
}
|
||||
|
||||
return results;
|
||||
return new List<ListenarrIndexer> { obj.ToObject<ListenarrIndexer>() };
|
||||
}
|
||||
|
||||
return new List<ListenarrIndexer> { obj.ToObject<ListenarrIndexer>() };
|
||||
}
|
||||
|
||||
// If it's already an array, parse each item, normalize fields and expand implementations arrays
|
||||
if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array)
|
||||
if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array)
|
||||
{
|
||||
var list = new List<ListenarrIndexer>();
|
||||
|
||||
|
|
@ -168,7 +125,6 @@ public List<ListenarrIndexer> GetIndexerSchema(ListenarrSettings settings)
|
|||
|
||||
var obj = (Newtonsoft.Json.Linq.JObject)item;
|
||||
|
||||
// Normalize fields if needed
|
||||
if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj2)
|
||||
{
|
||||
var fieldsArray = new Newtonsoft.Json.Linq.JArray();
|
||||
|
|
@ -191,96 +147,17 @@ public List<ListenarrIndexer> GetIndexerSchema(ListenarrSettings settings)
|
|||
obj["fields"] = fieldsArray;
|
||||
}
|
||||
|
||||
if (obj["implementations"] is Newtonsoft.Json.Linq.JArray impls)
|
||||
{
|
||||
foreach (var impl in impls)
|
||||
{
|
||||
var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone();
|
||||
copy.Property("implementations")?.Remove();
|
||||
copy["implementation"] = impl;
|
||||
list.Add(copy.ToObject<ListenarrIndexer>());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
list.Add(obj.ToObject<ListenarrIndexer>());
|
||||
}
|
||||
list.Add(obj.ToObject<ListenarrIndexer>());
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
// Unexpected token type
|
||||
throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema");
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/schema", HttpMethod.Get);
|
||||
var fallbackResponse = _httpClient.Execute(fallback);
|
||||
|
||||
if ((int)fallbackResponse.StatusCode >= 300)
|
||||
{
|
||||
throw new HttpException(fallbackResponse);
|
||||
}
|
||||
|
||||
var token = Newtonsoft.Json.Linq.JToken.Parse(fallbackResponse.Content);
|
||||
|
||||
if (token.Type == Newtonsoft.Json.Linq.JTokenType.Array)
|
||||
{
|
||||
return token.ToObject<List<ListenarrIndexer>>();
|
||||
}
|
||||
|
||||
if (token.Type == Newtonsoft.Json.Linq.JTokenType.Object)
|
||||
{
|
||||
var obj = (Newtonsoft.Json.Linq.JObject)token;
|
||||
|
||||
if (obj["fields"] is Newtonsoft.Json.Linq.JObject fieldsObj)
|
||||
{
|
||||
var fieldsArray = new Newtonsoft.Json.Linq.JArray();
|
||||
|
||||
foreach (var prop in fieldsObj.Properties())
|
||||
{
|
||||
if (prop.Value.Type == Newtonsoft.Json.Linq.JTokenType.Object)
|
||||
{
|
||||
var item = (Newtonsoft.Json.Linq.JObject)prop.Value;
|
||||
item["name"] = prop.Name;
|
||||
fieldsArray.Add(item);
|
||||
}
|
||||
else
|
||||
{
|
||||
var item = new Newtonsoft.Json.Linq.JObject { ["name"] = prop.Name, ["value"] = prop.Value };
|
||||
fieldsArray.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
obj["fields"] = fieldsArray;
|
||||
}
|
||||
|
||||
if (obj["implementations"] is Newtonsoft.Json.Linq.JArray implsArray && implsArray.Count > 0)
|
||||
{
|
||||
var results = new List<ListenarrIndexer>();
|
||||
|
||||
foreach (var impl in implsArray)
|
||||
{
|
||||
var copy = (Newtonsoft.Json.Linq.JObject)obj.DeepClone();
|
||||
copy.Property("implementations")?.Remove();
|
||||
copy["implementation"] = impl;
|
||||
results.Add(copy.ToObject<ListenarrIndexer>());
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
return new List<ListenarrIndexer> { obj.ToObject<ListenarrIndexer>() };
|
||||
}
|
||||
|
||||
throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema (fallback)");
|
||||
}
|
||||
throw new JsonReaderException("Unexpected JSON token while parsing Listenarr schema");
|
||||
}
|
||||
|
||||
public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings settings)
|
||||
{
|
||||
// Defensive check: avoid creating duplicates if an indexer with the same baseUrl already exists on the remote app.
|
||||
try
|
||||
{
|
||||
var incomingBaseUrl = indexer?.Fields?.FirstOrDefault(f => f.Name == "baseUrl")?.Value as string;
|
||||
|
|
@ -305,7 +182,6 @@ public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings s
|
|||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// If the existence check fails for any reason, proceed with the add flow and let any resulting errors bubble up.
|
||||
_logger.Debug(ex, "Failed to run pre-flight existence check before AddIndexer; proceeding to create");
|
||||
}
|
||||
|
||||
|
|
@ -326,22 +202,6 @@ public ListenarrIndexer AddIndexer(ListenarrIndexer indexer, ListenarrSettings s
|
|||
_logger.Debug("Retry payload: {0}", request.ContentSummary);
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Try plural form as a fallback
|
||||
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers", HttpMethod.Post);
|
||||
fallback.SetContent(indexer.ToJson());
|
||||
fallback.ContentSummary = indexer.ToJson(Formatting.None);
|
||||
|
||||
try
|
||||
{
|
||||
return ExecuteIndexerRequest(fallback);
|
||||
}
|
||||
catch (HttpException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSettings settings)
|
||||
|
|
@ -361,22 +221,6 @@ public ListenarrIndexer UpdateIndexer(ListenarrIndexer indexer, ListenarrSetting
|
|||
request.Url = request.Url.AddQueryParam("forceSave", "true");
|
||||
return ExecuteIndexerRequest(request);
|
||||
}
|
||||
catch (HttpException ex) when (ex.Response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
// Try plural form as a fallback
|
||||
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/{indexer.Id}", HttpMethod.Put);
|
||||
fallback.SetContent(indexer.ToJson());
|
||||
fallback.ContentSummary = indexer.ToJson(Formatting.None);
|
||||
|
||||
try
|
||||
{
|
||||
return ExecuteIndexerRequest(fallback);
|
||||
}
|
||||
catch (HttpException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSettings settings)
|
||||
|
|
@ -390,16 +234,6 @@ public ValidationFailure TestConnection(ListenarrIndexer indexer, ListenarrSetti
|
|||
{
|
||||
var applicationVersion = _httpClient.Post(request).Headers.GetSingleValue("X-Application-Version");
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
// Try plural endpoint as a fallback
|
||||
var fallback = BuildRequest(settings, $"{AppApiRoute}/indexers/test", HttpMethod.Post);
|
||||
fallback.SetContent(indexer.ToJson());
|
||||
fallback.ContentSummary = indexer.ToJson(Formatting.None);
|
||||
|
||||
applicationVersion = _httpClient.Post(fallback).Headers.GetSingleValue("X-Application-Version");
|
||||
}
|
||||
|
||||
if (applicationVersion == null)
|
||||
{
|
||||
return new ValidationFailure(string.Empty, "Failed to fetch Listenarr version");
|
||||
|
|
|
|||
Loading…
Reference in a new issue