using Cleanuparr.Infrastructure.Features.LazyLibrarian; using Cleanuparr.Domain.Entities.Arr; using Cleanuparr.Domain.Entities.Arr.Queue; using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Events; 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; using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; using Cleanuparr.Infrastructure.Features.Files; using Cleanuparr.Infrastructure.Features.ItemStriker; using Cleanuparr.Infrastructure.Features.Jobs; using Cleanuparr.Infrastructure.Features.MalwareBlocker; using Cleanuparr.Infrastructure.Features.Notifications; using Cleanuparr.Infrastructure.Realtime; using Cleanuparr.Infrastructure.Interceptors; using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; using Cleanuparr.Infrastructure.Tests.TestHelpers; using Cleanuparr.Persistence; using Cleanuparr.Persistence.Models.Configuration; using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Persistence.Models.State; using Cleanuparr.Persistence.Providers; using System.Threading.Channels; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Time.Testing; using NSubstitute; using Cleanuparr.Infrastructure.Features.Arr.ForceImport; namespace Cleanuparr.Infrastructure.Tests.Features.Jobs.Integration; /// /// Shared fixture for integration tests that wires up real services (EventPublisher, QueueItemRemover) /// with NSubstitute mocks at external boundaries (Arr clients, download clients, notifications). /// public class IntegrationTestFixture : IDisposable { // Real services public DataContext DataContext { get; private set; } public EventsContext EventsContext { get; private set; } public MemoryCache Cache { get; private set; } public EventPublisher EventPublisher { get; private set; } = null!; public QueueItemRemover QueueItemRemover { get; private set; } = null!; public Striker Striker { get; private set; } = null!; public FakeTimeProvider TimeProvider { get; private set; } // Mocks public Channel RemovalQueue { get; private set; } public IArrClientFactory ArrClientFactory { get; private set; } public IArrClient ArrClient { get; private set; } public IArrQueueReader ArrQueueReader { get; private set; } public IDownloadServiceFactory DownloadServiceFactory { get; private set; } public IBlocklistProvider BlocklistProvider { get; private set; } public IHardLinkFileService HardLinkFileService { get; private set; } public INotificationPublisher NotificationPublisher { get; private set; } public IDryRunInterceptor DryRunInterceptor { get; private set; } public ILazyLibrarianService LazyLibrarianService { get; private set; } public IForceImportService ForceImportService { get; private set; } public ILazyLibrarianEvaluator LazyLibrarianServiceQC { get; private set; } public ILazyLibrarianEvaluator LazyLibrarianServiceCB { get; private set; } public IEventPublisher EventPublisherInterface { get; private set; } = null!; public IEventNotifier EventNotifier { get; private set; } public IStatusNotifier StatusNotifier { get; private set; } public ISeedingRulesCleanupService SeedingRulesService { get; private set; } = null!; public IUnlinkedDownloadsService UnlinkedService { get; private set; } = null!; public IDeadTorrentService DeadTorrentService { get; private set; } = null!; public IOrphanedFilesCleanupService OrphanedFilesService { get; private set; } = null!; // State public Guid JobRunId { get; private set; } public List CapturedMessages { get; } = []; public IntegrationTestFixture() { SubstituteHelper.ClearPendingArgSpecs(); DataContext = TestDataContextFactory.Create(); EventsContext = TestEventsContextFactory.Create(); Cache = new MemoryCache(new MemoryCacheOptions()); TimeProvider = new FakeTimeProvider(); RemovalQueue = Channel.CreateUnbounded(); ArrClientFactory = Substitute.For(); ArrClient = Substitute.For(); ArrQueueReader = Substitute.For(); DownloadServiceFactory = Substitute.For(); BlocklistProvider = Substitute.For(); HardLinkFileService = Substitute.For(); NotificationPublisher = Substitute.For(); DryRunInterceptor = Substitute.For(); LazyLibrarianService = Substitute.For(); ForceImportService = Substitute.For(); LazyLibrarianServiceQC = Substitute.For(); LazyLibrarianServiceCB = Substitute.For(); EventNotifier = Substitute.For(); StatusNotifier = Substitute.For(); SetupDefaults(); BuildRealServices(); } private void SetupDefaults() { // ArrClientFactory returns the shared ArrClient mock by default ArrClientFactory.GetClient(default, default).ReturnsForAnyArgs(ArrClient); // DryRunInterceptor returns false (not dry run) by default DryRunInterceptor.IsDryRunEnabled().Returns(false); DryRunInterceptor.InterceptAsync(Arg.Any>(), Arg.Any()).ReturnsForAnyArgs(Task.CompletedTask); // Seed a JobRun so EventPublisher FK constraints are satisfied JobRunId = Guid.NewGuid(); EventsContext.JobRuns.Add(new JobRun { Id = JobRunId, Type = JobType.QueueCleaner }); EventsContext.SaveChanges(); ContextProvider.SetJobRunId(JobRunId); } private void BuildRealServices() { EventPublisher = new EventPublisher( EventsContext, EventNotifier, Substitute.For>(), NotificationPublisher, DryRunInterceptor, // The wall clock, not the fixture's fake: event timestamps still come from entity initializers. new SqliteDatabaseProvider(), System.TimeProvider.System); // Expose EventPublisher as both concrete and interface EventPublisherInterface = EventPublisher; Striker = new Striker( Substitute.For>(), EventsContext, EventPublisher, DryRunInterceptor); QueueItemRemover = new QueueItemRemover( Substitute.For>(), Cache, ArrClientFactory, EventPublisher, EventsContext, DataContext, LazyLibrarianService); SeedingRulesService = new SeedingRulesCleanupService( Substitute.For>(), DataContext); UnlinkedService = new UnlinkedDownloadsService( Substitute.For>(), DataContext, HardLinkFileService); DeadTorrentService = new DeadTorrentService( Substitute.For>(), DataContext, TimeProvider, Striker); OrphanedFilesService = new OrphanedFilesCleanupService( Substitute.For>(), DataContext, TimeProvider, DryRunInterceptor); } public List GetCapturedRemoveRequests() { while (RemovalQueue.Reader.TryRead(out QueueItemRemoveRequest? request)) { CapturedMessages.Add(request); } return CapturedMessages .OfType() .DistinctBy(r => r.Target.DownloadId) .ToList(); } /// /// Runs the captured messages through the real QueueItemRemover, as the consumer would. /// public async Task ProcessCapturedRemoveRequestsAsync() { foreach (QueueItemRemoveRequest request in GetCapturedRemoveRequests()) { await QueueItemRemover.RemoveQueueItemAsync(request); } } /// /// Configures the IArrQueueReader to return the given records for any instance. /// public void SetupArrQueueReader(params QueueRecord[] records) { ArrQueueReader.ReadAllAsync(Arg.Any(), Arg.Any()) .Returns(records.ToList()); } /// /// Creates a NSubstitute IDownloadService mock with default configuration. /// public IMockDownloadService CreateMockDownloadService( string clientName = "Test qBittorrent", DownloadClientTypeName typeName = DownloadClientTypeName.qBittorrent, DownloadClientType type = DownloadClientType.Torrent) { var mock = Substitute.For(); mock.ClientConfig.Returns(new DownloadClientConfig { Id = Guid.NewGuid(), Name = clientName, TypeName = typeName, Type = type, Enabled = true, Host = new Uri("http://localhost:8080"), Username = "admin", Password = "admin" }); mock.LoginAsync().Returns(Task.CompletedTask); return mock; } /// /// Registers mock download services with the factory, matched by their ClientConfig. /// public void SetupDownloadServices(params IDownloadService[] services) { foreach (var service in services) { DownloadServiceFactory.GetDownloadService(service.ClientConfig).Returns(service); } } /// /// Recreates DataContext, EventsContext, cache, and resets all mocks for a clean test. /// public void Reset() { SubstituteHelper.ClearPendingArgSpecs(); DataContext?.Dispose(); EventsContext?.Dispose(); Cache?.Dispose(); DataContext = TestDataContextFactory.Create(); EventsContext = TestEventsContextFactory.Create(); Cache = new MemoryCache(new MemoryCacheOptions()); TimeProvider = new FakeTimeProvider(); CapturedMessages.Clear(); // Recreate all NSubstitute mocks to clear received call state RemovalQueue = Channel.CreateUnbounded(); ArrClientFactory = Substitute.For(); ArrClient = Substitute.For(); ArrQueueReader = Substitute.For(); DownloadServiceFactory = Substitute.For(); BlocklistProvider = Substitute.For(); ForceImportService = Substitute.For(); HardLinkFileService = Substitute.For(); NotificationPublisher = Substitute.For(); DryRunInterceptor = Substitute.For(); EventNotifier = Substitute.For(); StatusNotifier = Substitute.For(); // Re-setup defaults and rebuild real services SetupDefaults(); BuildRealServices(); // Clear static state Striker.RecurringHashes.Clear(); } public void Dispose() { DataContext?.Dispose(); EventsContext?.Dispose(); Cache?.Dispose(); Striker.RecurringHashes.Clear(); GC.SuppressFinalize(this); } }