diff --git a/.github/workflows/multi-plugin-smoke-test.yml b/.github/workflows/multi-plugin-smoke-test.yml index f20b19de..630a0460 100644 --- a/.github/workflows/multi-plugin-smoke-test.yml +++ b/.github/workflows/multi-plugin-smoke-test.yml @@ -24,8 +24,6 @@ jobs: name: 🔥 Plugin Co-existence uses: RicherTunes/Lidarr.Plugin.Common/.github/workflows/multi-plugin-smoke-test.yml@main with: - caller_plugin: brainarr test_plugins: 'brainarr,qobuzarr,tidalarr' - lidarr_docker_version: 'pr-plugins-2.14.2.4786' - secrets: - SUBMODULES_TOKEN: ${{ secrets.SUBMODULES_TOKEN }} + lidarr_tag: 'pr-plugins-2.14.2.4786' + secrets: inherit diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 37e51c81..edd50132 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,7 +29,7 @@ jobs: with: fetch-depth: 0 submodules: false - token: ${{ secrets.SUBMODULES_TOKEN || github.token }} + token: ${{ github.token }} - name: Init Common submodule uses: ./.github/actions/init-common-submodule diff --git a/.github/workflows/packaging-closure.yml b/.github/workflows/packaging-closure.yml index 4baa2a06..e00c14c2 100644 --- a/.github/workflows/packaging-closure.yml +++ b/.github/workflows/packaging-closure.yml @@ -29,7 +29,7 @@ jobs: with: fetch-depth: 0 submodules: false - token: ${{ secrets.SUBMODULES_TOKEN || github.token }} + token: ${{ github.token }} - name: Init Common submodule uses: ./.github/actions/init-common-submodule diff --git a/.github/workflows/plugin-package.yml b/.github/workflows/plugin-package.yml index 072f12fe..74162707 100644 --- a/.github/workflows/plugin-package.yml +++ b/.github/workflows/plugin-package.yml @@ -27,7 +27,7 @@ jobs: with: fetch-depth: 0 submodules: false - token: ${{ secrets.SUBMODULES_TOKEN || github.token }} + token: ${{ github.token }} - name: Init Common submodule uses: ./.github/actions/init-common-submodule @@ -198,7 +198,7 @@ jobs: with: fetch-depth: 0 submodules: false - token: ${{ secrets.SUBMODULES_TOKEN || github.token }} + token: ${{ github.token }} - name: Download packaged artifact uses: actions/download-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4712ba6..98d77855 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: with: fetch-depth: 0 submodules: false - token: ${{ secrets.SUBMODULES_TOKEN || github.token }} + token: ${{ github.token }} - name: Init Common submodule diff --git a/Brainarr.Plugin/Resilience/CircuitBreaker.cs b/Brainarr.Plugin/Resilience/CircuitBreaker.cs index f2e687b8..79d77f44 100644 --- a/Brainarr.Plugin/Resilience/CircuitBreaker.cs +++ b/Brainarr.Plugin/Resilience/CircuitBreaker.cs @@ -79,7 +79,8 @@ public CircuitBreaker( int failureThreshold = 3, int openDurationSeconds = 60, int timeoutSeconds = 30, - Logger logger = null) + Logger logger = null, + TimeProvider timeProvider = null) { _timeout = TimeSpan.FromSeconds(timeoutSeconds); _logger = logger ?? LogManager.GetCurrentClassLogger(); @@ -92,11 +93,12 @@ public CircuitBreaker( SuccessThresholdInHalfOpen = 1 }; - // Create inner circuit breaker with NLog adapter + // Create inner circuit breaker with NLog adapter and optional TimeProvider _inner = new CommonResilience.CircuitBreaker( $"brainarr-{Guid.NewGuid():N}", options, - new NLogAdapter(_logger)); + new NLogAdapter(_logger), + timeProvider); } /// diff --git a/Brainarr.Tests/Brainarr.Tests.csproj b/Brainarr.Tests/Brainarr.Tests.csproj index 0e74384a..d606baf4 100644 --- a/Brainarr.Tests/Brainarr.Tests.csproj +++ b/Brainarr.Tests/Brainarr.Tests.csproj @@ -49,6 +49,9 @@ PluginPackagingDisable=true + + PluginPackagingDisable=true + diff --git a/Brainarr.Tests/Resilience/CircuitBreakerTests.cs b/Brainarr.Tests/Resilience/CircuitBreakerTests.cs index 1055d34f..f3bf78f3 100644 --- a/Brainarr.Tests/Resilience/CircuitBreakerTests.cs +++ b/Brainarr.Tests/Resilience/CircuitBreakerTests.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using FluentAssertions; +using Lidarr.Plugin.Common.TestKit.Testing; using NLog; using NzbDrone.Core.ImportLists.Brainarr.Resilience; using Xunit; @@ -159,11 +160,14 @@ await cb.ExecuteAsync( [Fact] public async Task CircuitBreaker_transitions_to_half_open_after_duration() { + // Use FakeTimeProvider for deterministic time control (no flaky Task.Delay) + var fakeTime = new FakeTimeProvider(); var cb = new CircuitBreaker( failureThreshold: 1, openDurationSeconds: 1, // 1 second for test timeoutSeconds: 30, - logger: L); + logger: L, + timeProvider: fakeTime); // Open the circuit try @@ -176,8 +180,8 @@ await cb.ExecuteAsync( cb.State.Should().Be(CircuitState.Open); - // Wait for open duration to pass - await Task.Delay(1500); // Wait 1.5 seconds + // Advance fake time past open duration (deterministic, instant) + fakeTime.Advance(TimeSpan.FromSeconds(1.5)); // Next call should find circuit in half-open and succeed var result = await cb.ExecuteAsync( @@ -198,9 +202,9 @@ public async Task Timeout_opens_circuit_and_reset_allows_success() // Immediate timeout and open var cb = new CircuitBreaker(failureThreshold: 1, openDurationSeconds: 1, timeoutSeconds: 3, logger: L); - // First call times out -> opens + // First call times out -> opens (simulated timeout; no real delay required) await Assert.ThrowsAsync(async () => - await cb.ExecuteAsync(async () => { await Task.Delay(4000); return 1; }, "slow") + await cb.ExecuteAsync(() => Task.FromException(new TimeoutException("Simulated timeout")), "slow") ); cb.State.Should().Be(CircuitState.Open); cb.FailureCount.Should().BeGreaterThan(0); @@ -215,20 +219,22 @@ await Assert.ThrowsAsync(async () => [Fact] public async Task Half_open_failure_reopens_circuit() { - var cb = new CircuitBreaker(failureThreshold: 1, openDurationSeconds: 1, timeoutSeconds: 1, logger: L); + // Use FakeTimeProvider for open duration transitions (deterministic) + var fakeTime = new FakeTimeProvider(); + var cb = new CircuitBreaker(failureThreshold: 1, openDurationSeconds: 1, timeoutSeconds: 1, logger: L, timeProvider: fakeTime); - // Open + // Open circuit via timeout (simulated timeout; no real delay required) await Assert.ThrowsAsync(async () => - await cb.ExecuteAsync(async () => { await Task.Delay(2000); return 1; }, "slow") + await cb.ExecuteAsync(() => Task.FromException(new TimeoutException("Simulated timeout")), "slow") ); cb.State.Should().Be(CircuitState.Open); - // Wait for open duration to pass so circuit transitions to half-open - await Task.Delay(1100); + // Advance fake time past open duration (instant, deterministic) + fakeTime.Advance(TimeSpan.FromSeconds(1.1)); - // Half-open attempt fails with timeout -> should re-open + // Half-open attempt fails with timeout -> should re-open (simulated timeout; no real delay required) await Assert.ThrowsAsync(async () => - await cb.ExecuteAsync(async () => { await Task.Delay(2000); return 1; }, "still-slow") + await cb.ExecuteAsync(() => Task.FromException(new TimeoutException("Simulated timeout")), "still-slow") ); cb.State.Should().Be(CircuitState.Open); } diff --git a/Brainarr.Tests/packages.lock.json b/Brainarr.Tests/packages.lock.json index cf5bd1ba..6d545bf3 100644 --- a/Brainarr.Tests/packages.lock.json +++ b/Brainarr.Tests/packages.lock.json @@ -317,12 +317,12 @@ }, "Microsoft.Extensions.Logging": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "4x+pzsQEbqxhNf1QYRr5TDkLP9UsLT3A6MdRKDDEgrW7h1ljiEPgTNhKYUhNCCAaVpQECVQ+onA91PTPnIp6Lw==", + "resolved": "9.0.0", + "contentHash": "crjWyORoug0kK7RSNJBTeSE6VX8IQgLf3nUpTB9m62bPXp/tzbnOsnbe8TXEG0AASNaKZddnpHKw7fET8E++Pg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection": "8.0.1", - "Microsoft.Extensions.Logging.Abstractions": "8.0.2", - "Microsoft.Extensions.Options": "8.0.2" + "Microsoft.Extensions.DependencyInjection": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0" } }, "Microsoft.Extensions.Logging.Configuration": { @@ -414,6 +414,14 @@ "resolved": "6.0.0", "contentHash": "hqTM5628jSsQiv+HGpiq3WKBl2c8v1KZfby2J6Pr7pEPlK9waPdgEO6b8A/+/xn/yZ9ulv8HuqK71ONy2tg67A==" }, + "NSubstitute": { + "type": "Transitive", + "resolved": "5.3.0", + "contentHash": "lJ47Cps5Qzr86N99lcwd+OUvQma7+fBgr8+Mn+aOC0WrlqMNkdivaYD9IvnZ5Mqo6Ky3LS7ZI+tUq1/s9ERd0Q==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, "System.ClientModel": { "type": "Transitive", "resolved": "1.4.2", @@ -451,11 +459,6 @@ "resolved": "1.6.0", "contentHash": "COC1aiAJjCoA5GBF+QKL2uLqEBew4JsCkQmoHKbN3TlOZKa2fKLz5CpiRQKDz0RsAOEGsVKqOD5bomsXq/4STQ==" }, - "System.Runtime.CompilerServices.Unsafe": { - "type": "Transitive", - "resolved": "6.0.0", - "contentHash": "/iUeP3tq1S0XdNNoMz5C9twLSrM/TH+qElHkXWaPvuNOt+99G75NrV0OS2EqHx5wMN7popYjpc8oTjC1y16DLg==" - }, "System.Security.AccessControl": { "type": "Transitive", "resolved": "6.0.0", @@ -563,7 +566,7 @@ "Lidarr.Plugin.Brainarr": { "type": "Project", "dependencies": { - "Lidarr.Plugin.Common": "[1.4.0, )", + "Lidarr.Plugin.Common": "[1.5.0, )", "Microsoft.Extensions.Caching.Memory": "[8.0.1, )", "Microsoft.Extensions.DependencyInjection": "[8.0.1, )", "Newtonsoft.Json": "[13.0.4, )", @@ -576,7 +579,7 @@ "Azure.Extensions.AspNetCore.DataProtection.Keys": "[1.6.1, )", "Azure.Identity": "[1.12.0, )", "FluentValidation": "[9.5.4, )", - "Lidarr.Plugin.Abstractions": "[1.4.0, )", + "Lidarr.Plugin.Abstractions": "[1.5.0, )", "Microsoft.AspNetCore.DataProtection": "[8.0.16, )", "Microsoft.AspNetCore.DataProtection.Extensions": "[8.0.16, )", "Microsoft.Extensions.Caching.Memory": "[8.0.1, )", @@ -592,6 +595,18 @@ "TagLibSharp": "[2.3.0, )" } }, + "lidarr.plugin.common.testkit": { + "type": "Project", + "dependencies": { + "Lidarr.Plugin.Abstractions": "[1.5.0, )", + "Lidarr.Plugin.Common": "[1.5.0, )", + "Microsoft.Extensions.DependencyInjection": "[9.0.0, )", + "Microsoft.Extensions.Logging": "[9.0.0, )", + "Microsoft.Extensions.Logging.Abstractions": "[9.0.0, )", + "Moq": "[4.20.72, )", + "NSubstitute": "[5.3.0, )" + } + }, "FluentValidation": { "type": "CentralTransitive", "requested": "[11.11.0, )", @@ -623,51 +638,49 @@ "Microsoft.Extensions.DependencyInjection": { "type": "CentralTransitive", "requested": "[8.0.1, )", - "resolved": "8.0.1", - "contentHash": "BmANAnR5Xd4Oqw7yQ75xOAYODybZQRzdeNucg7kS5wWKd2PNnMdYtJ2Vciy0QLylRmv42DGl5+AFL9izA6F1Rw==", + "resolved": "9.0.0", + "contentHash": "MCPrg7v3QgNMr0vX4vzRXvkNGgLg8vKWX0nKCWUxu2uPyMsaRgiRc1tHBnbTcfJMhMKj2slE/j2M9oGkd25DNw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0" } }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "CentralTransitive", "requested": "[8.0.2, )", - "resolved": "8.0.2", - "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + "resolved": "9.0.0", + "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==" }, "Microsoft.Extensions.Logging.Abstractions": { "type": "CentralTransitive", "requested": "[7.0.0, )", - "resolved": "8.0.3", - "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "resolved": "9.0.0", + "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "System.Diagnostics.DiagnosticSource": "9.0.0" } }, "Microsoft.Extensions.Options": { "type": "CentralTransitive", "requested": "[7.0.0, )", - "resolved": "8.0.2", - "contentHash": "dWGKvhFybsaZpGmzkGCbNNwBD1rVlWzrZKANLW/CcbFJpCEceMCGzT7zZwHOGBCbwM0SzBuceMj5HN1LKV1QqA==", + "resolved": "9.0.0", + "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Primitives": { "type": "CentralTransitive", "requested": "[7.0.0, )", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.0", + "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==" }, "System.Diagnostics.DiagnosticSource": { "type": "CentralTransitive", "requested": "[7.0.2, )", - "resolved": "6.0.1", - "contentHash": "KiLYDu2k2J82Q9BJpWiuQqCkFjRBWVq4jDzKKWawVi9KWzyD0XG3cmfX0vqTQlL14Wi9EufJrbL0+KCLTbqWiQ==", - "dependencies": { - "System.Runtime.CompilerServices.Unsafe": "6.0.0" - } + "resolved": "9.0.0", + "contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw==" }, "System.Security.Cryptography.ProtectedData": { "type": "CentralTransitive", diff --git a/NuGet.config b/NuGet.config index e888f23a..7a3e6e4a 100644 --- a/NuGet.config +++ b/NuGet.config @@ -27,6 +27,7 @@ + diff --git a/ext-common-sha.txt b/ext-common-sha.txt index 239bfe6f..4214b801 100644 --- a/ext-common-sha.txt +++ b/ext-common-sha.txt @@ -1 +1 @@ -9ea7dcd9006006dbafc856493cd98b25f3dd986b +e1fd02e15f7e94bdc8d83c245691a1f8ae07cb65 diff --git a/ext/lidarr.plugin.common b/ext/lidarr.plugin.common index 9ea7dcd9..e1fd02e1 160000 --- a/ext/lidarr.plugin.common +++ b/ext/lidarr.plugin.common @@ -1 +1 @@ -Subproject commit 9ea7dcd9006006dbafc856493cd98b25f3dd986b +Subproject commit e1fd02e15f7e94bdc8d83c245691a1f8ae07cb65