diff --git a/CLAUDE.md b/CLAUDE.md
index 616ec93e..e2d7666d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,7 +38,7 @@ Cleanuparr is a tool for automating the cleanup of unwanted or blocked files in
- **Database**: SQLite with Entity Framework Core
- Three separate contexts: `DataContext`, `EventsContext`, `UsersContext`
- **Key Libraries**:
- - MassTransit (messaging)
+ - System.Threading.Channels (in-process queues)
- Quartz.NET (scheduling)
- Serilog (logging)
- SignalR (real-time communication)
diff --git a/code/backend/Cleanuparr.Api.Tests/Json/RemovalRequestSerializationTests.cs b/code/backend/Cleanuparr.Api.Tests/Json/RemovalRequestSerializationTests.cs
deleted file mode 100644
index 25295628..00000000
--- a/code/backend/Cleanuparr.Api.Tests/Json/RemovalRequestSerializationTests.cs
+++ /dev/null
@@ -1,116 +0,0 @@
-using System.Text.Json;
-using Cleanuparr.Api.Json;
-using Cleanuparr.Domain.Entities.Arr;
-using Cleanuparr.Domain.Entities.Arr.Queue;
-using Cleanuparr.Domain.Enums;
-using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
-using Cleanuparr.Persistence.Models.Configuration.Arr;
-using Shouldly;
-using Xunit;
-
-namespace Cleanuparr.Api.Tests.Json;
-
-///
-/// The in-memory bus serializes with these options, so a broken discriminator
-/// only shows up at runtime.
-///
-public class RemovalRequestSerializationTests
-{
- private static readonly JsonSerializerOptions Options = CreateOptions();
-
- private static JsonSerializerOptions CreateOptions()
- {
- JsonSerializerOptions options = new();
- CleanuparrJsonConfiguration.ConfigureCore(options);
- return options;
- }
-
- private static QueueItemRemoveRequest CreateRequest(SearchItem searchItem)
- {
- return new QueueItemRemoveRequest
- {
- Instance = new ArrInstance
- {
- Name = "Test Instance",
- Url = new Uri("http://sonarr.local"),
- ApiKey = "test-api-key",
- ArrConfig = new ArrConfig { Type = InstanceType.Sonarr },
- },
- Target = new ArrRemovalTarget
- {
- Record = new QueueRecord
- {
- Id = 1,
- Title = "Test Record",
- Protocol = "torrent",
- DownloadId = "ABC123",
- },
- SearchItem = searchItem,
- RemoveFromClient = true,
- ChangeCategory = false,
- },
- DeleteReason = DeleteReason.Stalled,
- JobRunId = Guid.NewGuid(),
- IsDryRun = false,
- };
- }
-
- private static QueueItemRemoveRequest RoundTrip(QueueItemRemoveRequest request)
- {
- string json = JsonSerializer.Serialize(request, Options);
- return JsonSerializer.Deserialize(json, Options)!;
- }
-
- [Fact]
- public void ArrTarget_SurvivesRoundTrip()
- {
- QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SearchItem { Id = 42 }));
-
- ArrRemovalTarget target = result.Target.ShouldBeOfType();
- target.Record.DownloadId.ShouldBe("ABC123");
- target.RemoveFromClient.ShouldBeTrue();
- result.DeleteReason.ShouldBe(DeleteReason.Stalled);
- }
-
- [Fact]
- public void BaseSearchItem_SurvivesRoundTrip()
- {
- QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SearchItem { Id = 42 }));
-
- SearchItem item = ArrTargetOf(result).SearchItem;
- item.ShouldBeOfType();
- item.Id.ShouldBe(42);
- }
-
- [Fact]
- public void SeriesSearchItem_KeepsItsDerivedType()
- {
- QueueItemRemoveRequest result = RoundTrip(CreateRequest(new SeriesSearchItem
- {
- Id = 100,
- SeriesId = 10,
- SearchType = SeriesSearchType.Episode,
- }));
-
- SeriesSearchItem item = ArrTargetOf(result).SearchItem.ShouldBeOfType();
- item.Id.ShouldBe(100);
- item.SeriesId.ShouldBe(10);
- item.SearchType.ShouldBe(SeriesSearchType.Episode);
- }
-
- [Fact]
- public void Target_CarriesTheDiscriminator_AndOmitsDerivedMembers()
- {
- string json = JsonSerializer.Serialize(CreateRequest(new SearchItem { Id = 42 }), Options);
-
- using JsonDocument document = JsonDocument.Parse(json);
- JsonElement target = document.RootElement.GetProperty("Target");
-
- target.GetProperty("$target").GetString().ShouldBe("arr");
- target.TryGetProperty("DownloadId", out _).ShouldBeFalse();
- target.TryGetProperty("Title", out _).ShouldBeFalse();
- }
-
- private static ArrRemovalTarget ArrTargetOf(QueueItemRemoveRequest request) =>
- (ArrRemovalTarget)request.Target;
-}
diff --git a/code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj b/code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj
index 751d4120..2dbeaeb0 100644
--- a/code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj
+++ b/code/backend/Cleanuparr.Api/Cleanuparr.Api.csproj
@@ -27,7 +27,6 @@
-
all
diff --git a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
index 9f9f0c2b..224cb1f8 100644
--- a/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
+++ b/code/backend/Cleanuparr.Api/DependencyInjection/MainDI.cs
@@ -1,12 +1,14 @@
-using Cleanuparr.Api.Json;
+using System.Threading.Channels;
using Cleanuparr.Domain.Entities.Arr;
using Cleanuparr.Infrastructure.Features.DownloadRemover.Consumers;
+using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
+using Cleanuparr.Infrastructure.Features.Messaging;
using Cleanuparr.Infrastructure.Features.Notifications.Consumers;
+using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Health;
using Cleanuparr.Infrastructure.Http;
using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem;
using Cleanuparr.Shared.Helpers;
-using MassTransit;
using Microsoft.Extensions.Caching.Memory;
namespace Cleanuparr.Api.DependencyInjection;
@@ -22,38 +24,25 @@ public static class MainDI
.AddHealthServices()
.AddQuartzServices(configuration)
.AddNotifications()
- .AddMassTransit(config =>
- {
- config.DisableUsageTelemetry();
-
- config.AddConsumer();
- config.AddConsumer();
+ .AddMessageQueue()
+ .AddMessageQueue();
- config.UsingInMemory((context, cfg) =>
- {
- cfg.ConfigureJsonSerializerOptions(options =>
- {
- CleanuparrJsonConfiguration.ConfigureCore(options);
+ ///
+ /// Registers an unbounded in-process queue for ,
+ /// drained one message at a time by a .
+ ///
+ private static IServiceCollection AddMessageQueue(this IServiceCollection services)
+ where THandler : class, IMessageHandler
+ {
+ Channel channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true });
+
+ return services
+ .AddSingleton(channel)
+ .AddSingleton(channel.Writer)
+ .AddScoped, THandler>()
+ .AddHostedService>();
+ }
- return options;
- });
-
- cfg.ReceiveEndpoint("download-remover-queue", e =>
- {
- e.ConfigureConsumer(context);
- e.ConcurrentMessageLimit = 1;
- e.PrefetchCount = 1;
- });
-
- cfg.ReceiveEndpoint("notification-queue", e =>
- {
- e.ConfigureConsumer(context);
- e.ConcurrentMessageLimit = 1;
- e.PrefetchCount = 1;
- });
- });
- });
-
private static IServiceCollection AddHttpClients(this IServiceCollection services, IConfiguration configuration)
{
// Add the dynamic HTTP client system - this replaces all the previous static configurations
diff --git a/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs b/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs
index 1d48ce49..68fd31c7 100644
--- a/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs
+++ b/code/backend/Cleanuparr.Api/Json/CleanuparrJsonConfiguration.cs
@@ -7,7 +7,7 @@ namespace Cleanuparr.Api.Json;
public static class CleanuparrJsonConfiguration
{
- public static void ConfigureCore(JsonSerializerOptions options)
+ private static void ConfigureCore(JsonSerializerOptions options)
{
options.PropertyNameCaseInsensitive = true;
options.Converters.Add(new JsonStringEnumConverter());
diff --git a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchItem.cs b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchItem.cs
index 0183994c..193a339c 100644
--- a/code/backend/Cleanuparr.Domain/Entities/Arr/SearchItem.cs
+++ b/code/backend/Cleanuparr.Domain/Entities/Arr/SearchItem.cs
@@ -1,10 +1,5 @@
-using System.Text.Json.Serialization;
+namespace Cleanuparr.Domain.Entities.Arr;
-namespace Cleanuparr.Domain.Entities.Arr;
-
-[JsonPolymorphic(TypeDiscriminatorPropertyName = "$item")]
-[JsonDerivedType(typeof(SearchItem), "base")]
-[JsonDerivedType(typeof(SeriesSearchItem), "series")]
public class SearchItem
{
public long Id { get; set; }
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs
index a3939f8e..d5b0226a 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadRemover/Consumers/DownloadRemoverConsumerTests.cs
@@ -8,7 +8,6 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.DryRun;
using Cleanuparr.Infrastructure.Tests.TestHelpers;
using Cleanuparr.Persistence.Models.Configuration.Arr;
-using MassTransit;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
@@ -32,50 +31,47 @@ public class DownloadRemoverConsumerTests
_consumer = new DownloadRemoverConsumer(_logger, _queueItemRemover, _dryRunActivity);
}
- #region Consume Tests
+ #region HandleAsync Tests
[Fact]
- public async Task Consume_CallsRemoveQueueItemAsync()
+ public async Task HandleAsync_CallsRemoveQueueItemAsync()
{
// Arrange
var request = CreateRemoveRequest();
- var context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.Returns(Task.CompletedTask);
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
await _queueItemRemover.Received(1).RemoveQueueItemAsync(request);
}
[Fact]
- public async Task Consume_WhenRemoverThrows_LogsErrorAndDoesNotRethrow()
+ public async Task HandleAsync_WhenRemoverThrows_LogsErrorAndDoesNotRethrow()
{
// Arrange
var request = CreateRemoveRequest();
- var context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.ThrowsAsync(new Exception("Remove failed"));
// Act - Should not throw
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
_logger.HasLogContaining(LogLevel.Error, "failed to remove queue item").ShouldBeTrue();
}
[Fact]
- public async Task Consume_PassesCorrectRequestToRemover()
+ public async Task HandleAsync_PassesCorrectRequestToRemover()
{
// Arrange
var request = CreateRemoveRequest();
- var context = CreateConsumeContext(request);
QueueItemRemoveRequest? capturedRequest = null;
_queueItemRemover
@@ -84,7 +80,7 @@ public class DownloadRemoverConsumerTests
.AndDoes(ci => capturedRequest = ci.Arg());
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
capturedRequest.ShouldNotBeNull();
@@ -95,11 +91,10 @@ public class DownloadRemoverConsumerTests
}
[Fact]
- public async Task Consume_WithDryRunRequest_SetsStickyDryRunBeforeRemoving()
+ public async Task HandleAsync_WithDryRunRequest_SetsStickyDryRunBeforeRemoving()
{
// Arrange
var request = CreateRemoveRequest() with { IsDryRun = true };
- var context = CreateConsumeContext(request);
bool observedDryRun = false;
_queueItemRemover
@@ -108,43 +103,41 @@ public class DownloadRemoverConsumerTests
.AndDoes(_ => observedDryRun = ContextProvider.IsDryRunSticky());
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
observedDryRun.ShouldBeTrue();
}
[Fact]
- public async Task Consume_WithDryRunRequest_RequestsAPurgeAfterRemoving()
+ public async Task HandleAsync_WithDryRunRequest_RequestsAPurgeAfterRemoving()
{
// Arrange
var request = CreateRemoveRequest() with { IsDryRun = true };
- var context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.Returns(Task.CompletedTask);
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
(await _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None)).ShouldBeTrue();
}
[Fact]
- public async Task Consume_WithLiveRequest_RequestsNoPurge()
+ public async Task HandleAsync_WithLiveRequest_RequestsNoPurge()
{
// Arrange
var request = CreateRemoveRequest() with { IsDryRun = false };
- var context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.Returns(Task.CompletedTask);
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
Task waitTask = _dryRunActivity.WaitForPurgeRequestAsync(CancellationToken.None).AsTask();
@@ -153,29 +146,27 @@ public class DownloadRemoverConsumerTests
}
[Fact]
- public async Task Consume_WithDryRunRequest_WhenRemoverThrows_ExitsActivity()
+ public async Task HandleAsync_WithDryRunRequest_WhenRemoverThrows_ExitsActivity()
{
// Arrange
QueueItemRemoveRequest request = CreateRemoveRequest() with { IsDryRun = true };
- ConsumeContext context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.ThrowsAsync(new Exception("Remove failed"));
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
_dryRunActivity.IsActive.ShouldBeFalse();
}
[Fact]
- public async Task Consume_WithLiveRequest_NeverEntersActivity()
+ public async Task HandleAsync_WithLiveRequest_NeverEntersActivity()
{
// Arrange
QueueItemRemoveRequest request = CreateRemoveRequest() with { IsDryRun = false };
- ConsumeContext context = CreateConsumeContext(request);
bool activeDuringRemoval = false;
_queueItemRemover
@@ -184,7 +175,7 @@ public class DownloadRemoverConsumerTests
.AndDoes(_ => activeDuringRemoval = _dryRunActivity.IsActive);
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
activeDuringRemoval.ShouldBeFalse();
@@ -192,7 +183,7 @@ public class DownloadRemoverConsumerTests
}
[Fact]
- public async Task Consume_WithRemoveFromClientTrue_PassesCorrectly()
+ public async Task HandleAsync_WithRemoveFromClientTrue_PassesCorrectly()
{
// Arrange
var request = new QueueItemRemoveRequest
@@ -208,14 +199,13 @@ public class DownloadRemoverConsumerTests
JobRunId = Guid.NewGuid(),
IsDryRun = false,
};
- var context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.Returns(Task.CompletedTask);
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
await _queueItemRemover.Received(1).RemoveQueueItemAsync(
@@ -225,7 +215,7 @@ public class DownloadRemoverConsumerTests
}
[Fact]
- public async Task Consume_WithDifferentDeleteReasons_HandlesCorrectly()
+ public async Task HandleAsync_WithDifferentDeleteReasons_HandlesCorrectly()
{
// Arrange
var request = new QueueItemRemoveRequest
@@ -241,14 +231,13 @@ public class DownloadRemoverConsumerTests
JobRunId = Guid.NewGuid(),
IsDryRun = false,
};
- var context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.Returns(Task.CompletedTask);
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
await _queueItemRemover.Received(1).RemoveQueueItemAsync(
@@ -257,7 +246,7 @@ public class DownloadRemoverConsumerTests
}
[Fact]
- public async Task Consume_WithDifferentInstanceTypes_HandlesCorrectly()
+ public async Task HandleAsync_WithDifferentInstanceTypes_HandlesCorrectly()
{
// Arrange
var request = new QueueItemRemoveRequest
@@ -273,14 +262,13 @@ public class DownloadRemoverConsumerTests
JobRunId = Guid.NewGuid(),
IsDryRun = false,
};
- var context = CreateConsumeContext(request);
_queueItemRemover
.RemoveQueueItemAsync(Arg.Any())
.Returns(Task.CompletedTask);
// Act
- await _consumer.Consume(context);
+ await _consumer.HandleAsync(request);
// Assert
await _queueItemRemover.Received(1).RemoveQueueItemAsync(
@@ -330,12 +318,6 @@ public class DownloadRemoverConsumerTests
};
}
- private static ConsumeContext CreateConsumeContext(QueueItemRemoveRequest message)
- {
- var context = Substitute.For>();
- context.Message.Returns(message);
- return context;
- }
#endregion
}
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs
index 2ede54c0..c151b047 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerOrphanedFilesTests.cs
@@ -47,7 +47,7 @@ public sealed class DownloadCleanerOrphanedFilesTests : IDisposable
_logger,
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs
index f31040d1..9602eb31 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/DownloadCleanerTests.cs
@@ -45,7 +45,7 @@ public class DownloadCleanerTests : IDisposable
_logger,
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs
index 4bb175a0..dea6eaf2 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs
@@ -10,6 +10,7 @@ using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Helpers;
using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers;
+using Cleanuparr.Infrastructure.Tests.TestHelpers;
using Cleanuparr.Persistence;
using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Events;
@@ -18,7 +19,7 @@ using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner;
using Cleanuparr.Persistence.Models.Configuration.General;
using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker;
using Cleanuparr.Persistence.Models.Configuration.QueueCleaner;
-using MassTransit;
+using System.Threading.Channels;
using Microsoft.EntityFrameworkCore;
using Cleanuparr.Infrastructure.Interceptors;
using Microsoft.Extensions.Caching.Memory;
@@ -43,7 +44,7 @@ public class GenericHandlerTests : IClassFixture
Substitute.For>(),
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
@@ -293,7 +294,7 @@ public class GenericHandlerTests : IClassFixture
DeleteReason.FailedImport);
// Assert
- await _fixture.MessageBus.DidNotReceiveWithAnyArgs().Publish(default(object)!, default(CancellationToken));
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(0);
}
[Fact]
@@ -316,8 +317,7 @@ public class GenericHandlerTests : IClassFixture
instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport);
// Assert
- await _fixture.MessageBus.Received(1)
- .Publish(Arg.Any(), Arg.Any());
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(1);
await _fixture.EventPublisher.Received(1).PublishAsync(
EventType.DownloadMarkedForDeletion, Arg.Any(), Arg.Any(),
Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any());
@@ -344,8 +344,8 @@ public class GenericHandlerTests : IClassFixture
instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport);
// Assert
- await _fixture.MessageBus.Received(1)
- .Publish(Arg.Is(r => r.IsDryRun), Arg.Any());
+ QueueItemRemoveRequest request = _fixture.RemovalQueue.ShouldHaveSingle();
+ request.IsDryRun.ShouldBeTrue();
}
[Fact]
@@ -368,8 +368,7 @@ public class GenericHandlerTests : IClassFixture
instance, record, isPack: false, removeFromClient: false, DeleteReason.Stalled);
// Assert
- await _fixture.MessageBus.Received(1)
- .Publish(Arg.Any(), Arg.Any());
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(1);
}
[Fact]
@@ -392,8 +391,7 @@ public class GenericHandlerTests : IClassFixture
instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport);
// Assert
- await _fixture.MessageBus.Received(1)
- .Publish(Arg.Any(), Arg.Any());
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(1);
}
[Fact]
@@ -411,45 +409,32 @@ public class GenericHandlerTests : IClassFixture
};
QueueRecord record = NewRecord(seriesId: 1, episodeId: 2);
string key = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
- bool markedWhenPublished = false;
- _fixture.MessageBus
- .When(bus => bus.Publish(Arg.Any(), Arg.Any()))
- .Do(_ => markedWhenPublished = _fixture.Cache.TryGetValue(key, out bool _));
+ MarkOrderSpyChannelWriter spyWriter = new(
+ _fixture.RemovalQueue.Writer,
+ () => _fixture.Cache.TryGetValue(key, out bool _));
+
+ TestHandler handler = new(
+ Substitute.For>(),
+ _fixture.DataContext,
+ _fixture.Cache,
+ spyWriter,
+ _fixture.ArrClientFactory,
+ _fixture.ArrQueueReader,
+ _fixture.DownloadServiceFactory,
+ _fixture.EventPublisher,
+ _fixture.DryRunInterceptor,
+ _fixture.ForceImportService);
// Act
- await _handler.PublicPublishQueueItemRemoveRequest(
+ await handler.PublicPublishQueueItemRemoveRequest(
instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport);
- // Assert
- markedWhenPublished.ShouldBeTrue();
- }
+ // Assert: the mark was already set at the moment of the write
+ spyWriter.MarkWasSetAtWrite.ShouldBe(true);
- [Fact]
- public async Task PublishQueueItemRemoveRequest_PublishThrows_RemovesMark()
- {
- // Arrange
- ArrConfig arrConfig = new() { Type = InstanceType.Sonarr, Instances = [] };
- ArrInstance instance = new()
- {
- Name = "s",
- Url = new Uri("http://s"),
- ApiKey = "k",
- ArrConfig = arrConfig,
- Version = 4f,
- };
- QueueRecord record = NewRecord(seriesId: 1, episodeId: 2);
- string key = CacheKeys.DownloadMarkedForRemoval(record.DownloadId, instance.Url);
-
- _fixture.MessageBus
- .Publish(Arg.Any(), Arg.Any())
- .Returns(Task.FromException(new InvalidOperationException("bus is down")));
-
- // Act & Assert
- await Should.ThrowAsync(() => _handler.PublicPublishQueueItemRemoveRequest(
- instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport));
-
- _fixture.Cache.TryGetValue(key, out bool _).ShouldBeFalse();
+ // Assert: a successful write leaves the mark set afterwards too
+ _fixture.Cache.TryGetValue(key, out bool _).ShouldBeTrue();
}
[Fact]
@@ -475,32 +460,6 @@ public class GenericHandlerTests : IClassFixture
_fixture.ForceImportService.Received(1).Forget(instance, record.DownloadId);
}
- [Fact]
- public async Task PublishQueueItemRemoveRequest_PublishThrows_KeepsThePendingForceImport()
- {
- // Arrange
- ArrConfig arrConfig = new() { Type = InstanceType.Sonarr, Instances = [] };
- ArrInstance instance = new()
- {
- Name = "s",
- Url = new Uri("http://s"),
- ApiKey = "k",
- ArrConfig = arrConfig,
- Version = 4f,
- };
- QueueRecord record = NewRecord(seriesId: 1, episodeId: 2);
-
- _fixture.MessageBus
- .Publish(Arg.Any(), Arg.Any())
- .Returns(Task.FromException(new InvalidOperationException("bus is down")));
-
- // Act & Assert: nothing was removed, so a pending import still stands
- await Should.ThrowAsync(() => _handler.PublicPublishQueueItemRemoveRequest(
- instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport));
-
- _fixture.ForceImportService.DidNotReceive().Forget(Arg.Any(), Arg.Any());
- }
-
#endregion
#region GetInitializedDownloadServicesAsync
@@ -679,6 +638,32 @@ public class GenericHandlerTests : IClassFixture
};
}
+ ///
+ /// Wraps a real channel writer and records, at the moment of the write, whether the given probe is true.
+ ///
+ private sealed class MarkOrderSpyChannelWriter : ChannelWriter
+ {
+ private readonly ChannelWriter _inner;
+ private readonly Func _probe;
+
+ public bool? MarkWasSetAtWrite { get; private set; }
+
+ public MarkOrderSpyChannelWriter(ChannelWriter inner, Func probe)
+ {
+ _inner = inner;
+ _probe = probe;
+ }
+
+ public override bool TryWrite(QueueItemRemoveRequest item)
+ {
+ MarkWasSetAtWrite = _probe();
+ return _inner.TryWrite(item);
+ }
+
+ public override ValueTask WaitToWriteAsync(CancellationToken cancellationToken = default) =>
+ _inner.WaitToWriteAsync(cancellationToken);
+ }
+
///
/// Concrete GenericHandler subclass exposing protected members for testing.
///
@@ -706,14 +691,14 @@ public class GenericHandlerTests : IClassFixture
ILogger logger,
DataContext dataContext,
IMemoryCache cache,
- IBus messageBus,
+ ChannelWriter removalQueue,
IArrClientFactory arrClientFactory,
IArrQueueReader arrQueueReader,
IDownloadServiceFactory downloadServiceFactory,
IEventPublisher eventPublisher,
IDryRunInterceptor dryRunInterceptor,
IForceImportService forceImportService)
- : base(logger, dataContext, cache, messageBus, arrClientFactory, arrQueueReader, downloadServiceFactory, eventPublisher, dryRunInterceptor, forceImportService)
+ : base(logger, dataContext, cache, removalQueue, arrClientFactory, arrQueueReader, downloadServiceFactory, eventPublisher, dryRunInterceptor, forceImportService)
{
}
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs
index 7c499b30..df21cf0c 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/DownloadCleanerIntegrationTests.cs
@@ -51,7 +51,7 @@ public class DownloadCleanerIntegrationTests : IDisposable
Substitute.For>(),
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs
index 1b2f8500..8d77dd9f 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/IntegrationTestFixture.cs
@@ -24,7 +24,7 @@ using Cleanuparr.Persistence.Models.Configuration;
using Cleanuparr.Persistence.Models.Configuration.Arr;
using Cleanuparr.Persistence.Models.State;
using Cleanuparr.Persistence.Providers;
-using MassTransit;
+using System.Threading.Channels;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
@@ -49,7 +49,7 @@ public class IntegrationTestFixture : IDisposable
public FakeTimeProvider TimeProvider { get; private set; }
// Mocks
- public IBus MessageBus { get; private set; }
+ public Channel RemovalQueue { get; private set; }
public IArrClientFactory ArrClientFactory { get; private set; }
public IArrClient ArrClient { get; private set; }
public IArrQueueReader ArrQueueReader { get; private set; }
@@ -83,7 +83,7 @@ public class IntegrationTestFixture : IDisposable
Cache = new MemoryCache(new MemoryCacheOptions());
TimeProvider = new FakeTimeProvider();
- MessageBus = Substitute.For();
+ RemovalQueue = Channel.CreateUnbounded();
ArrClientFactory = Substitute.For();
ArrClient = Substitute.For();
ArrQueueReader = Substitute.For();
@@ -112,10 +112,6 @@ public class IntegrationTestFixture : IDisposable
DryRunInterceptor.IsDryRunEnabled().Returns(false);
DryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()).ReturnsForAnyArgs(Task.CompletedTask);
- MessageBus.Publish(default(QueueItemRemoveRequest)!, default)
- .ReturnsForAnyArgs(Task.CompletedTask)
- .AndDoes(ci => CapturedMessages.Add(ci[0]));
-
// Seed a JobRun so EventPublisher FK constraints are satisfied
JobRunId = Guid.NewGuid();
EventsContext.JobRuns.Add(new JobRun { Id = JobRunId, Type = JobType.QueueCleaner });
@@ -174,6 +170,11 @@ public class IntegrationTestFixture : IDisposable
public List GetCapturedRemoveRequests()
{
+ while (RemovalQueue.Reader.TryRead(out QueueItemRemoveRequest? request))
+ {
+ CapturedMessages.Add(request);
+ }
+
return CapturedMessages
.OfType()
.DistinctBy(r => r.Target.DownloadId)
@@ -252,7 +253,7 @@ public class IntegrationTestFixture : IDisposable
CapturedMessages.Clear();
// Recreate all NSubstitute mocks to clear received call state
- MessageBus = Substitute.For();
+ RemovalQueue = Channel.CreateUnbounded();
ArrClientFactory = Substitute.For();
ArrClient = Substitute.For();
ArrQueueReader = Substitute.For();
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs
index 393765a8..d20b04f6 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/MalwareBlockerIntegrationTests.cs
@@ -39,7 +39,7 @@ public class MalwareBlockerIntegrationTests : IDisposable
Substitute.For>(),
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs
index b16f9b1c..905181a1 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/Integration/QueueCleanerIntegrationTests.cs
@@ -42,7 +42,7 @@ public class QueueCleanerIntegrationTests : IDisposable
Substitute.For>(),
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
@@ -84,7 +84,7 @@ public class QueueCleanerIntegrationTests : IDisposable
// Act
await sut.ExecuteAsync();
- // Assert Phase 1: IBus received a remove request
+ // Assert Phase 1: removal queue received a remove request
var removeRequests = _fixture.GetCapturedRemoveRequests();
removeRequests.Count.ShouldBe(1);
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs
index f3fb933c..646d6f16 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/MalwareBlockerTests.cs
@@ -50,7 +50,7 @@ public class MalwareBlockerTests : IDisposable
_logger,
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
@@ -371,12 +371,8 @@ public class MalwareBlockerTests : IDisposable
await sut.ExecuteAsync();
// Assert
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.DeleteReason == DeleteReason.AllFilesBlocked
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
[Fact]
@@ -492,12 +488,8 @@ public class MalwareBlockerTests : IDisposable
await sut.ExecuteAsync();
// Assert
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.DeleteReason == DeleteReason.AtLeastOneFileBlocked
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.DeleteReason.ShouldBe(DeleteReason.AtLeastOneFileBlocked);
}
[Fact]
@@ -602,12 +594,8 @@ public class MalwareBlockerTests : IDisposable
await sut.ExecuteAsync();
// Assert - RemoveFromClient should be false
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.ArrTarget().RemoveFromClient == false
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.ArrTarget().RemoveFromClient.ShouldBeFalse();
}
[Fact]
@@ -697,10 +685,7 @@ public class MalwareBlockerTests : IDisposable
// Assert
_logger.HasLogContaining(LogLevel.Information, "skip | item is missing the content id").ShouldBeTrue();
- await _fixture.MessageBus.DidNotReceive().Publish(
- Arg.Any(),
- Arg.Any()
- );
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(0);
}
[Fact]
@@ -759,13 +744,9 @@ public class MalwareBlockerTests : IDisposable
await sut.ExecuteAsync();
// Assert - SkipSearch must be true because the item has no content ID
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.SkipSearch == true &&
- r.DeleteReason == DeleteReason.AllFilesBlocked
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.SkipSearch.ShouldBeTrue();
+ r.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
}
#endregion
@@ -872,14 +853,10 @@ public class MalwareBlockerTests : IDisposable
// Assert
await downloadService.Received(1).DeleteDownload(torrent, true);
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.LazyTarget().RemovedFromClient
- && r.LazyTarget().Item.Books.Single().BookId == "OL7353617M"
- && r.DeleteReason == DeleteReason.AllFilesBlocked
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.LazyTarget().RemovedFromClient.ShouldBeTrue();
+ r.LazyTarget().Item.Books.Single().BookId.ShouldBe("OL7353617M");
+ r.DeleteReason.ShouldBe(DeleteReason.AllFilesBlocked);
_fixture.ArrClientFactory.DidNotReceiveWithAnyArgs().GetClient(default, default);
await _fixture.ArrQueueReader.DidNotReceiveWithAnyArgs()
.ReadAllAsync(default!, default!);
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs
index a554c760..3e56e989 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/QueueCleanerTests.cs
@@ -1,4 +1,5 @@
using Cleanuparr.Domain.Entities.Arr;
+using System.Threading.Channels;
using Cleanuparr.Infrastructure.Features.Arr.ForceImport;
using Cleanuparr.Infrastructure.Features.LazyLibrarian;
using Cleanuparr.Domain.Entities.LazyLibrarian;
@@ -54,7 +55,7 @@ public class QueueCleanerTests : IDisposable
_logger,
_fixture.DataContext,
_fixture.Cache,
- _fixture.MessageBus,
+ _fixture.RemovalQueue.Writer,
_fixture.ArrClientFactory,
_fixture.ArrQueueReader,
_fixture.DownloadServiceFactory,
@@ -432,10 +433,7 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Any(),
- Arg.Any()
- );
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(1);
}
[Fact]
@@ -862,12 +860,16 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert - the pack flag true branch builds a season SearchItem instead of an episode one
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.SeriesItem().SearchType == SeriesSearchType.Season &&
- r.SeriesItem().Id == 1
- ),
- Arg.Any()
+ List requests = [];
+
+ while (_fixture.RemovalQueue.Reader.TryRead(out QueueItemRemoveRequest? request))
+ {
+ requests.Add(request);
+ }
+
+ requests.ShouldContain(r =>
+ r.SeriesItem().SearchType == SeriesSearchType.Season &&
+ r.SeriesItem().Id == 1
);
}
@@ -924,12 +926,8 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.DeleteReason == DeleteReason.FailedImport
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.DeleteReason.ShouldBe(DeleteReason.FailedImport);
}
[Fact]
@@ -967,10 +965,7 @@ public class QueueCleanerTests : IDisposable
// Assert
_logger.HasLogContaining(LogLevel.Information, "skip | item is missing the content id").ShouldBeTrue();
- await _fixture.MessageBus.DidNotReceive().Publish(
- Arg.Any(),
- Arg.Any()
- );
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(0);
}
[Fact]
@@ -1035,13 +1030,9 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert - SkipSearch must be true because the item has no content ID
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.SkipSearch == true &&
- r.DeleteReason == DeleteReason.Stalled
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.SkipSearch.ShouldBeTrue();
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
#endregion
@@ -1175,10 +1166,7 @@ public class QueueCleanerTests : IDisposable
_logger.HasLogContaining(LogLevel.Debug, "skip removal request").ShouldBeTrue();
// Verify no publish was made
- await _fixture.MessageBus.DidNotReceive().Publish(
- Arg.Any(),
- Arg.Any()
- );
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(0);
}
[Fact]
@@ -1234,14 +1222,10 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest (not SeriesSearchItem)
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.Instance.ArrConfig.Type == InstanceType.Radarr &&
- r.ArrTarget().SearchItem.Id == 42 &&
- r.DeleteReason == DeleteReason.Stalled
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.Instance.ArrConfig.Type.ShouldBe(InstanceType.Radarr);
+ r.ArrTarget().SearchItem.Id.ShouldBe(42);
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
[Fact]
@@ -1297,14 +1281,10 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest with AlbumId
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.Instance.ArrConfig.Type == InstanceType.Lidarr &&
- r.ArrTarget().SearchItem.Id == 123 &&
- r.DeleteReason == DeleteReason.SlowSpeed
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.Instance.ArrConfig.Type.ShouldBe(InstanceType.Lidarr);
+ r.ArrTarget().SearchItem.Id.ShouldBe(123);
+ r.DeleteReason.ShouldBe(DeleteReason.SlowSpeed);
}
[Fact]
@@ -1360,14 +1340,10 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest with BookId
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.Instance.ArrConfig.Type == InstanceType.Readarr &&
- r.ArrTarget().SearchItem.Id == 456 &&
- r.DeleteReason == DeleteReason.Stalled
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.Instance.ArrConfig.Type.ShouldBe(InstanceType.Readarr);
+ r.ArrTarget().SearchItem.Id.ShouldBe(456);
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
[Fact]
@@ -1424,16 +1400,12 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
- r.ArrTarget().SearchItem.Id == 100 && // EpisodeId
- r.SeriesItem().SeriesId == 10 &&
- r.SeriesItem().SearchType == SeriesSearchType.Episode &&
- r.DeleteReason == DeleteReason.Stalled
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.Instance.ArrConfig.Type.ShouldBe(InstanceType.Whisparr);
+ r.ArrTarget().SearchItem.Id.ShouldBe(100); // EpisodeId
+ r.SeriesItem().SeriesId.ShouldBe(10);
+ r.SeriesItem().SearchType.ShouldBe(SeriesSearchType.Episode);
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
[Fact]
@@ -1489,14 +1461,10 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert - should publish QueueItemRemoveRequest with MovieId
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
- r.ArrTarget().SearchItem.Id == 42 && // MovieId
- r.DeleteReason == DeleteReason.Stalled
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.Instance.ArrConfig.Type.ShouldBe(InstanceType.Whisparr);
+ r.ArrTarget().SearchItem.Id.ShouldBe(42); // MovieId
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
[Fact]
@@ -1566,16 +1534,12 @@ public class QueueCleanerTests : IDisposable
// Assert - should publish QueueItemRemoveRequest with Season search type
// because multiple records with the same download ID indicate a pack
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.Instance.ArrConfig.Type == InstanceType.Whisparr &&
- r.ArrTarget().SearchItem.Id == 3 && // SeasonNumber
- r.SeriesItem().SeriesId == 10 &&
- r.SeriesItem().SearchType == SeriesSearchType.Season &&
- r.DeleteReason == DeleteReason.Stalled
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.Instance.ArrConfig.Type.ShouldBe(InstanceType.Whisparr);
+ r.ArrTarget().SearchItem.Id.ShouldBe(3); // SeasonNumber
+ r.SeriesItem().SeriesId.ShouldBe(10);
+ r.SeriesItem().SearchType.ShouldBe(SeriesSearchType.Season);
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
#endregion
@@ -1646,14 +1610,10 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.DeleteReason == DeleteReason.FailedImport &&
- r.ArrTarget().ChangeCategory == true &&
- r.ArrTarget().RemoveFromClient == false
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.DeleteReason.ShouldBe(DeleteReason.FailedImport);
+ r.ArrTarget().ChangeCategory.ShouldBeTrue();
+ r.ArrTarget().RemoveFromClient.ShouldBeFalse();
}
[Fact]
@@ -1711,14 +1671,10 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.DeleteReason == DeleteReason.Stalled &&
- r.ArrTarget().ChangeCategory == true &&
- r.ArrTarget().RemoveFromClient == false
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
+ r.ArrTarget().ChangeCategory.ShouldBeTrue();
+ r.ArrTarget().RemoveFromClient.ShouldBeFalse();
}
#endregion
@@ -1788,14 +1744,10 @@ public class QueueCleanerTests : IDisposable
// Assert
await downloadService.Received(1).DeleteDownload(torrent, true);
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- r.LazyTarget().RemovedFromClient
- && r.LazyTarget().Item.Books.Single().BookId == "OL7353617M"
- && r.DeleteReason == DeleteReason.Stalled
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.LazyTarget().RemovedFromClient.ShouldBeTrue();
+ r.LazyTarget().Item.Books.Single().BookId.ShouldBe("OL7353617M");
+ r.DeleteReason.ShouldBe(DeleteReason.Stalled);
}
[Fact]
@@ -1811,12 +1763,8 @@ public class QueueCleanerTests : IDisposable
// Assert
await downloadService.DidNotReceive().DeleteDownload(Arg.Any(), Arg.Any());
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r =>
- !r.LazyTarget().RemovedFromClient
- ),
- Arg.Any()
- );
+ QueueItemRemoveRequest r = _fixture.RemovalQueue.ShouldHaveSingle();
+ r.LazyTarget().RemovedFromClient.ShouldBeFalse();
}
[Fact]
@@ -1834,63 +1782,7 @@ public class QueueCleanerTests : IDisposable
await sut.ExecuteAsync();
// Assert
- await _fixture.MessageBus.DidNotReceive().Publish(
- Arg.Any(),
- Arg.Any()
- );
- }
-
- [Fact]
- public async Task ProcessInstanceAsync_LazyLibrarian_KeepsGoingWhenOneRemovalCannotBePublished()
- {
- // Arrange: the first torrent is already deleted, so a failed publish must not skip the second book.
- (IDownloadService downloadService, ITorrentItemWrapper torrent) = StubLazyLibrarianDecision();
- LazyLibrarianQueueItem second = CreateBookItem() with { DownloadId = "torrent-hash-2" };
-
- ITorrentItemWrapper secondTorrent = Substitute.For();
- secondTorrent.Hash.Returns("torrent-hash-2");
-
- DownloadClientConfig clientConfig = downloadService.ClientConfig;
- IReadOnlyList decisions =
- [
- new LazyLibrarianRemovalDecision
- {
- Item = CreateBookItem(),
- DeleteReason = DeleteReason.Stalled,
- RemoveFromClient = true,
- DownloadClient = clientConfig,
- DownloadService = downloadService,
- Torrent = torrent,
- },
- new LazyLibrarianRemovalDecision
- {
- Item = second,
- DeleteReason = DeleteReason.Stalled,
- RemoveFromClient = true,
- DownloadClient = clientConfig,
- DownloadService = downloadService,
- Torrent = secondTorrent,
- },
- ];
-
- _fixture.LazyLibrarianServiceQC
- .EvaluateAsync(Arg.Any(), Arg.Any>(), Arg.Any>())
- .Returns(decisions);
-
- _fixture.MessageBus
- .Publish(Arg.Is(r => r.Target.DownloadId == "torrent-hash"), Arg.Any())
- .Returns(Task.FromException(new Exception("bus is down")));
-
- var sut = CreateSut();
-
- // Act
- await sut.ExecuteAsync();
-
- // Assert
- await _fixture.MessageBus.Received(1).Publish(
- Arg.Is(r => r.Target.DownloadId == "torrent-hash-2"),
- Arg.Any()
- );
+ _fixture.RemovalQueue.Reader.Count.ShouldBe(0);
}
[Fact]
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs
index 278498b0..0c4e4a04 100644
--- a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/TestHelpers/JobHandlerFixture.cs
@@ -1,15 +1,16 @@
-using Cleanuparr.Infrastructure.Events.Interfaces;
+using System.Threading.Channels;
+using Cleanuparr.Infrastructure.Events.Interfaces;
using Cleanuparr.Infrastructure.Features.Arr.Interfaces;
using Cleanuparr.Infrastructure.Features.Context;
using Cleanuparr.Infrastructure.Features.DownloadCleaner.Services;
using Cleanuparr.Infrastructure.Features.DownloadClient;
+using Cleanuparr.Infrastructure.Features.DownloadRemover.Models;
using Cleanuparr.Infrastructure.Features.Files;
using Cleanuparr.Infrastructure.Features.Jobs;
using Cleanuparr.Infrastructure.Features.LazyLibrarian;
using Cleanuparr.Infrastructure.Features.MalwareBlocker;
using Cleanuparr.Infrastructure.Interceptors;
using Cleanuparr.Persistence;
-using MassTransit;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Time.Testing;
@@ -27,7 +28,7 @@ public class JobHandlerFixture : IDisposable
public DataContext DataContext { get; private set; }
public EventsContext EventsContext { get; private set; }
public MemoryCache Cache { get; private set; }
- public IBus MessageBus { get; private set; }
+ public Channel RemovalQueue { get; private set; }
public IArrClientFactory ArrClientFactory { get; private set; }
public IArrQueueReader ArrQueueReader { get; private set; }
public IDownloadServiceFactory DownloadServiceFactory { get; private set; }
@@ -55,7 +56,7 @@ public class JobHandlerFixture : IDisposable
DataContext = TestDataContextFactory.Create();
EventsContext = TestDataContextFactory.CreateEvents();
Cache = new MemoryCache(new MemoryCacheOptions());
- MessageBus = Substitute.For();
+ RemovalQueue = Channel.CreateUnbounded();
ArrClientFactory = Substitute.For();
ArrQueueReader = Substitute.For();
DownloadServiceFactory = Substitute.For();
@@ -160,7 +161,7 @@ public class JobHandlerFixture : IDisposable
{
SubstituteHelper.ClearPendingArgSpecs();
// Recreate all substitutes to clear received call state
- MessageBus = Substitute.For();
+ RemovalQueue = Channel.CreateUnbounded();
ArrClientFactory = Substitute.For();
ArrQueueReader = Substitute.For();
DownloadServiceFactory = Substitute.For();
diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Messaging/QueueWorkerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Messaging/QueueWorkerTests.cs
new file mode 100644
index 00000000..f6d97aed
--- /dev/null
+++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Messaging/QueueWorkerTests.cs
@@ -0,0 +1,193 @@
+using System.Threading.Channels;
+using Cleanuparr.Infrastructure.Features.Context;
+using Cleanuparr.Infrastructure.Features.Messaging;
+using Cleanuparr.Infrastructure.Tests.TestHelpers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using Shouldly;
+using Xunit;
+
+namespace Cleanuparr.Infrastructure.Tests.Features.Messaging;
+
+public class QueueWorkerTests : IAsyncDisposable
+{
+ private const string ContextKey = "QueueWorkerTests.Value";
+
+ private readonly Channel _channel = Channel.CreateUnbounded();
+ private readonly Recorder _recorder = new();
+ private readonly ServiceProvider _provider;
+ private readonly ILogger> _logger;
+ private readonly QueueWorker _worker;
+
+ public QueueWorkerTests()
+ {
+ ServiceCollection services = new();
+ services.AddSingleton(_recorder);
+ services.AddScoped();
+ services.AddScoped, TestHandler>();
+ _provider = services.BuildServiceProvider();
+
+ _logger = Substitute.For>>();
+ _worker = new QueueWorker(_logger, _provider.GetRequiredService(), _channel);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _worker.StopAsync(CancellationToken.None);
+ await _provider.DisposeAsync();
+ }
+
+ [Fact]
+ public async Task ProcessesMessages_InFifoOrder()
+ {
+ await _worker.StartAsync(CancellationToken.None);
+
+ await _channel.Writer.WriteAsync(new TestMessage(1));
+ await _channel.Writer.WriteAsync(new TestMessage(2));
+ await _channel.Writer.WriteAsync(new TestMessage(3));
+
+ await WaitUntilAsync(() => _recorder.HandledOrder.Count == 3);
+
+ _recorder.HandledOrder.ShouldBe([1, 2, 3]);
+ }
+
+ [Fact]
+ public async Task NeverRunsTwoHandlersAtOnce()
+ {
+ _recorder.Gate = new TaskCompletionSource();
+ await _worker.StartAsync(CancellationToken.None);
+
+ await _channel.Writer.WriteAsync(new TestMessage(1));
+ await _channel.Writer.WriteAsync(new TestMessage(2));
+
+ await WaitUntilAsync(() => Volatile.Read(ref _recorder.Concurrent) == 1);
+ // The second message must still be waiting behind the first.
+ _recorder.HandledOrder.ShouldBeEmpty();
+
+ _recorder.Gate.SetResult();
+ await WaitUntilAsync(() => _recorder.HandledOrder.Count == 2);
+
+ _recorder.MaxConcurrent.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task KeepsRunning_AfterAHandlerThrows_AndLogsTheError()
+ {
+ _recorder.ThrowIds.Add(1);
+ await _worker.StartAsync(CancellationToken.None);
+
+ await _channel.Writer.WriteAsync(new TestMessage(1));
+ await _channel.Writer.WriteAsync(new TestMessage(2));
+
+ await WaitUntilAsync(() => _recorder.HandledOrder.Count == 2);
+
+ _logger.HasLogContainingAtLeastOnce(LogLevel.Error, "failed to handle queued message").ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task CreatesANewScopePerMessage()
+ {
+ await _worker.StartAsync(CancellationToken.None);
+
+ await _channel.Writer.WriteAsync(new TestMessage(1));
+ await _channel.Writer.WriteAsync(new TestMessage(2));
+
+ await WaitUntilAsync(() => _recorder.ScopeIds.Count == 2);
+
+ _recorder.ScopeIds[0].ShouldNotBe(_recorder.ScopeIds[1]);
+ }
+
+ [Fact]
+ public async Task DoesNotLeak_ContextProviderValues_BetweenMessages()
+ {
+ await _worker.StartAsync(CancellationToken.None);
+
+ await _channel.Writer.WriteAsync(new TestMessage(1));
+ await _channel.Writer.WriteAsync(new TestMessage(2));
+
+ await WaitUntilAsync(() => _recorder.HandledOrder.Count == 2);
+
+ // Neither message should observe the value the previous message set.
+ _recorder.ObservedContextAtStart.ShouldAllBe(value => value == null);
+ }
+
+ [Fact]
+ public async Task Stops_WhenTheStoppingTokenCancels()
+ {
+ await _worker.StartAsync(CancellationToken.None);
+
+ await Should.NotThrowAsync(() => _worker.StopAsync(CancellationToken.None));
+ }
+
+ private static async Task WaitUntilAsync(Func condition)
+ {
+ DateTime deadline = DateTime.UtcNow.AddSeconds(5);
+
+ while (!condition() && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(10);
+ }
+
+ condition().ShouldBeTrue("condition was not met within the timeout");
+ }
+
+ public sealed record TestMessage(int Id);
+
+ private sealed class ScopedMarker
+ {
+ public readonly Guid Id = Guid.NewGuid();
+ }
+
+ private sealed class Recorder
+ {
+ public readonly List HandledOrder = [];
+ public readonly List