diff --git a/code/backend/Cleanuparr.Api.Tests/Features/Arr/ArrConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/Arr/ArrConfigControllerTests.cs new file mode 100644 index 00000000..13997a42 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/Arr/ArrConfigControllerTests.cs @@ -0,0 +1,361 @@ +using Cleanuparr.Api.Features.Arr.Contracts.Requests; +using Cleanuparr.Api.Features.Arr.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr.Dtos; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.Arr; + +public class ArrConfigControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly IArrClientFactory _arrClientFactory; + private readonly IArrClient _arrClient; + private readonly ArrConfigController _controller; + + public ArrConfigControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _arrClientFactory = Substitute.For(); + _arrClient = Substitute.For(); + _arrClientFactory.GetClient(Arg.Any(), Arg.Any()).Returns(_arrClient); + _controller = new ArrConfigController(logger, _dataContext, _arrClientFactory); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + #region GET configs + + [Theory] + [InlineData(InstanceType.Sonarr)] + [InlineData(InstanceType.Radarr)] + [InlineData(InstanceType.Lidarr)] + [InlineData(InstanceType.Readarr)] + [InlineData(InstanceType.Whisparr)] + public async Task GetArrConfig_AllTypes_ReturnOk(InstanceType type) + { + // Act + var result = await DispatchGet(type); + + // Assert + var ok = result.ShouldBeOfType(); + var dto = ok.Value.ShouldBeOfType(); + dto.Type.ShouldBe(type); + } + + [Fact] + public async Task GetSonarrConfig_OrdersInstancesByName() + { + // Arrange + var config = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr); + _dataContext.ArrInstances.AddRange( + new ArrInstance { Name = "z", Url = new Uri("http://z"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true }, + new ArrInstance { Name = "a", Url = new Uri("http://a"), ApiKey = "k", ArrConfigId = config.Id, Enabled = true }); + await _dataContext.SaveChangesAsync(); + + // Act + var result = await _controller.GetSonarrConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + var dto = ok.Value.ShouldBeOfType(); + dto.Instances[0].Name.ShouldBe("a"); + dto.Instances[1].Name.ShouldBe("z"); + } + + #endregion + + #region PUT configs + + [Fact] + public async Task UpdateSonarrConfig_PersistsFailedImportMaxStrikes() + { + // Arrange + var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = 7 }; + + // Act + var result = await _controller.UpdateSonarrConfig(request); + + // Assert + result.ShouldBeOfType(); + var saved = await _dataContext.ArrConfigs.AsNoTracking().FirstAsync(c => c.Type == InstanceType.Sonarr); + saved.FailedImportMaxStrikes.ShouldBe((short)7); + } + + [Fact] + public async Task UpdateSonarrConfig_DefaultStrikes_PassesThrough() + { + // ArrConfig.Validate is currently a no-op; -1 (the default disabled value) is accepted + var request = new UpdateArrConfigRequest { FailedImportMaxStrikes = -1 }; + + // Act + var result = await _controller.UpdateSonarrConfig(request); + + // Assert + result.ShouldBeOfType(); + } + + #endregion + + #region Create instance + + [Fact] + public async Task CreateSonarrInstance_PersistsInstanceUnderSonarrConfig() + { + // Arrange + var request = new ArrInstanceRequest + { + Name = "test", + Url = "http://sonarr.test:8989", + ApiKey = "abc", + Version = 4f, + }; + + // Act + var result = await _controller.CreateSonarrInstance(request); + + // Assert + var created = result.ShouldBeOfType(); + var dto = created.Value.ShouldBeOfType(); + dto.Name.ShouldBe("test"); + var sonarrConfig = await _dataContext.ArrConfigs + .Include(c => c.Instances) + .FirstAsync(c => c.Type == InstanceType.Sonarr); + sonarrConfig.Instances.ShouldContain(i => i.Name == "test"); + } + + [Fact] + public async Task CreateSonarrInstance_PlaceholderApiKey_ThrowsValidationException() + { + // Arrange — placeholder ApiKey is rejected by ArrInstanceRequest.ToEntity + var request = new ArrInstanceRequest + { + Name = "test", + Url = "http://sonarr.test:8989", + ApiKey = "••••••••", + Version = 4f, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.CreateSonarrInstance(request)); + } + + #endregion + + #region Update instance + + [Fact] + public async Task UpdateSonarrInstance_UnknownId_ReturnsNotFound() + { + // Arrange + var request = new ArrInstanceRequest + { + Name = "x", + Url = "http://x", + ApiKey = "k", + Version = 4f, + }; + + // Act + var result = await _controller.UpdateSonarrInstance(Guid.NewGuid(), request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Fact] + public async Task UpdateSonarrInstance_Existing_PersistsChanges() + { + // Arrange + var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr); + var instance = new ArrInstance + { + Name = "orig", + Url = new Uri("http://orig:8989"), + ApiKey = "k", + ArrConfigId = sonarr.Id, + Enabled = true, + }; + _dataContext.ArrInstances.Add(instance); + await _dataContext.SaveChangesAsync(); + + var request = new ArrInstanceRequest + { + Name = "renamed", + Url = "http://renamed:8989", + ApiKey = "newkey", + Version = 4f, + Enabled = false, + }; + + // Act + var result = await _controller.UpdateSonarrInstance(instance.Id, request); + + // Assert + result.ShouldBeOfType(); + var saved = await _dataContext.ArrInstances.AsNoTracking().FirstAsync(i => i.Id == instance.Id); + saved.Name.ShouldBe("renamed"); + saved.Enabled.ShouldBeFalse(); + saved.ApiKey.ShouldBe("newkey"); + } + + #endregion + + #region Delete instance + + [Fact] + public async Task DeleteSonarrInstance_UnknownId_ReturnsNotFound() + { + // Act + var result = await _controller.DeleteSonarrInstance(Guid.NewGuid()); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Fact] + public async Task DeleteSonarrInstance_Existing_ReturnsNoContent() + { + // Arrange + var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr); + var instance = new ArrInstance + { + Name = "doomed", + Url = new Uri("http://doomed:8989"), + ApiKey = "k", + ArrConfigId = sonarr.Id, + Enabled = true, + }; + _dataContext.ArrInstances.Add(instance); + await _dataContext.SaveChangesAsync(); + + // Act + var result = await _controller.DeleteSonarrInstance(instance.Id); + + // Assert + result.ShouldBeOfType(); + (await _dataContext.ArrInstances.CountAsync(i => i.Id == instance.Id)).ShouldBe(0); + } + + #endregion + + #region Test instance + + [Fact] + public async Task TestSonarrInstance_HealthCheckSucceeds_ReturnsOk() + { + // Arrange — IArrClient.HealthCheckAsync returns Task.CompletedTask by default + var request = new TestArrInstanceRequest + { + Url = "http://sonarr.test:8989", + ApiKey = "k", + Version = 4f, + }; + + // Act + var result = await _controller.TestSonarrInstance(request); + + // Assert + result.ShouldBeOfType(); + await _arrClient.Received(1).HealthCheckAsync(Arg.Any()); + } + + [Fact] + public async Task TestSonarrInstance_HealthCheckThrows_ReturnsBadRequest() + { + // Arrange + _arrClient.HealthCheckAsync(Arg.Any()) + .Returns(Task.FromException(new HttpRequestException("unreachable"))); + + var request = new TestArrInstanceRequest + { + Url = "http://sonarr.test:8989", + ApiKey = "k", + Version = 4f, + }; + + // Act + var result = await _controller.TestSonarrInstance(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + + [Fact] + public async Task TestSonarrInstance_PlaceholderApiKeyNoInstanceId_ReturnsBadRequest() + { + // Arrange — placeholder API key with no InstanceId means we can't resolve it; ToTestInstance throws + var request = new TestArrInstanceRequest + { + Url = "http://sonarr.test:8989", + ApiKey = "••••••••", + Version = 4f, + }; + + // Act + var result = await _controller.TestSonarrInstance(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + + [Fact] + public async Task TestSonarrInstance_PlaceholderApiKeyResolvesFromInstanceId_RunsCheck() + { + // Arrange + var sonarr = await _dataContext.ArrConfigs.FirstAsync(c => c.Type == InstanceType.Sonarr); + var stored = new ArrInstance + { + Name = "stored", + Url = new Uri("http://stored:8989"), + ApiKey = "stored-key", + ArrConfigId = sonarr.Id, + Enabled = true, + }; + _dataContext.ArrInstances.Add(stored); + await _dataContext.SaveChangesAsync(); + + var request = new TestArrInstanceRequest + { + Url = "http://sonarr.test:8989", + ApiKey = "••••••••", + Version = 4f, + InstanceId = stored.Id, + }; + + // Act + var result = await _controller.TestSonarrInstance(request); + + // Assert + result.ShouldBeOfType(); + await _arrClient.Received(1).HealthCheckAsync(Arg.Is(i => i.ApiKey == "stored-key")); + } + + #endregion + + private Task DispatchGet(InstanceType type) => type switch + { + InstanceType.Sonarr => _controller.GetSonarrConfig(), + InstanceType.Radarr => _controller.GetRadarrConfig(), + InstanceType.Lidarr => _controller.GetLidarrConfig(), + InstanceType.Readarr => _controller.GetReadarrConfig(), + InstanceType.Whisparr => _controller.GetWhisparrConfig(), + _ => throw new ArgumentOutOfRangeException(nameof(type)), + }; +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/BlacklistSync/BlacklistSyncConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/BlacklistSync/BlacklistSyncConfigControllerTests.cs new file mode 100644 index 00000000..a628b77a --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/BlacklistSync/BlacklistSyncConfigControllerTests.cs @@ -0,0 +1,174 @@ +using Cleanuparr.Api.Features.BlacklistSync.Contracts.Requests; +using Cleanuparr.Api.Features.BlacklistSync.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.BlacklistSync; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.BlacklistSync; + +public class BlacklistSyncConfigControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly IJobManagementService _jobManagementService; + private readonly BlacklistSyncConfigController _controller; + + public BlacklistSyncConfigControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _jobManagementService = Substitute.For(); + _controller = new BlacklistSyncConfigController(logger, _dataContext, _jobManagementService); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetBlacklistSyncConfig_ReturnsExistingConfig() + { + // Act + var result = await _controller.GetBlacklistSyncConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType(); + } + + [Fact] + public async Task UpdateBlacklistSyncConfig_BecameEnabled_StartsAndTriggersJob() + { + // Arrange — start disabled, enable with URL path + var request = new UpdateBlacklistSyncConfigRequest + { + Enabled = true, + BlacklistPath = "https://example.com/blacklist.txt", + }; + + // Act + var result = await _controller.UpdateBlacklistSyncConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StartJob(JobType.BlacklistSynchronizer, null, Arg.Any()); + await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer); + await _jobManagementService.DidNotReceive().StopJob(Arg.Any()); + } + + [Fact] + public async Task UpdateBlacklistSyncConfig_BecameDisabled_StopsJob() + { + // Arrange — pre-enable so the toggle to disabled hits the stop path + var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync(); + existing.Enabled = true; + existing.BlacklistPath = "https://example.com/blacklist.txt"; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateBlacklistSyncConfigRequest + { + Enabled = false, + BlacklistPath = existing.BlacklistPath, + }; + + // Act + var result = await _controller.UpdateBlacklistSyncConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StopJob(JobType.BlacklistSynchronizer); + await _jobManagementService.DidNotReceive().StartJob(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateBlacklistSyncConfig_StaysEnabledAndPathChanged_TriggersOnce() + { + // Arrange — pre-enable + var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync(); + existing.Enabled = true; + existing.BlacklistPath = "https://example.com/old.txt"; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateBlacklistSyncConfigRequest + { + Enabled = true, + BlacklistPath = "https://example.com/new.txt", + }; + + // Act + var result = await _controller.UpdateBlacklistSyncConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).TriggerJobOnce(JobType.BlacklistSynchronizer); + await _jobManagementService.DidNotReceive().StartJob(Arg.Any(), Arg.Any(), Arg.Any()); + await _jobManagementService.DidNotReceive().StopJob(Arg.Any()); + } + + [Fact] + public async Task UpdateBlacklistSyncConfig_StaysEnabledNoPathChange_NoJobOps() + { + // Arrange — pre-enable, then resubmit identical values + var existing = await _dataContext.BlacklistSyncConfigs.FirstAsync(); + existing.Enabled = true; + existing.BlacklistPath = "https://example.com/list.txt"; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateBlacklistSyncConfigRequest + { + Enabled = true, + BlacklistPath = existing.BlacklistPath, + }; + + // Act + var result = await _controller.UpdateBlacklistSyncConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.DidNotReceive().StartJob(Arg.Any(), Arg.Any(), Arg.Any()); + await _jobManagementService.DidNotReceive().StopJob(Arg.Any()); + await _jobManagementService.DidNotReceive().TriggerJobOnce(Arg.Any()); + } + + [Fact] + public async Task UpdateBlacklistSyncConfig_EnabledWithMissingPath_Throws() + { + // Arrange + var request = new UpdateBlacklistSyncConfigRequest + { + Enabled = true, + BlacklistPath = null, + }; + + // Act / Assert — Validate throws and is rethrown + await Should.ThrowAsync(() => _controller.UpdateBlacklistSyncConfig(request)); + } + + [Fact] + public async Task UpdateBlacklistSyncConfig_PersistsChangesToDatabase() + { + // Arrange + var request = new UpdateBlacklistSyncConfigRequest + { + Enabled = true, + BlacklistPath = "https://example.com/list.txt", + }; + + // Act + await _controller.UpdateBlacklistSyncConfig(request); + + // Assert + var saved = await _dataContext.BlacklistSyncConfigs.AsNoTracking().FirstAsync(); + saved.Enabled.ShouldBeTrue(); + saved.BlacklistPath.ShouldBe("https://example.com/list.txt"); + } +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DownloadCleanerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DownloadCleanerConfigControllerTests.cs new file mode 100644 index 00000000..80d477de --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/DownloadCleanerConfigControllerTests.cs @@ -0,0 +1,128 @@ +using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests; +using Cleanuparr.Api.Features.DownloadCleaner.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.DownloadCleaner; + +public class DownloadCleanerConfigControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly IJobManagementService _jobManagementService; + private readonly DownloadCleanerConfigController _controller; + + public DownloadCleanerConfigControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _jobManagementService = Substitute.For(); + _controller = new DownloadCleanerConfigController(logger, _dataContext, _jobManagementService); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetDownloadCleanerConfig_NoClients_ReturnsConfigWithEmptyClientsList() + { + // Act + var result = await _controller.GetDownloadCleanerConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + ok.Value.ShouldNotBeNull(); + } + + [Fact] + public async Task UpdateDownloadCleanerConfig_Enabled_StartsJob() + { + // Arrange + var request = new UpdateDownloadCleanerConfigRequest + { + Enabled = true, + CronExpression = "0 0 * * * ?", + IgnoredDownloads = new List(), + }; + + // Act + var result = await _controller.UpdateDownloadCleanerConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StartJob(JobType.DownloadCleaner, null, "0 0 * * * ?"); + } + + [Fact] + public async Task UpdateDownloadCleanerConfig_Disabled_StopsJob() + { + // Arrange — pre-enable + var existing = await _dataContext.DownloadCleanerConfigs.FirstAsync(); + existing.Enabled = true; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateDownloadCleanerConfigRequest + { + Enabled = false, + CronExpression = "0 0 * * * ?", + IgnoredDownloads = new List(), + }; + + // Act + var result = await _controller.UpdateDownloadCleanerConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StopJob(JobType.DownloadCleaner); + } + + [Fact] + public async Task UpdateDownloadCleanerConfig_InvalidCron_PropagatesValidationException() + { + // Arrange — controller's catch only handles System.ComponentModel.DataAnnotations.ValidationException; + // CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException which propagates. + var request = new UpdateDownloadCleanerConfigRequest + { + Enabled = true, + CronExpression = "not-a-cron", + IgnoredDownloads = new List(), + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.UpdateDownloadCleanerConfig(request)); + } + + [Fact] + public async Task UpdateDownloadCleanerConfig_PersistsChanges() + { + // Arrange + var request = new UpdateDownloadCleanerConfigRequest + { + Enabled = true, + CronExpression = "0 0/15 * * * ?", + UseAdvancedScheduling = true, + IgnoredDownloads = new List { "skip-me" }, + }; + + // Act + await _controller.UpdateDownloadCleanerConfig(request); + + // Assert + var saved = await _dataContext.DownloadCleanerConfigs.AsNoTracking().FirstAsync(); + saved.Enabled.ShouldBeTrue(); + saved.CronExpression.ShouldBe("0 0/15 * * * ?"); + saved.UseAdvancedScheduling.ShouldBeTrue(); + saved.IgnoredDownloads.ShouldContain("skip-me"); + } +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/UnlinkedConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/UnlinkedConfigControllerTests.cs new file mode 100644 index 00000000..c19df105 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadCleaner/UnlinkedConfigControllerTests.cs @@ -0,0 +1,200 @@ +using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Requests; +using Cleanuparr.Api.Features.DownloadCleaner.Contracts.Responses; +using Cleanuparr.Api.Features.DownloadCleaner.Controllers; +using Cleanuparr.Domain.Exceptions; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.DownloadCleaner; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.DownloadCleaner; + +public class UnlinkedConfigControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly UnlinkedConfigController _controller; + + public UnlinkedConfigControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _controller = new UnlinkedConfigController(logger, _dataContext); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetUnlinkedConfig_ClientNotFound_ReturnsNotFound() + { + // Act + var result = await _controller.GetUnlinkedConfig(Guid.NewGuid()); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Fact] + public async Task GetUnlinkedConfig_ClientWithoutConfig_ReturnsOkWithNull() + { + // Arrange — add a client but no UnlinkedConfig + var client = AddDownloadClient(); + + // Act + var result = await _controller.GetUnlinkedConfig(client.Id); + + // Assert + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeNull(); + } + + [Fact] + public async Task GetUnlinkedConfig_ClientWithConfig_ReturnsConfig() + { + // Arrange + var client = AddDownloadClient(); + _dataContext.UnlinkedConfigs.Add(new UnlinkedConfig + { + DownloadClientConfigId = client.Id, + Enabled = true, + TargetCategory = "unlinked-cat", + Categories = new List { "regular" }, + }); + await _dataContext.SaveChangesAsync(); + + // Act + var result = await _controller.GetUnlinkedConfig(client.Id); + + // Assert + var ok = result.ShouldBeOfType(); + var config = ok.Value.ShouldBeOfType(); + config.Enabled.ShouldBeTrue(); + config.TargetCategory.ShouldBe("unlinked-cat"); + } + + [Fact] + public async Task UpdateUnlinkedConfig_ClientNotFound_ReturnsNotFound() + { + // Arrange + var dto = new UnlinkedConfigRequest { Enabled = false }; + + // Act + var result = await _controller.UpdateUnlinkedConfig(Guid.NewGuid(), dto); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Fact] + public async Task UpdateUnlinkedConfig_NewConfig_CreatesAndReturnsIt() + { + // Arrange + var client = AddDownloadClient(); + var dto = new UnlinkedConfigRequest + { + Enabled = false, + TargetCategory = "unlinked-cat", + Categories = new List { "movies" }, + }; + + // Act + var result = await _controller.UpdateUnlinkedConfig(client.Id, dto); + + // Assert + var ok = result.ShouldBeOfType(); + var saved = await _dataContext.UnlinkedConfigs.AsNoTracking() + .FirstAsync(u => u.DownloadClientConfigId == client.Id); + saved.TargetCategory.ShouldBe("unlinked-cat"); + saved.Categories.ShouldContain("movies"); + } + + [Fact] + public async Task UpdateUnlinkedConfig_ExistingConfig_UpdatesInPlace() + { + // Arrange + var client = AddDownloadClient(); + _dataContext.UnlinkedConfigs.Add(new UnlinkedConfig + { + DownloadClientConfigId = client.Id, + Enabled = false, + TargetCategory = "old-cat", + }); + await _dataContext.SaveChangesAsync(); + + var dto = new UnlinkedConfigRequest + { + Enabled = false, + TargetCategory = "new-cat", + }; + + // Act + var result = await _controller.UpdateUnlinkedConfig(client.Id, dto); + + // Assert + result.ShouldBeOfType(); + var rows = await _dataContext.UnlinkedConfigs + .Where(u => u.DownloadClientConfigId == client.Id) + .ToListAsync(); + rows.Count.ShouldBe(1); + rows[0].TargetCategory.ShouldBe("new-cat"); + } + + [Fact] + public async Task UpdateUnlinkedConfig_EnabledButNoCategories_ReturnsBadRequest() + { + // Arrange — enabled requires at least one category per Validate() + var client = AddDownloadClient(); + var dto = new UnlinkedConfigRequest + { + Enabled = true, + TargetCategory = "unlinked-cat", + Categories = new List(), + }; + + // Act + Assert + await Should.ThrowAsync( + () => _controller.UpdateUnlinkedConfig(client.Id, dto)); + } + + [Fact] + public async Task UpdateUnlinkedConfig_TargetInCategories_ReturnsBadRequest() + { + // Arrange — TargetCategory must not appear in Categories + var client = AddDownloadClient(); + var dto = new UnlinkedConfigRequest + { + Enabled = true, + TargetCategory = "unlinked-cat", + Categories = new List { "movies", "unlinked-cat" }, + }; + + // Act + Assert + await Should.ThrowAsync( + () => _controller.UpdateUnlinkedConfig(client.Id, dto)); + } + + private DownloadClientConfig AddDownloadClient() + { + var client = new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = "test-client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + }; + _dataContext.DownloadClients.Add(client); + _dataContext.SaveChanges(); + return client; + } +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs new file mode 100644 index 00000000..0c3aa6a4 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/DownloadClient/DownloadClientControllerTests.cs @@ -0,0 +1,275 @@ +using Cleanuparr.Api.Features.DownloadClient.Contracts.Requests; +using Cleanuparr.Api.Features.DownloadClient.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Entities.HealthCheck; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.DownloadClient; + +public class DownloadClientControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory; + private readonly IDownloadServiceFactory _downloadServiceFactory; + private readonly DownloadClientController _controller; + + public DownloadClientControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _dynamicHttpClientFactory = Substitute.For(); + _downloadServiceFactory = Substitute.For(); + _controller = new DownloadClientController(logger, _dataContext, _dynamicHttpClientFactory, _downloadServiceFactory); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetDownloadClientConfig_EmptyDatabase_ReturnsOkWithEmptyClients() + { + // Act + var result = await _controller.GetDownloadClientConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + ok.Value.ShouldNotBeNull(); + } + + [Fact] + public async Task GetDownloadClientConfig_OrdersByTypeThenName() + { + // Arrange — add 3 clients out of order + _dataContext.DownloadClients.AddRange( + NewClient("z-client", DownloadClientTypeName.qBittorrent), + NewClient("a-client", DownloadClientTypeName.qBittorrent), + NewClient("b-client", DownloadClientTypeName.Deluge) + ); + await _dataContext.SaveChangesAsync(); + + // Act + var result = await _controller.GetDownloadClientConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + var dict = ok.Value!.GetType().GetProperty("clients")!.GetValue(ok.Value) as List; + dict.ShouldNotBeNull(); + dict!.Count.ShouldBe(3); + // qBittorrent (0) comes before Deluge (1) by enum value, then alphabetical within type + dict![0].Name.ShouldBe("a-client"); + dict![1].Name.ShouldBe("z-client"); + dict![2].Name.ShouldBe("b-client"); + } + + [Fact] + public async Task CreateDownloadClientConfig_ValidRequest_ReturnsCreated() + { + // Arrange + var request = new CreateDownloadClientRequest + { + Enabled = true, + Name = "my-client", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://localhost:8080", + Username = "user", + Password = "pass", + }; + + // Act + var result = await _controller.CreateDownloadClientConfig(request); + + // Assert + var created = result.ShouldBeOfType(); + var entity = created.Value.ShouldBeOfType(); + entity.Name.ShouldBe("my-client"); + (await _dataContext.DownloadClients.CountAsync()).ShouldBe(1); + } + + [Fact] + public async Task CreateDownloadClientConfig_InvalidHost_PropagatesValidationException() + { + // Arrange + var request = new CreateDownloadClientRequest + { + Name = "x", + Host = string.Empty, + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + }; + + // Act / Assert — Validate throws, controller's generic catch logs and re-throws + await Should.ThrowAsync( + () => _controller.CreateDownloadClientConfig(request)); + } + + [Fact] + public async Task UpdateDownloadClientConfig_UnknownId_ReturnsNotFound() + { + // Arrange + var request = new UpdateDownloadClientRequest + { + Name = "x", + Host = "http://localhost:8080", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + }; + + // Act + var result = await _controller.UpdateDownloadClientConfig(Guid.NewGuid(), request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Fact] + public async Task UpdateDownloadClientConfig_ExistingClient_PersistsChanges() + { + // Arrange + var client = NewClient("orig", DownloadClientTypeName.qBittorrent); + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + var request = new UpdateDownloadClientRequest + { + Enabled = true, + Name = "renamed", + Host = "http://newhost:9090", + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + }; + + // Act + var result = await _controller.UpdateDownloadClientConfig(client.Id, request); + + // Assert + result.ShouldBeOfType(); + var saved = await _dataContext.DownloadClients.AsNoTracking().FirstAsync(c => c.Id == client.Id); + saved.Name.ShouldBe("renamed"); + saved.Host!.ToString().ShouldContain("newhost"); + } + + [Fact] + public async Task DeleteDownloadClientConfig_ExistingClient_RemovesAndUnregistersHttpClient() + { + // Arrange + var client = NewClient("doomed", DownloadClientTypeName.qBittorrent); + _dataContext.DownloadClients.Add(client); + await _dataContext.SaveChangesAsync(); + + // Act + var result = await _controller.DeleteDownloadClientConfig(client.Id); + + // Assert + result.ShouldBeOfType(); + (await _dataContext.DownloadClients.CountAsync()).ShouldBe(0); + _dynamicHttpClientFactory.Received(1).UnregisterConfiguration($"DownloadClient_{client.Id}"); + } + + [Fact] + public async Task DeleteDownloadClientConfig_UnknownId_ReturnsNotFound() + { + // Act + var result = await _controller.DeleteDownloadClientConfig(Guid.NewGuid()); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + _dynamicHttpClientFactory.DidNotReceive().UnregisterConfiguration(Arg.Any()); + } + + [Fact] + public async Task TestDownloadClient_Healthy_ReturnsOkWithResponseTime() + { + // Arrange + var downloadService = Substitute.For(); + downloadService.HealthCheckAsync().Returns(new HealthCheckResult + { + IsHealthy = true, + ResponseTime = TimeSpan.FromMilliseconds(123), + }); + _downloadServiceFactory.GetDownloadService(Arg.Any()).Returns(downloadService); + + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://localhost:8080", + Password = "pass", + }; + + // Act + var result = await _controller.TestDownloadClient(request); + + // Assert + result.ShouldBeOfType(); + } + + [Fact] + public async Task TestDownloadClient_Unhealthy_ReturnsBadRequestWithMessage() + { + // Arrange + var downloadService = Substitute.For(); + downloadService.HealthCheckAsync().Returns(new HealthCheckResult + { + IsHealthy = false, + ErrorMessage = "connection refused", + }); + _downloadServiceFactory.GetDownloadService(Arg.Any()).Returns(downloadService); + + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = "http://localhost:8080", + Password = "pass", + }; + + // Act + var result = await _controller.TestDownloadClient(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + + [Fact] + public async Task TestDownloadClient_InvalidHost_ReturnsBadRequest() + { + // Arrange — empty host fails Validate; the controller wraps the exception in BadRequest + var request = new TestDownloadClientRequest + { + TypeName = DownloadClientTypeName.qBittorrent, + Type = DownloadClientType.Torrent, + Host = string.Empty, + Password = "pass", + }; + + // Act + var result = await _controller.TestDownloadClient(request); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + + private static DownloadClientConfig NewClient(string name, DownloadClientTypeName typeName) => new() + { + Id = Guid.NewGuid(), + Name = name, + TypeName = typeName, + Type = DownloadClientType.Torrent, + Host = new Uri("http://localhost:8080"), + }; +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs new file mode 100644 index 00000000..8b22e502 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/General/GeneralConfigControllerTests.cs @@ -0,0 +1,152 @@ +using Cleanuparr.Api.Features.General.Contracts.Requests; +using Cleanuparr.Api.Features.General.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.General; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.General; + +public class GeneralConfigControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly EventsContext _eventsContext; + private readonly IDynamicHttpClientFactory _dynamicHttpClientFactory; + private readonly GeneralConfigController _controller; + + public GeneralConfigControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + _eventsContext = ConfigControllerTestDataFactory.CreateEventsContext(); + _dynamicHttpClientFactory = Substitute.For(); + + var logger = Substitute.For>(); + _controller = new GeneralConfigController(logger, _dataContext); + + // Mount a DefaultHttpContext with a ServiceProvider that resolves IDynamicHttpClientFactory + var services = new ServiceCollection(); + services.AddSingleton(_dynamicHttpClientFactory); + _controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider() }, + }; + } + + public void Dispose() + { + _dataContext.Dispose(); + _eventsContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetGeneralConfig_ReturnsExistingConfig() + { + // Act + var result = await _controller.GetGeneralConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType(); + } + + [Fact] + public async Task UpdateGeneralConfig_PersistsChangesAndUpdatesHttpClients() + { + // Arrange — keep Log defaults matching DB so loggingChanged=false (avoid LoggingConfigManager statics) + var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync(); + var request = new UpdateGeneralConfigRequest + { + DisplaySupportBanner = false, + DryRun = false, + HttpMaxRetries = 5, + HttpTimeout = 60, + StatusCheckEnabled = false, + EncryptionKey = existing.EncryptionKey, + IgnoredDownloads = new List { "ignored-item" }, + StrikeInactivityWindowHours = 48, + Log = MatchingLogRequest(existing.Log), + Auth = new UpdateAuthConfigRequest(), + }; + + // Act + var result = await _controller.UpdateGeneralConfig(request, _eventsContext); + + // Assert + result.ShouldBeOfType(); + _dynamicHttpClientFactory.Received(1).UpdateAllClientsFromGeneralConfig(Arg.Any()); + + var saved = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync(); + saved.DisplaySupportBanner.ShouldBeFalse(); + saved.HttpMaxRetries.ShouldBe((ushort)5); + saved.HttpTimeout.ShouldBe((ushort)60); + saved.StrikeInactivityWindowHours.ShouldBe((ushort)48); + saved.IgnoredDownloads.ShouldContain("ignored-item"); + } + + [Fact] + public async Task UpdateGeneralConfig_InvalidHttpTimeout_Throws() + { + // Arrange — HttpTimeout=0 fails validation + var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync(); + var request = new UpdateGeneralConfigRequest + { + HttpTimeout = 0, + EncryptionKey = existing.EncryptionKey, + StrikeInactivityWindowHours = 24, + Log = MatchingLogRequest(existing.Log), + Auth = new UpdateAuthConfigRequest(), + }; + + // Act / Assert + await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request, _eventsContext)); + } + + [Fact] + public async Task UpdateGeneralConfig_InvalidStrikeWindow_Throws() + { + // Arrange — StrikeInactivityWindowHours > 168 fails validation + var existing = await _dataContext.GeneralConfigs.AsNoTracking().FirstAsync(); + var request = new UpdateGeneralConfigRequest + { + HttpTimeout = 60, + EncryptionKey = existing.EncryptionKey, + StrikeInactivityWindowHours = 200, + Log = MatchingLogRequest(existing.Log), + Auth = new UpdateAuthConfigRequest(), + }; + + // Act / Assert + await Should.ThrowAsync(() => _controller.UpdateGeneralConfig(request, _eventsContext)); + } + + [Fact] + public async Task PurgeAllStrikes_ReturnsDeletedCounts() + { + // Act + var result = await _controller.PurgeAllStrikes(_eventsContext); + + // Assert — initially empty, but the endpoint still succeeds with zero counts + var ok = result.ShouldBeOfType(); + ok.Value.ShouldNotBeNull(); + } + + private static UpdateLoggingConfigRequest MatchingLogRequest(LoggingConfig existing) => new() + { + Level = existing.Level, + RollingSizeMB = existing.RollingSizeMB, + RetainedFileCount = existing.RetainedFileCount, + TimeLimitHours = existing.TimeLimitHours, + ArchiveEnabled = existing.ArchiveEnabled, + ArchiveRetainedCount = existing.ArchiveRetainedCount, + ArchiveTimeLimitHours = existing.ArchiveTimeLimitHours, + }; +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/MalwareBlocker/MalwareBlockerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/MalwareBlocker/MalwareBlockerConfigControllerTests.cs new file mode 100644 index 00000000..69679af7 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/MalwareBlocker/MalwareBlockerConfigControllerTests.cs @@ -0,0 +1,151 @@ +using Cleanuparr.Api.Features.MalwareBlocker.Contracts.Requests; +using Cleanuparr.Api.Features.MalwareBlocker.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.MalwareBlocker; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.MalwareBlocker; + +public class MalwareBlockerConfigControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly IJobManagementService _jobManagementService; + private readonly MalwareBlockerConfigController _controller; + + public MalwareBlockerConfigControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _jobManagementService = Substitute.For(); + _controller = new MalwareBlockerConfigController(logger, _dataContext, _jobManagementService); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetMalwareBlockerConfig_ReturnsExistingConfig() + { + // Act + var result = await _controller.GetMalwareBlockerConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType(); + } + + [Fact] + public async Task UpdateMalwareBlockerConfig_Enabled_StartsJobWithCron() + { + // Arrange — Enabled requires at least one blocklist with a valid path + var request = new UpdateMalwareBlockerConfigRequest + { + Enabled = true, + CronExpression = "0/5 * * * * ?", + Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/blocklist.txt" }, + Radarr = new BlocklistSettings { Enabled = false }, + Lidarr = new BlocklistSettings { Enabled = false }, + Readarr = new BlocklistSettings { Enabled = false }, + Whisparr = new BlocklistSettings { Enabled = false }, + }; + + // Act + var result = await _controller.UpdateMalwareBlockerConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StartJob(JobType.MalwareBlocker, null, "0/5 * * * * ?"); + await _jobManagementService.DidNotReceive().StopJob(Arg.Any()); + } + + [Fact] + public async Task UpdateMalwareBlockerConfig_Disabled_StopsJob() + { + // Arrange — pre-enable + var existing = await _dataContext.ContentBlockerConfigs.FirstAsync(); + existing.Enabled = true; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateMalwareBlockerConfigRequest + { + Enabled = false, + CronExpression = "0/5 * * * * ?", + Sonarr = new BlocklistSettings { Enabled = false }, + Radarr = new BlocklistSettings { Enabled = false }, + Lidarr = new BlocklistSettings { Enabled = false }, + Readarr = new BlocklistSettings { Enabled = false }, + Whisparr = new BlocklistSettings { Enabled = false }, + }; + + // Act + var result = await _controller.UpdateMalwareBlockerConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StopJob(JobType.MalwareBlocker); + await _jobManagementService.DidNotReceive().StartJob(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateMalwareBlockerConfig_InvalidCronExpression_PropagatesValidationException() + { + // Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException, + // which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match. + var request = new UpdateMalwareBlockerConfigRequest + { + Enabled = true, + CronExpression = "definitely-not-a-cron", + Sonarr = new BlocklistSettings { Enabled = false }, + Radarr = new BlocklistSettings { Enabled = false }, + Lidarr = new BlocklistSettings { Enabled = false }, + Readarr = new BlocklistSettings { Enabled = false }, + Whisparr = new BlocklistSettings { Enabled = false }, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.UpdateMalwareBlockerConfig(request)); + await _jobManagementService.DidNotReceive().StartJob(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateMalwareBlockerConfig_PersistsChanges() + { + // Arrange + var request = new UpdateMalwareBlockerConfigRequest + { + Enabled = true, + CronExpression = "0/10 * * * * ?", + IgnorePrivate = true, + DeletePrivate = false, + Sonarr = new BlocklistSettings { Enabled = true, BlocklistPath = "https://example.com/list.txt" }, + Radarr = new BlocklistSettings { Enabled = false }, + Lidarr = new BlocklistSettings { Enabled = false }, + Readarr = new BlocklistSettings { Enabled = false }, + Whisparr = new BlocklistSettings { Enabled = false }, + IgnoredDownloads = new List { "foo" }, + }; + + // Act + await _controller.UpdateMalwareBlockerConfig(request); + + // Assert + var saved = await _dataContext.ContentBlockerConfigs.AsNoTracking().FirstAsync(); + saved.Enabled.ShouldBeTrue(); + saved.CronExpression.ShouldBe("0/10 * * * * ?"); + saved.IgnorePrivate.ShouldBeTrue(); + saved.Sonarr.Enabled.ShouldBeTrue(); + saved.IgnoredDownloads.ShouldContain("foo"); + } +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueCleanerConfigControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueCleanerConfigControllerTests.cs new file mode 100644 index 00000000..bee620c3 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueCleanerConfigControllerTests.cs @@ -0,0 +1,156 @@ +using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests; +using Cleanuparr.Api.Features.QueueCleaner.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.QueueCleaner; + +public class QueueCleanerConfigControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly IJobManagementService _jobManagementService; + private readonly QueueCleanerConfigController _controller; + + public QueueCleanerConfigControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _jobManagementService = Substitute.For(); + _controller = new QueueCleanerConfigController(logger, _dataContext, _jobManagementService); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetQueueCleanerConfig_ReturnsExistingConfig() + { + // Act + var result = await _controller.GetQueueCleanerConfig(); + + // Assert + var ok = result.ShouldBeOfType(); + ok.Value.ShouldBeOfType(); + } + + [Fact] + public async Task UpdateQueueCleanerConfig_Enabled_StartsJob() + { + // Arrange + var request = new UpdateQueueCleanerConfigRequest + { + Enabled = true, + CronExpression = "0 0/5 * * * ?", + FailedImport = new FailedImportConfig(), + IgnoredDownloads = new List(), + }; + + // Act + var result = await _controller.UpdateQueueCleanerConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StartJob(JobType.QueueCleaner, null, "0 0/5 * * * ?"); + await _jobManagementService.DidNotReceive().StopJob(Arg.Any()); + } + + [Fact] + public async Task UpdateQueueCleanerConfig_Disabled_StopsJob() + { + // Arrange — pre-enable + var existing = await _dataContext.QueueCleanerConfigs.FirstAsync(); + existing.Enabled = true; + await _dataContext.SaveChangesAsync(); + + var request = new UpdateQueueCleanerConfigRequest + { + Enabled = false, + CronExpression = "0 0/5 * * * ?", + FailedImport = new FailedImportConfig(), + IgnoredDownloads = new List(), + }; + + // Act + var result = await _controller.UpdateQueueCleanerConfig(request); + + // Assert + result.ShouldBeOfType(); + await _jobManagementService.Received(1).StopJob(JobType.QueueCleaner); + } + + [Fact] + public async Task UpdateQueueCleanerConfig_InvalidCronExpression_PropagatesValidationException() + { + // Arrange — CronValidationHelper throws Cleanuparr.Domain.Exceptions.ValidationException, + // which the controller's catch (System.ComponentModel.DataAnnotations.ValidationException) does NOT match. + var request = new UpdateQueueCleanerConfigRequest + { + Enabled = true, + CronExpression = "not-a-cron", + FailedImport = new FailedImportConfig(), + IgnoredDownloads = new List(), + }; + + // Act / Assert + await Should.ThrowAsync( + () => _controller.UpdateQueueCleanerConfig(request)); + await _jobManagementService.DidNotReceive().StartJob(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task UpdateQueueCleanerConfig_ConfigValidationFails_ReturnsBadRequest() + { + // Arrange — DownloadingMetadataMaxStrikes < 3 (and > 0) triggers Validate() exception + var request = new UpdateQueueCleanerConfigRequest + { + Enabled = true, + CronExpression = "0 0/5 * * * ?", + FailedImport = new FailedImportConfig(), + DownloadingMetadataMaxStrikes = 2, + IgnoredDownloads = new List(), + }; + + // Act + Assert + await Should.ThrowAsync( + () => _controller.UpdateQueueCleanerConfig(request)); + } + + [Fact] + public async Task UpdateQueueCleanerConfig_PersistsChanges() + { + // Arrange + var request = new UpdateQueueCleanerConfigRequest + { + Enabled = true, + CronExpression = "0 0/10 * * * ?", + FailedImport = new FailedImportConfig(), + DownloadingMetadataMaxStrikes = 5, + ProcessNoContentId = true, + IgnoredDownloads = new List { "ignored" }, + }; + + // Act + await _controller.UpdateQueueCleanerConfig(request); + + // Assert + var saved = await _dataContext.QueueCleanerConfigs.AsNoTracking().FirstAsync(); + saved.Enabled.ShouldBeTrue(); + saved.CronExpression.ShouldBe("0 0/10 * * * ?"); + saved.DownloadingMetadataMaxStrikes.ShouldBe((ushort)5); + saved.ProcessNoContentId.ShouldBeTrue(); + saved.IgnoredDownloads.ShouldContain("ignored"); + } +} diff --git a/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueRulesControllerTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueRulesControllerTests.cs new file mode 100644 index 00000000..f66ffad6 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueRulesControllerTests.cs @@ -0,0 +1,258 @@ +using Cleanuparr.Api.Features.QueueCleaner.Contracts.Requests; +using Cleanuparr.Api.Features.QueueCleaner.Controllers; +using Cleanuparr.Api.Tests.TestHelpers; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Models; +using Cleanuparr.Infrastructure.Services.Interfaces; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.QueueCleaner; + +public class QueueRulesControllerTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly IRuleIntervalValidator _validator; + private readonly QueueRulesController _controller; + + public QueueRulesControllerTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + var logger = Substitute.For>(); + _validator = Substitute.For(); + _validator.ValidateStallRuleIntervals(Arg.Any(), Arg.Any>()).Returns(ValidationResult.Success()); + _validator.ValidateSlowRuleIntervals(Arg.Any(), Arg.Any>()).Returns(ValidationResult.Success()); + _controller = new QueueRulesController(logger, _dataContext, _validator); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_controller); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + #region Stall Rules + + [Fact] + public async Task GetStallRules_EmptyDatabase_ReturnsEmptyList() + { + // Act + var result = await _controller.GetStallRules(); + + // Assert + var ok = result.ShouldBeOfType(); + var rules = ok.Value.ShouldBeOfType>(); + rules.ShouldBeEmpty(); + } + + [Fact] + public async Task CreateStallRule_NewName_ReturnsCreatedWithRule() + { + // Arrange + var dto = NewStallDto(name: "default"); + + // Act + var result = await _controller.CreateStallRule(dto); + + // Assert + var created = result.ShouldBeOfType(); + var rule = created.Value.ShouldBeOfType(); + rule.Name.ShouldBe("default"); + rule.Id.ShouldNotBe(Guid.Empty); + (await _dataContext.StallRules.CountAsync()).ShouldBe(1); + } + + [Fact] + public async Task CreateStallRule_DuplicateName_ReturnsBadRequest() + { + // Arrange — seed existing rule + await _controller.CreateStallRule(NewStallDto(name: "rule-a")); + + // Act — try to create another with same name (case-insensitive) + var result = await _controller.CreateStallRule(NewStallDto(name: "RULE-A")); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + (await _dataContext.StallRules.CountAsync()).ShouldBe(1); + } + + [Fact] + public async Task CreateStallRule_IntervalValidatorFails_ReturnsBadRequest() + { + // Arrange + _validator.ValidateStallRuleIntervals(Arg.Any(), Arg.Any>()) + .Returns(ValidationResult.Failure("overlaps with existing rule")); + + // Act + var result = await _controller.CreateStallRule(NewStallDto(name: "x")); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + (await _dataContext.StallRules.CountAsync()).ShouldBe(0); + } + + [Fact] + public async Task UpdateStallRule_NonExistentId_ReturnsNotFound() + { + // Act + var result = await _controller.UpdateStallRule(Guid.NewGuid(), NewStallDto("x")); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Fact] + public async Task UpdateStallRule_ExistingRule_PersistsChanges() + { + // Arrange + var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "orig")); + var id = ((StallRule)create.Value!).Id; + + // Act + var result = await _controller.UpdateStallRule(id, NewStallDto(name: "renamed")); + + // Assert + var ok = result.ShouldBeOfType(); + var rule = ok.Value.ShouldBeOfType(); + rule.Name.ShouldBe("renamed"); + var saved = await _dataContext.StallRules.AsNoTracking().FirstAsync(r => r.Id == id); + saved.Name.ShouldBe("renamed"); + } + + [Fact] + public async Task UpdateStallRule_DuplicateNameOtherRule_ReturnsBadRequest() + { + // Arrange + await _controller.CreateStallRule(NewStallDto(name: "alpha")); + var betaCreate = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "beta")); + var betaId = ((StallRule)betaCreate.Value!).Id; + + // Act — try to rename beta → alpha + var result = await _controller.UpdateStallRule(betaId, NewStallDto(name: "alpha")); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + + [Fact] + public async Task DeleteStallRule_ExistingRule_ReturnsNoContent() + { + // Arrange + var create = (CreatedAtActionResult)await _controller.CreateStallRule(NewStallDto(name: "doomed")); + var id = ((StallRule)create.Value!).Id; + + // Act + var result = await _controller.DeleteStallRule(id); + + // Assert + result.ShouldBeOfType(); + (await _dataContext.StallRules.CountAsync()).ShouldBe(0); + } + + [Fact] + public async Task DeleteStallRule_NonExistent_ReturnsNotFound() + { + // Act + var result = await _controller.DeleteStallRule(Guid.NewGuid()); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + #endregion + + #region Slow Rules + + [Fact] + public async Task GetSlowRules_EmptyDatabase_ReturnsEmptyList() + { + // Act + var result = await _controller.GetSlowRules(); + + // Assert + var ok = result.ShouldBeOfType(); + var rules = ok.Value.ShouldBeOfType>(); + rules.ShouldBeEmpty(); + } + + [Fact] + public async Task CreateSlowRule_NewName_ReturnsCreated() + { + // Arrange + var dto = NewSlowDto(name: "slow-default"); + + // Act + var result = await _controller.CreateSlowRule(dto); + + // Assert + var created = result.ShouldBeOfType(); + var rule = created.Value.ShouldBeOfType(); + rule.Name.ShouldBe("slow-default"); + (await _dataContext.SlowRules.CountAsync()).ShouldBe(1); + } + + [Fact] + public async Task CreateSlowRule_DuplicateName_ReturnsBadRequest() + { + // Arrange + await _controller.CreateSlowRule(NewSlowDto(name: "x")); + + // Act + var result = await _controller.CreateSlowRule(NewSlowDto(name: "X")); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(400); + } + + [Fact] + public async Task UpdateSlowRule_NonExistentId_ReturnsNotFound() + { + // Act + var result = await _controller.UpdateSlowRule(Guid.NewGuid(), NewSlowDto("x")); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + [Fact] + public async Task DeleteSlowRule_NonExistent_ReturnsNotFound() + { + // Act + var result = await _controller.DeleteSlowRule(Guid.NewGuid()); + + // Assert + result.ShouldBeOfType().StatusCode.ShouldBe(404); + } + + #endregion + + private static StallRuleDto NewStallDto(string name) => new() + { + Name = name, + Enabled = true, + MaxStrikes = 3, + PrivacyType = TorrentPrivacyType.Public, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100, + ResetStrikesOnProgress = true, + }; + + private static SlowRuleDto NewSlowDto(string name) => new() + { + Name = name, + Enabled = true, + MaxStrikes = 3, + PrivacyType = TorrentPrivacyType.Public, + MinCompletionPercentage = 0, + MaxCompletionPercentage = 100, + ResetStrikesOnProgress = true, + MinSpeed = "1MB", + }; +} diff --git a/code/backend/Cleanuparr.Api.Tests/TestHelpers/ConfigControllerTestDataFactory.cs b/code/backend/Cleanuparr.Api.Tests/TestHelpers/ConfigControllerTestDataFactory.cs new file mode 100644 index 00000000..607e22e8 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/TestHelpers/ConfigControllerTestDataFactory.cs @@ -0,0 +1,136 @@ +using Cleanuparr.Domain.Enums; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.BlacklistSync; +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 Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using NSubstitute; + +namespace Cleanuparr.Api.Tests.TestHelpers; + +/// +/// Shared SQLite in-memory factory for controller tests that need a populated DataContext. +/// Seeds one row per config table so first-or-default reads succeed. +/// +public static class ConfigControllerTestDataFactory +{ + public static DataContext CreateDataContext() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .UseLowerCaseNamingConvention() + .UseSnakeCaseNamingConvention() + .Options; + + var context = new DataContext(options); + context.Database.EnsureCreated(); + + SeedDefaults(context); + return context; + } + + public static EventsContext CreateEventsContext() + { + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .UseLowerCaseNamingConvention() + .UseSnakeCaseNamingConvention() + .Options; + + var context = new EventsContext(options); + context.Database.EnsureCreated(); + return context; + } + + public static void ConfigureProblemDetails(ControllerBase controller) + { + ProblemDetailsFactory factory = Substitute.For(); + factory + .CreateProblemDetails( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => new ProblemDetails + { + Status = call.ArgAt(1), + Title = call.ArgAt(2), + Detail = call.ArgAt(4), + }); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext(), + }; + controller.ProblemDetailsFactory = factory; + } + + private static void SeedDefaults(DataContext context) + { + context.GeneralConfigs.Add(new GeneralConfig + { + Id = Guid.NewGuid(), + DryRun = false, + IgnoredDownloads = [], + Log = new LoggingConfig(), + }); + + context.ArrConfigs.AddRange( + new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Sonarr, Instances = [], FailedImportMaxStrikes = 3 }, + new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Radarr, Instances = [], FailedImportMaxStrikes = 3 }, + new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Lidarr, Instances = [], FailedImportMaxStrikes = 3 }, + new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Readarr, Instances = [], FailedImportMaxStrikes = 3 }, + new ArrConfig { Id = Guid.NewGuid(), Type = InstanceType.Whisparr, Instances = [], FailedImportMaxStrikes = 3 } + ); + + context.QueueCleanerConfigs.Add(new QueueCleanerConfig + { + Id = Guid.NewGuid(), + IgnoredDownloads = [], + FailedImport = new FailedImportConfig(), + }); + + context.ContentBlockerConfigs.Add(new ContentBlockerConfig + { + Id = Guid.NewGuid(), + IgnoredDownloads = [], + DeletePrivate = false, + Sonarr = new BlocklistSettings { Enabled = false }, + Radarr = new BlocklistSettings { Enabled = false }, + Lidarr = new BlocklistSettings { Enabled = false }, + Readarr = new BlocklistSettings { Enabled = false }, + Whisparr = new BlocklistSettings { Enabled = false }, + }); + + context.DownloadCleanerConfigs.Add(new DownloadCleanerConfig + { + Id = Guid.NewGuid(), + IgnoredDownloads = [], + }); + + context.BlacklistSyncConfigs.Add(new BlacklistSyncConfig + { + Id = Guid.NewGuid(), + Enabled = false, + CronExpression = "0 0 * * * ?", + }); + + context.SaveChanges(); + } +} diff --git a/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs b/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs index 6ecb918b..11a1c5e4 100644 --- a/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs +++ b/code/backend/Cleanuparr.Api/Features/Arr/Controllers/ArrConfigController.cs @@ -4,8 +4,8 @@ using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Features.Arr.Dtos; using Cleanuparr.Infrastructure.Features.Arr.Interfaces; using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration.Arr; using Cleanuparr.Shared.Helpers; -using Mapster; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -160,7 +160,7 @@ public sealed class ArrConfigController : ControllerBase .OrderBy(i => i.Name) .ToList(); - return Ok(config.Adapt()); + return Ok(ToDto(config)); } finally { @@ -201,7 +201,7 @@ public sealed class ArrConfigController : ControllerBase await _dataContext.ArrInstances.AddAsync(instance); await _dataContext.SaveChangesAsync(); - return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, instance.Adapt()); + return CreatedAtAction(GetConfigActionName(type), new { id = instance.Id }, ToDto(instance)); } finally { @@ -228,7 +228,7 @@ public sealed class ArrConfigController : ControllerBase await _dataContext.SaveChangesAsync(); - return Ok(instance.Adapt()); + return Ok(ToDto(instance)); } finally { @@ -295,6 +295,25 @@ public sealed class ArrConfigController : ControllerBase } } + private static ArrConfigDto ToDto(ArrConfig config) => new() + { + Id = config.Id, + Type = config.Type, + FailedImportMaxStrikes = config.FailedImportMaxStrikes, + Instances = config.Instances.Select(ToDto).ToList(), + }; + + private static ArrInstanceDto ToDto(ArrInstance instance) => new() + { + Id = instance.Id, + Enabled = instance.Enabled, + Version = instance.Version, + Name = instance.Name, + Url = instance.Url.ToString(), + ApiKey = instance.ApiKey, + ExternalUrl = instance.ExternalUrl?.ToString(), + }; + private static string GetConfigActionName(InstanceType type) => type switch { InstanceType.Sonarr => nameof(GetSonarrConfig), diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientTests.cs new file mode 100644 index 00000000..ed3290d5 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrClientTests.cs @@ -0,0 +1,700 @@ +using System.Net; +using System.Text; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Arr; + +public class ArrClientTests +{ + private readonly ILogger _logger; + private readonly IStriker _striker; + private readonly IDryRunInterceptor _dryRunInterceptor; + private readonly FakeHttpMessageHandler _httpMessageHandler; + private readonly TestArrClient _client; + private readonly ArrInstance _arrInstance; + + public ArrClientTests() + { + _logger = Substitute.For>(); + _striker = Substitute.For(); + _dryRunInterceptor = Substitute.For(); + _httpMessageHandler = new FakeHttpMessageHandler(); + + var httpClient = new HttpClient(_httpMessageHandler); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + _client = new TestArrClient(_logger, httpClientFactory, _striker, _dryRunInterceptor); + _arrInstance = new ArrInstance + { + Name = "test", + Url = new Uri("http://localhost:8989/"), + ApiKey = "secret-key", + }; + + // Default: dry-run disabled, pass-through delegate invocation + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns(async ci => + { + Func> action = ci.Arg>>(); + return await action(); + }); + } + + #region GetQueueItemsAsync + + [Fact] + public async Task GetQueueItemsAsync_SendsGetWithApiKeyAndExpectedUri() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new QueueListResponse { TotalRecords = 0, Records = Array.Empty() }))); + + // Act + var result = await _client.GetQueueItemsAsync(_arrInstance, 2); + + // Assert + result.ShouldNotBeNull(); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.Method.ShouldBe(HttpMethod.Get); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue"); + request.RequestUri.Query.ShouldBe("?page=2&pageSize=100"); + request.Headers.GetValues("x-api-key").ShouldHaveSingleItem().ShouldBe("secret-key"); + } + + [Fact] + public async Task GetQueueItemsAsync_NonSuccessStatus_Throws() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.InternalServerError); + + // Act / Assert + await Should.ThrowAsync(() => _client.GetQueueItemsAsync(_arrInstance, 1)); + } + + [Fact] + public async Task GetQueueItemsAsync_NullDeserialization_Throws() + { + // Arrange — body "null" deserializes to null + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", Encoding.UTF8, "application/json"), + })); + + // Act / Assert + await Should.ThrowAsync(() => _client.GetQueueItemsAsync(_arrInstance, 1)); + } + + #endregion + + #region GetActiveDownloadCountAsync + + [Fact] + public async Task GetActiveDownloadCountAsync_EmptyQueue_ReturnsZero() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new QueueListResponse { TotalRecords = 0, Records = Array.Empty() }))); + + // Act + var count = await _client.GetActiveDownloadCountAsync(_arrInstance); + + // Assert + count.ShouldBe(0); + } + + [Fact] + public async Task GetActiveDownloadCountAsync_CountsRecordsWithSizeLeftAboveZero() + { + // Arrange — 3 records, only 2 have SizeLeft > 0 + var records = new[] + { + BuildRecord(1, sizeLeft: 100), + BuildRecord(2, sizeLeft: 0), + BuildRecord(3, sizeLeft: 50), + }; + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new QueueListResponse { TotalRecords = records.Length, Records = records }))); + + // Act + var count = await _client.GetActiveDownloadCountAsync(_arrInstance); + + // Assert + count.ShouldBe(2); + } + + [Fact] + public async Task GetActiveDownloadCountAsync_MultiPage_AccumulatesAcrossPages() + { + // Arrange — page 1: 2 active of 2; page 2: 1 active of 2; total = 4 records + int call = 0; + _httpMessageHandler.SetupResponse((_, _) => + { + call++; + QueueRecord[] records = call switch + { + 1 => new[] { BuildRecord(1, sizeLeft: 10), BuildRecord(2, sizeLeft: 10) }, + 2 => new[] { BuildRecord(3, sizeLeft: 0), BuildRecord(4, sizeLeft: 5) }, + _ => Array.Empty(), + }; + return Task.FromResult(JsonResponse(new QueueListResponse { TotalRecords = 4, Records = records })); + }); + + // Act + var count = await _client.GetActiveDownloadCountAsync(_arrInstance); + + // Assert + count.ShouldBe(3); + _httpMessageHandler.CapturedRequests.Count.ShouldBe(2); + } + + #endregion + + #region DeleteQueueItemAsync + + [Fact] + public async Task DeleteQueueItemAsync_RemoveFromClient_SendsDeleteWithExpectedQuery() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(42), removeFromClient: true, changeCategory: false, DeleteReason.FailedImport); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.Method.ShouldBe(HttpMethod.Delete); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue/42"); + request.RequestUri.Query.ShouldBe("?blocklist=true&skipRedownload=true&changeCategory=false&removeFromClient=true"); + request.Headers.GetValues("x-api-key").ShouldHaveSingleItem().ShouldBe("secret-key"); + } + + [Fact] + public async Task DeleteQueueItemAsync_ChangeCategory_BuildsCategoryQuery() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(7), removeFromClient: true, changeCategory: true, DeleteReason.FailedImport); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.Query.ShouldBe("?blocklist=true&skipRedownload=true&changeCategory=true&removeFromClient=false"); + } + + [Fact] + public async Task DeleteQueueItemAsync_NoRemoveNoChangeCategory_BuildsBareQuery() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(7), removeFromClient: false, changeCategory: false, DeleteReason.Stalled); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.Query.ShouldBe("?blocklist=true&skipRedownload=true&changeCategory=false&removeFromClient=false"); + } + + [Fact] + public async Task DeleteQueueItemAsync_ServerError_Throws() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.BadGateway); + + // Act / Assert + await Should.ThrowAsync(() => + _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(1), removeFromClient: true, changeCategory: false, DeleteReason.Stalled)); + } + + [Fact] + public async Task DeleteQueueItemAsync_DryRunReturnsNull_DoesNotThrow() + { + // Arrange — interceptor short-circuits and returns null; method should still log "removed" + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns((HttpResponseMessage?)null); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(1), removeFromClient: false, changeCategory: false, DeleteReason.Stalled); + + // Assert — no HTTP call was actually made because the interceptor was substituted to return null + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + #endregion + + #region SearchItemAsync + + [Fact] + public async Task SearchItemAsync_DryRun_ReturnsFirstOrDefault() + { + // Arrange — TestArrClient.SearchItemsAsync returns empty; dry-run path uses FirstOrDefault + _dryRunInterceptor.IsDryRunEnabled().Returns(true); + + // Act + var result = await _client.SearchItemAsync(_arrInstance, new SearchItem { Id = 1 }); + + // Assert + result.ShouldBe(0); + } + + [Fact] + public async Task SearchItemAsync_NotDryRun_EmptyIds_ThrowsInvalidOperation() + { + // Arrange — non-dry-run path uses .First() which throws on empty + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + + // Act / Assert + await Should.ThrowAsync(() => _client.SearchItemAsync(_arrInstance, new SearchItem { Id = 1 })); + } + + [Fact] + public async Task SearchItemAsync_NotDryRun_ReturnsFirstId() + { + // Arrange — TestArrClient returns the id list it was given + _client.SearchResultIds = [99L, 100L]; + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + + // Act + var result = await _client.SearchItemAsync(_arrInstance, new SearchItem { Id = 1 }); + + // Assert + result.ShouldBe(99); + } + + #endregion + + #region IsRecordValid + + [Theory] + [InlineData("")] + [InlineData(null)] + public void IsRecordValid_NullOrEmptyDownloadId_ReturnsFalse(string? downloadId) + { + // Arrange + var record = new QueueRecord + { + Id = 1, + Title = "title", + DownloadId = downloadId!, + Protocol = "torrent", + }; + + // Act / Assert + _client.IsRecordValid(record).ShouldBeFalse(); + } + + [Fact] + public void IsRecordValid_NonEmptyDownloadId_ReturnsTrue() + { + // Arrange / Act / Assert + _client.IsRecordValid(BuildRecord(1)).ShouldBeTrue(); + } + + #endregion + + #region HealthCheckAsync + + [Fact] + public async Task HealthCheckAsync_SendsGetWithApiKey() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.HealthCheckAsync(_arrInstance); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.Method.ShouldBe(HttpMethod.Get); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/system/status"); + request.Headers.GetValues("x-api-key").ShouldHaveSingleItem().ShouldBe("secret-key"); + } + + [Fact] + public async Task HealthCheckAsync_NonSuccess_Throws() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.Unauthorized); + + // Act / Assert + await Should.ThrowAsync(() => _client.HealthCheckAsync(_arrInstance)); + } + + #endregion + + #region GetCommandStatusAsync + + [Fact] + public async Task GetCommandStatusAsync_DeserializesResponse() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new ArrCommandStatus(42, "completed", "ok")))); + + // Act + var status = await _client.GetCommandStatusAsync(_arrInstance, 42); + + // Assert + status.Id.ShouldBe(42); + status.Status.ShouldBe("completed"); + status.Message.ShouldBe("ok"); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command/42"); + } + + [Fact] + public async Task GetCommandStatusAsync_NullResponseBody_ReturnsUnknownDefault() + { + // Arrange — body deserializes to null + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("null", Encoding.UTF8, "application/json"), + })); + + // Act + var status = await _client.GetCommandStatusAsync(_arrInstance, 99); + + // Assert + status.Id.ShouldBe(99); + status.Status.ShouldBe("unknown"); + status.Message.ShouldBeNull(); + } + + #endregion + + #region ShouldRemoveFromQueue + + [Fact] + public async Task ShouldRemoveFromQueue_IgnorePrivateAndIsPrivate_ReturnsFalse() + { + // Arrange + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig { IgnorePrivate = true, MaxStrikes = 3, PatternMode = PatternMode.Exclude }, + }); + var record = BuildRecord(1, trackedStatus: "warning", trackedState: "importBlocked"); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: true, arrMaxStrikes: -1); + + // Assert + result.ShouldBeFalse(); + await _striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShouldRemoveFromQueue_NotFailedImport_ReturnsFalse() + { + // Arrange — tracked download is "downloading" with no failed-import message + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig { MaxStrikes = 3, PatternMode = PatternMode.Exclude }, + }); + var record = BuildRecord(1, trackedStatus: "ok", trackedState: "downloading"); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: -1); + + // Assert + result.ShouldBeFalse(); + await _striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShouldRemoveFromQueue_ArrMaxStrikesZero_ReturnsFalse() + { + // Arrange + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig { MaxStrikes = 3, PatternMode = PatternMode.Exclude }, + }); + var record = BuildRecord(1, trackedStatus: "warning", trackedState: "importBlocked", + statusMessages: new List + { + new() { Title = "import failed", Messages = ["bad file"] }, + }); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: 0); + + // Assert + result.ShouldBeFalse(); + await _striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShouldRemoveFromQueue_WarnImportBlocked_StrikesAndPropagatesLimitResult() + { + // Arrange — exclude-mode + non-matching pattern means we WILL strike + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = new List { "should not match" }, + }, + }); + var record = BuildRecord(1, trackedStatus: "warning", trackedState: "importBlocked", + statusMessages: new List + { + new() { Title = "failed", Messages = ["import error"] }, + }); + _striker.StrikeAndCheckLimit(record.DownloadId, record.Title, (ushort)3, StrikeType.FailedImport) + .Returns(true); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: -1); + + // Assert + result.ShouldBeTrue(); + await _striker.Received(1).StrikeAndCheckLimit(record.DownloadId, record.Title, (ushort)3, StrikeType.FailedImport); + } + + [Fact] + public async Task ShouldRemoveFromQueue_ArrMaxStrikesOverridesConfigMax() + { + // Arrange — config says 3, arr override says 7 + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = new List { "no match" }, + }, + }); + var record = BuildRecord(1, trackedStatus: "warning", trackedState: "importFailed", + statusMessages: new List + { + new() { Title = "failed", Messages = ["import error"] }, + }); + _striker.StrikeAndCheckLimit(record.DownloadId, record.Title, (ushort)7, StrikeType.FailedImport) + .Returns(false); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: 7); + + // Assert + result.ShouldBeFalse(); + await _striker.Received(1).StrikeAndCheckLimit(record.DownloadId, record.Title, (ushort)7, StrikeType.FailedImport); + } + + [Fact] + public async Task ShouldRemoveFromQueue_ExcludePatternMatched_DoesNotStrike() + { + // Arrange — exclude-mode with a matching pattern should skip + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = new List { "permission" }, + }, + }); + var record = BuildRecord(1, trackedStatus: "warning", trackedState: "importBlocked", + statusMessages: new List + { + new() { Title = "failed", Messages = ["permission denied on import"] }, + }); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: -1); + + // Assert + result.ShouldBeFalse(); + await _striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShouldRemoveFromQueue_IncludePatternNotMatched_DoesNotStrike() + { + // Arrange — include-mode with non-matching pattern should skip + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Include, + Patterns = new List { "specific reason" }, + }, + }); + var record = BuildRecord(1, trackedStatus: "warning", trackedState: "importBlocked", + statusMessages: new List + { + new() { Title = "failed", Messages = ["something else entirely"] }, + }); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: -1); + + // Assert + result.ShouldBeFalse(); + await _striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShouldRemoveFromQueue_NoStatusMessages_DoesNotStrike() + { + // Arrange + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig { MaxStrikes = 3, PatternMode = PatternMode.Exclude }, + }); + var record = BuildRecord(1, trackedStatus: "warning", trackedState: "importBlocked", statusMessages: null); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: -1); + + // Assert + result.ShouldBeFalse(); + await _striker.DidNotReceive().StrikeAndCheckLimit(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ShouldRemoveFromQueue_LidarrFailedWithWarn_Strikes() + { + // Arrange — Lidarr-specific path: status=failed/completed + warning triggers strike + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = new List { "ignored" }, + }, + }); + var record = BuildRecord(1, status: "failed", trackedStatus: "warning", trackedState: "downloading", + statusMessages: new List + { + new() { Title = "failed", Messages = ["import broke"] }, + }); + _striker.StrikeAndCheckLimit(record.DownloadId, record.Title, (ushort)3, StrikeType.FailedImport) + .Returns(true); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Lidarr, record, isPrivateDownload: false, arrMaxStrikes: -1); + + // Assert + result.ShouldBeTrue(); + await _striker.Received(1).StrikeAndCheckLimit(record.DownloadId, record.Title, (ushort)3, StrikeType.FailedImport); + } + + [Fact] + public async Task ShouldRemoveFromQueue_DownloadingWithFailedImportMessage_Strikes() + { + // Arrange — IsEdgeCase: state=downloading + a message starting with "Unable to import automatically" + SetQueueCleanerConfig(new QueueCleanerConfig + { + FailedImport = new FailedImportConfig + { + MaxStrikes = 3, + PatternMode = PatternMode.Exclude, + Patterns = new List { "ignored" }, + }, + }); + var record = BuildRecord(1, trackedStatus: "ok", trackedState: "downloading", + statusMessages: new List + { + new() { Title = "warn", Messages = ["Unable to import automatically, please try again"] }, + }); + _striker.StrikeAndCheckLimit(record.DownloadId, record.Title, (ushort)3, StrikeType.FailedImport) + .Returns(true); + + // Act + var result = await _client.ShouldRemoveFromQueue(InstanceType.Sonarr, record, isPrivateDownload: false, arrMaxStrikes: -1); + + // Assert + result.ShouldBeTrue(); + } + + #endregion + + #region Helpers + + private static void SetQueueCleanerConfig(QueueCleanerConfig config) + { + ContextProvider.Set(config); + } + + private static QueueRecord BuildRecord( + long id, + long sizeLeft = 0, + string status = "downloading", + string trackedStatus = "ok", + string trackedState = "downloading", + List? statusMessages = null) + { + return new QueueRecord + { + Id = id, + Title = $"item-{id}", + DownloadId = id.ToString(), + Protocol = "torrent", + SizeLeft = sizeLeft, + Status = status, + TrackedDownloadStatus = trackedStatus, + TrackedDownloadState = trackedState, + StatusMessages = statusMessages, + }; + } + + private static HttpResponseMessage JsonResponse(T body) + { + var json = Newtonsoft.Json.JsonConvert.SerializeObject(body); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + } + + #endregion + + private sealed class TestArrClient : ArrClient + { + public List SearchResultIds { get; set; } = new(); + + public TestArrClient( + ILogger logger, + IHttpClientFactory httpClientFactory, + IStriker striker, + IDryRunInterceptor dryRunInterceptor) + : base(logger, httpClientFactory, striker, dryRunInterceptor) + { + } + + protected override string GetSystemStatusUrlPath() => "/api/v1/system/status"; + + protected override string GetQueueUrlPath() => "/api/v1/queue"; + + protected override string GetQueueUrlQuery(int page) => $"page={page}&pageSize=100"; + + protected override string GetQueueDeleteUrlPath(long recordId) => $"/api/v1/queue/{recordId}"; + + public override Task> SearchItemsAsync(ArrInstance arrInstance, HashSet? items) + => Task.FromResult(SearchResultIds); + + public override bool HasContentId(QueueRecord record) => true; + + public override Task> GetAllTagsAsync(ArrInstance arrInstance) => Task.FromResult(new List()); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrQueueIteratorTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrQueueIteratorTests.cs new file mode 100644 index 00000000..86a8db22 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ArrQueueIteratorTests.cs @@ -0,0 +1,171 @@ +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Arr; + +public class ArrQueueIteratorTests +{ + private readonly ILogger _logger; + private readonly IArrClient _arrClient; + private readonly ArrInstance _arrInstance; + private readonly ArrQueueIterator _iterator; + + public ArrQueueIteratorTests() + { + _logger = Substitute.For>(); + _arrClient = Substitute.For(); + _arrInstance = new ArrInstance + { + Name = "test", + Url = new Uri("http://localhost:8989"), + ApiKey = "key", + }; + _iterator = new ArrQueueIterator(_logger); + } + + [Fact] + public async Task Iterate_EmptyQueue_DoesNotInvokeAction() + { + // Arrange + _arrClient.GetQueueItemsAsync(_arrInstance, Arg.Any()) + .Returns(new QueueListResponse { TotalRecords = 0, Records = Array.Empty() }); + int invocations = 0; + + // Act + await _iterator.Iterate(_arrClient, _arrInstance, _ => + { + invocations++; + return Task.CompletedTask; + }); + + // Assert + invocations.ShouldBe(0); + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, 1); + } + + [Fact] + public async Task Iterate_SinglePage_InvokesActionOnce() + { + // Arrange + var records = new[] { BuildRecord(1), BuildRecord(2) }; + _arrClient.GetQueueItemsAsync(_arrInstance, 1) + .Returns(new QueueListResponse { TotalRecords = records.Length, Records = records }); + int invocations = 0; + IReadOnlyList? captured = null; + + // Act + await _iterator.Iterate(_arrClient, _arrInstance, batch => + { + invocations++; + captured = batch; + return Task.CompletedTask; + }); + + // Assert + invocations.ShouldBe(1); + captured.ShouldNotBeNull(); + captured!.Count.ShouldBe(2); + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, 1); + await _arrClient.DidNotReceive().GetQueueItemsAsync(_arrInstance, 2); + } + + [Fact] + public async Task Iterate_MultiPage_AdvancesPageAndInvokesActionPerPage() + { + // Arrange — 5 total records, 2 per page + _arrClient.GetQueueItemsAsync(_arrInstance, 1) + .Returns(new QueueListResponse { TotalRecords = 5, Records = new[] { BuildRecord(1), BuildRecord(2) } }); + _arrClient.GetQueueItemsAsync(_arrInstance, 2) + .Returns(new QueueListResponse { TotalRecords = 5, Records = new[] { BuildRecord(3), BuildRecord(4) } }); + _arrClient.GetQueueItemsAsync(_arrInstance, 3) + .Returns(new QueueListResponse { TotalRecords = 5, Records = new[] { BuildRecord(5) } }); + int invocations = 0; + + // Act + await _iterator.Iterate(_arrClient, _arrInstance, _ => + { + invocations++; + return Task.CompletedTask; + }); + + // Assert + invocations.ShouldBe(3); + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, 1); + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, 2); + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, 3); + await _arrClient.DidNotReceive().GetQueueItemsAsync(_arrInstance, 4); + } + + [Fact] + public async Task Iterate_StopsWhenProcessedReachesTotal() + { + // Arrange — total reported as 2, server returns 2 on page 1; iterator must not request page 2 + _arrClient.GetQueueItemsAsync(_arrInstance, 1) + .Returns(new QueueListResponse { TotalRecords = 2, Records = new[] { BuildRecord(1), BuildRecord(2) } }); + + // Act + await _iterator.Iterate(_arrClient, _arrInstance, _ => Task.CompletedTask); + + // Assert + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, Arg.Any()); + } + + [Fact] + public async Task Iterate_EmptyMidPagination_BreaksWithoutCallingAction() + { + // Arrange — first page has records, second page is empty (should stop without invoking action again) + _arrClient.GetQueueItemsAsync(_arrInstance, 1) + .Returns(new QueueListResponse { TotalRecords = 99, Records = new[] { BuildRecord(1) } }); + _arrClient.GetQueueItemsAsync(_arrInstance, 2) + .Returns(new QueueListResponse { TotalRecords = 99, Records = Array.Empty() }); + int invocations = 0; + + // Act + await _iterator.Iterate(_arrClient, _arrInstance, _ => + { + invocations++; + return Task.CompletedTask; + }); + + // Assert + invocations.ShouldBe(1); + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, 1); + await _arrClient.Received(1).GetQueueItemsAsync(_arrInstance, 2); + await _arrClient.DidNotReceive().GetQueueItemsAsync(_arrInstance, 3); + } + + [Fact] + public async Task Iterate_PassesRecordsToActionWithoutMutation() + { + // Arrange + var records = new[] { BuildRecord(7), BuildRecord(8) }; + _arrClient.GetQueueItemsAsync(_arrInstance, 1) + .Returns(new QueueListResponse { TotalRecords = 2, Records = records }); + IReadOnlyList? observed = null; + + // Act + await _iterator.Iterate(_arrClient, _arrInstance, batch => + { + observed = batch; + return Task.CompletedTask; + }); + + // Assert + observed.ShouldNotBeNull(); + observed!.Select(r => r.Id).ShouldBe(new long[] { 7, 8 }); + } + + private static QueueRecord BuildRecord(long id) => new() + { + Id = id, + Title = $"item-{id}", + DownloadId = id.ToString(), + Protocol = "torrent", + }; +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/LidarrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/LidarrClientTests.cs new file mode 100644 index 00000000..2a18ac95 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/LidarrClientTests.cs @@ -0,0 +1,262 @@ +using System.Net; +using System.Text; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Arr; + +public class LidarrClientTests +{ + private readonly IStriker _striker; + private readonly IDryRunInterceptor _dryRunInterceptor; + private readonly FakeHttpMessageHandler _httpMessageHandler; + private readonly LidarrClient _client; + private readonly ArrInstance _arrInstance; + + public LidarrClientTests() + { + var logger = Substitute.For>(); + _striker = Substitute.For(); + _dryRunInterceptor = Substitute.For(); + _httpMessageHandler = new FakeHttpMessageHandler(); + + var httpClient = new HttpClient(_httpMessageHandler); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + _client = new LidarrClient(logger, httpClientFactory, _striker, _dryRunInterceptor); + _arrInstance = new ArrInstance + { + Name = "lidarr", + Url = new Uri("http://localhost:8686/"), + ApiKey = "api-key", + }; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns(async ci => + { + Func> action = ci.Arg>>(); + return await action(); + }); + } + + #region Queue URL overrides + + [Fact] + public async Task GetQueueItemsAsync_BuildsLidarrSpecificQuery() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new QueueListResponse { TotalRecords = 0, Records = Array.Empty() }))); + + // Act + await _client.GetQueueItemsAsync(_arrInstance, 2); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue"); + request.RequestUri.Query.ShouldBe("?page=2&pageSize=200&includeUnknownArtistItems=true&includeArtist=true&includeAlbum=true"); + } + + [Fact] + public async Task DeleteQueueItemAsync_UsesV1QueuePath() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(7), removeFromClient: false, changeCategory: false, DeleteReason.FailedImport); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue/7"); + } + + [Fact] + public async Task HealthCheckAsync_UsesV1SystemStatus() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.HealthCheckAsync(_arrInstance); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/system/status"); + } + + #endregion + + #region HasContentId + + [Fact] + public void HasContentId_ArtistAndAlbumSet_ReturnsTrue() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", ArtistId = 1, AlbumId = 2 }; + _client.HasContentId(record).ShouldBeTrue(); + } + + [Fact] + public void HasContentId_ArtistZero_ReturnsFalse() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", ArtistId = 0, AlbumId = 2 }; + _client.HasContentId(record).ShouldBeFalse(); + } + + [Fact] + public void HasContentId_AlbumZero_ReturnsFalse() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", ArtistId = 1, AlbumId = 0 }; + _client.HasContentId(record).ShouldBeFalse(); + } + + #endregion + + #region SearchItemsAsync + + [Fact] + public async Task SearchItemsAsync_NullItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, null); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_EmptyItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, new HashSet()); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_PostsAlbumSearchCommandWithAllIds_AndReturnsEmpty() + { + // Arrange — Lidarr's SearchItemsAsync always returns [] regardless of HTTP response + RouteResponses(commandIdForPost: 11); + var items = new HashSet + { + new SearchItem { Id = 10 }, + new SearchItem { Id = 20 }, + }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBeEmpty(); + var post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post); + post.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command"); + var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)]; + body.ShouldNotBeNull(); + body!.ShouldContain("\"name\":\"AlbumSearch\"", Case.Insensitive); + body!.ShouldContain("\"albumIds\":[10,20]", Case.Insensitive); + } + + [Fact] + public async Task SearchItemsAsync_ServerError_Throws() + { + // Arrange + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + return Task.FromResult(JsonNullResponse()); + }); + + var items = new HashSet { new() { Id = 1 } }; + + // Act / Assert + await Should.ThrowAsync(() => _client.SearchItemsAsync(_arrInstance, items)); + } + + [Fact] + public async Task SearchItemsAsync_DryRun_DoesNotPost() + { + // Arrange — interceptor returns null + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns((HttpResponseMessage?)null); + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse())); + + var items = new HashSet { new() { Id = 1 } }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldNotContain(r => r.Method == HttpMethod.Post); + } + + #endregion + + #region GetAllTagsAsync + + [Fact] + public async Task GetAllTagsAsync_ThrowsNotImplemented() + { + // Lidarr/Readarr/Whisparr do not implement tag listing + await Should.ThrowAsync(() => _client.GetAllTagsAsync(_arrInstance)); + } + + #endregion + + #region Helpers + + private void RouteResponses(long commandIdForPost) + { + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/command")) + { + return Task.FromResult(JsonResponse(new { id = commandIdForPost })); + } + // GET log-context calls (/album?...) return null so log context bails out + return Task.FromResult(JsonNullResponse()); + }); + } + + private static QueueRecord BuildRecord(long id) => new() + { + Id = id, + Title = $"item-{id}", + DownloadId = id.ToString(), + Protocol = "torrent", + }; + + private static HttpResponseMessage JsonResponse(T body) => new(HttpStatusCode.OK) + { + Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"), + }; + + private static HttpResponseMessage JsonNullResponse() => new(HttpStatusCode.OK) + { + Content = new StringContent("null", Encoding.UTF8, "application/json"), + }; + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/RadarrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/RadarrClientTests.cs new file mode 100644 index 00000000..e57c5981 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/RadarrClientTests.cs @@ -0,0 +1,385 @@ +using System.Net; +using System.Text; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Arr; + +public class RadarrClientTests +{ + private readonly IStriker _striker; + private readonly IDryRunInterceptor _dryRunInterceptor; + private readonly FakeHttpMessageHandler _httpMessageHandler; + private readonly RadarrClient _client; + private readonly ArrInstance _arrInstance; + + public RadarrClientTests() + { + var logger = Substitute.For>(); + _striker = Substitute.For(); + _dryRunInterceptor = Substitute.For(); + _httpMessageHandler = new FakeHttpMessageHandler(); + + var httpClient = new HttpClient(_httpMessageHandler); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + _client = new RadarrClient(logger, httpClientFactory, _striker, _dryRunInterceptor); + _arrInstance = new ArrInstance + { + Name = "radarr", + Url = new Uri("http://localhost:7878/"), + ApiKey = "api-key", + }; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns(async ci => + { + Func> action = ci.Arg>>(); + return await action(); + }); + } + + #region Queue URL overrides + + [Fact] + public async Task GetQueueItemsAsync_BuildsRadarrSpecificQuery() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new QueueListResponse { TotalRecords = 0, Records = Array.Empty() }))); + + // Act + await _client.GetQueueItemsAsync(_arrInstance, 4); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/queue"); + request.RequestUri.Query.ShouldBe("?page=4&pageSize=200&includeUnknownMovieItems=true&includeMovie=true"); + } + + [Fact] + public async Task DeleteQueueItemAsync_UsesV3QueuePath() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(55), removeFromClient: true, changeCategory: false, DeleteReason.Stalled); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/queue/55"); + } + + [Fact] + public async Task HealthCheckAsync_UsesV3SystemStatus() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.HealthCheckAsync(_arrInstance); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/system/status"); + } + + #endregion + + #region HasContentId + + [Fact] + public void HasContentId_MovieIdSet_ReturnsTrue() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", MovieId = 5 }; + _client.HasContentId(record).ShouldBeTrue(); + } + + [Fact] + public void HasContentId_MovieIdZero_ReturnsFalse() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", MovieId = 0 }; + _client.HasContentId(record).ShouldBeFalse(); + } + + #endregion + + #region SearchItemsAsync + + [Fact] + public async Task SearchItemsAsync_NullItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, null); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_EmptyItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, new HashSet()); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_PostsMoviesSearchCommandWithAllIds() + { + // Arrange + RouteResponses(commandIdForPost: 88); + var items = new HashSet + { + new SearchItem { Id = 10 }, + new SearchItem { Id = 20 }, + }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBe(new long[] { 88 }); + var post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post); + post.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command"); + var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)]; + body.ShouldNotBeNull(); + body!.ShouldContain("\"Name\":\"MoviesSearch\"", Case.Insensitive); + body!.ShouldContain("\"MovieIds\":[10,20]", Case.Insensitive); + } + + [Fact] + public async Task SearchItemsAsync_NullCommandResponseFromDryRun_ReturnsEmpty() + { + // Arrange — interceptor returns null on dry-run + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns((HttpResponseMessage?)null); + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse())); + + var items = new HashSet { new() { Id = 5 } }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_ServerError_Throws() + { + // Arrange + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + return Task.FromResult(JsonNullResponse()); + }); + + var items = new HashSet { new() { Id = 5 } }; + + // Act / Assert + await Should.ThrowAsync(() => _client.SearchItemsAsync(_arrInstance, items)); + } + + [Fact] + public async Task SearchItemsAsync_NoCommandIdInResponse_ReturnsEmpty() + { + // Arrange — POST returns 200 with body that has no id (treated as null id) + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{}", Encoding.UTF8, "application/json"), + }); + } + return Task.FromResult(JsonNullResponse()); + }); + + var items = new HashSet { new() { Id = 5 } }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBeEmpty(); + } + + #endregion + + #region GetAllMoviesAsync / GetAllTagsAsync / GetQualityProfilesAsync / GetMovieFileScoresAsync + + [Fact] + public async Task GetAllMoviesAsync_DeserializesList() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new SearchableMovie { Id = 1, Title = "Movie", QualityProfileId = 1 }, + }))); + + // Act + var movies = await _client.GetAllMoviesAsync(_arrInstance); + + // Assert + movies.Count.ShouldBe(1); + movies[0].Title.ShouldBe("Movie"); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/movie"); + } + + [Fact] + public async Task GetAllMoviesAsync_NullBody_ReturnsEmpty() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse())); + + // Act + var movies = await _client.GetAllMoviesAsync(_arrInstance); + + // Assert + movies.ShouldBeEmpty(); + } + + [Fact] + public async Task GetAllTagsAsync_DeserializesList() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new Tag { Id = 1, Label = "4K" }, + }))); + + // Act + var tags = await _client.GetAllTagsAsync(_arrInstance); + + // Assert + tags.Count.ShouldBe(1); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/tag"); + } + + [Fact] + public async Task GetQualityProfilesAsync_DeserializesList() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new ArrQualityProfile { Id = 1, Name = "UHD", CutoffFormatScore = 100 }, + }))); + + // Act + var profiles = await _client.GetQualityProfilesAsync(_arrInstance); + + // Assert + profiles.Count.ShouldBe(1); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/qualityprofile"); + } + + [Fact] + public async Task GetMovieFileScoresAsync_EmptyList_MakesNoRequests() + { + // Act + var scores = await _client.GetMovieFileScoresAsync(_arrInstance, new List()); + + // Assert + scores.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task GetMovieFileScoresAsync_OverHundred_BatchesIntoMultipleRequests() + { + // Arrange — 250 ids should produce 3 batches (100 + 100 + 50) + var ids = Enumerable.Range(1, 250).Select(i => (long)i).ToList(); + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty()))); + + // Act + await _client.GetMovieFileScoresAsync(_arrInstance, ids); + + // Assert + _httpMessageHandler.CapturedRequests.Count.ShouldBe(3); + _httpMessageHandler.CapturedRequests.ShouldAllBe(r => r.RequestUri!.AbsolutePath == "/api/v3/moviefile"); + } + + [Fact] + public async Task GetMovieFileScoresAsync_MergesScoresFromResponses() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new MediaFileScore { Id = 11, CustomFormatScore = 200 }, + new MediaFileScore { Id = 12, CustomFormatScore = -50 }, + }))); + + // Act + var scores = await _client.GetMovieFileScoresAsync(_arrInstance, new List { 11, 12 }); + + // Assert + scores.Count.ShouldBe(2); + scores[11].ShouldBe(200); + scores[12].ShouldBe(-50); + } + + #endregion + + #region Helpers + + private void RouteResponses(long commandIdForPost) + { + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/command")) + { + return Task.FromResult(JsonResponse(new { id = commandIdForPost })); + } + // GET log-context calls (/movie/{id}) — return null so log context bails out + return Task.FromResult(JsonNullResponse()); + }); + } + + private static QueueRecord BuildRecord(long id) => new() + { + Id = id, + Title = $"item-{id}", + DownloadId = id.ToString(), + Protocol = "torrent", + }; + + private static HttpResponseMessage JsonResponse(T body) => new(HttpStatusCode.OK) + { + Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"), + }; + + private static HttpResponseMessage JsonNullResponse() => new(HttpStatusCode.OK) + { + Content = new StringContent("null", Encoding.UTF8, "application/json"), + }; + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ReadarrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ReadarrClientTests.cs new file mode 100644 index 00000000..86983faa --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/ReadarrClientTests.cs @@ -0,0 +1,261 @@ +using System.Net; +using System.Text; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Arr; + +public class ReadarrClientTests +{ + private readonly IStriker _striker; + private readonly IDryRunInterceptor _dryRunInterceptor; + private readonly FakeHttpMessageHandler _httpMessageHandler; + private readonly ReadarrClient _client; + private readonly ArrInstance _arrInstance; + + public ReadarrClientTests() + { + var logger = Substitute.For>(); + _striker = Substitute.For(); + _dryRunInterceptor = Substitute.For(); + _httpMessageHandler = new FakeHttpMessageHandler(); + + var httpClient = new HttpClient(_httpMessageHandler); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + _client = new ReadarrClient(logger, httpClientFactory, _striker, _dryRunInterceptor); + _arrInstance = new ArrInstance + { + Name = "readarr", + Url = new Uri("http://localhost:8787/"), + ApiKey = "api-key", + }; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns(async ci => + { + Func> action = ci.Arg>>(); + return await action(); + }); + } + + #region Queue URL overrides + + [Fact] + public async Task GetQueueItemsAsync_BuildsReadarrSpecificQuery() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new QueueListResponse { TotalRecords = 0, Records = Array.Empty() }))); + + // Act + await _client.GetQueueItemsAsync(_arrInstance, 1); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue"); + request.RequestUri.Query.ShouldBe("?page=1&pageSize=200&includeUnknownAuthorItems=true&includeAuthor=true&includeBook=true"); + } + + [Fact] + public async Task DeleteQueueItemAsync_UsesV1QueuePath() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(99), removeFromClient: false, changeCategory: false, DeleteReason.Stalled); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/queue/99"); + } + + [Fact] + public async Task HealthCheckAsync_UsesV1SystemStatus() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.HealthCheckAsync(_arrInstance); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v1/system/status"); + } + + #endregion + + #region HasContentId + + [Fact] + public void HasContentId_AuthorAndBookSet_ReturnsTrue() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", AuthorId = 1, BookId = 2 }; + _client.HasContentId(record).ShouldBeTrue(); + } + + [Fact] + public void HasContentId_AuthorZero_ReturnsFalse() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", AuthorId = 0, BookId = 2 }; + _client.HasContentId(record).ShouldBeFalse(); + } + + [Fact] + public void HasContentId_BookZero_ReturnsFalse() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", AuthorId = 1, BookId = 0 }; + _client.HasContentId(record).ShouldBeFalse(); + } + + #endregion + + #region SearchItemsAsync + + [Fact] + public async Task SearchItemsAsync_NullItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, null); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_EmptyItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, new HashSet()); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_PostsBookSearchCommandWithAllIds_AndReturnsEmpty() + { + // Arrange — Readarr's SearchItemsAsync always returns [] regardless of HTTP response + RouteResponses(commandIdForPost: 22); + var items = new HashSet + { + new SearchItem { Id = 100 }, + new SearchItem { Id = 200 }, + }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBeEmpty(); + var post = _httpMessageHandler.CapturedRequests.Single(r => r.Method == HttpMethod.Post); + post.RequestUri!.AbsolutePath.ShouldBe("/api/v1/command"); + var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)]; + body.ShouldNotBeNull(); + body!.ShouldContain("\"Name\":\"BookSearch\"", Case.Insensitive); + body!.ShouldContain("\"BookIds\":[100,200]", Case.Insensitive); + } + + [Fact] + public async Task SearchItemsAsync_ServerError_Throws() + { + // Arrange + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + return Task.FromResult(JsonNullResponse()); + }); + + var items = new HashSet { new() { Id = 1 } }; + + // Act / Assert + await Should.ThrowAsync(() => _client.SearchItemsAsync(_arrInstance, items)); + } + + [Fact] + public async Task SearchItemsAsync_DryRun_DoesNotPost() + { + // Arrange — interceptor short-circuits + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns((HttpResponseMessage?)null); + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse())); + + var items = new HashSet { new() { Id = 1 } }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldNotContain(r => r.Method == HttpMethod.Post); + } + + #endregion + + #region GetAllTagsAsync + + [Fact] + public async Task GetAllTagsAsync_ThrowsNotImplemented() + { + await Should.ThrowAsync(() => _client.GetAllTagsAsync(_arrInstance)); + } + + #endregion + + #region Helpers + + private void RouteResponses(long commandIdForPost) + { + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/command")) + { + return Task.FromResult(JsonResponse(new { id = commandIdForPost })); + } + // GET log-context calls (/book/{id}) return null so log context bails out + return Task.FromResult(JsonNullResponse()); + }); + } + + private static QueueRecord BuildRecord(long id) => new() + { + Id = id, + Title = $"item-{id}", + DownloadId = id.ToString(), + Protocol = "torrent", + }; + + private static HttpResponseMessage JsonResponse(T body) => new(HttpStatusCode.OK) + { + Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"), + }; + + private static HttpResponseMessage JsonNullResponse() => new(HttpStatusCode.OK) + { + Content = new StringContent("null", Encoding.UTF8, "application/json"), + }; + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs new file mode 100644 index 00000000..950ae075 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Arr/SonarrClientTests.cs @@ -0,0 +1,456 @@ +using System.Net; +using System.Text; +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Entities.Sonarr; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Features.Arr; +using Cleanuparr.Infrastructure.Features.ItemStriker; +using Cleanuparr.Infrastructure.Interceptors; +using Cleanuparr.Infrastructure.Tests.TestHelpers; +using Cleanuparr.Persistence.Models.Configuration.Arr; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Arr; + +public class SonarrClientTests +{ + private readonly IStriker _striker; + private readonly IDryRunInterceptor _dryRunInterceptor; + private readonly FakeHttpMessageHandler _httpMessageHandler; + private readonly SonarrClient _client; + private readonly ArrInstance _arrInstance; + + public SonarrClientTests() + { + var logger = Substitute.For>(); + _striker = Substitute.For(); + _dryRunInterceptor = Substitute.For(); + _httpMessageHandler = new FakeHttpMessageHandler(); + + var httpClient = new HttpClient(_httpMessageHandler); + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(httpClient); + + _client = new SonarrClient(logger, httpClientFactory, _striker, _dryRunInterceptor); + _arrInstance = new ArrInstance + { + Name = "sonarr", + Url = new Uri("http://localhost:8989/"), + ApiKey = "api-key", + }; + + _dryRunInterceptor.IsDryRunEnabled().Returns(false); + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns(async ci => + { + Func> action = ci.Arg>>(); + return await action(); + }); + } + + #region Queue URL overrides (via GetQueueItemsAsync / DeleteQueueItemAsync / HealthCheckAsync) + + [Fact] + public async Task GetQueueItemsAsync_BuildsSonarrSpecificQuery() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse( + new QueueListResponse { TotalRecords = 0, Records = Array.Empty() }))); + + // Act + await _client.GetQueueItemsAsync(_arrInstance, 1); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/queue"); + request.RequestUri.Query.ShouldBe("?page=1&pageSize=200&includeUnknownSeriesItems=true&includeSeries=true&includeEpisode=true"); + } + + [Fact] + public async Task DeleteQueueItemAsync_UsesV3QueuePath() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.DeleteQueueItemAsync(_arrInstance, BuildRecord(123), removeFromClient: true, changeCategory: false, DeleteReason.FailedImport); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/queue/123"); + } + + [Fact] + public async Task HealthCheckAsync_UsesV3SystemStatus() + { + // Arrange + _httpMessageHandler.SetupResponse(HttpStatusCode.OK); + + // Act + await _client.HealthCheckAsync(_arrInstance); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/system/status"); + } + + #endregion + + #region HasContentId + + [Fact] + public void HasContentId_BothSeriesAndEpisodeSet_ReturnsTrue() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", SeriesId = 5, EpisodeId = 9 }; + _client.HasContentId(record).ShouldBeTrue(); + } + + [Fact] + public void HasContentId_SeriesIdZero_ReturnsFalse() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", SeriesId = 0, EpisodeId = 9 }; + _client.HasContentId(record).ShouldBeFalse(); + } + + [Fact] + public void HasContentId_EpisodeIdZero_ReturnsFalse() + { + var record = new QueueRecord { Id = 1, Title = "t", DownloadId = "h", Protocol = "torrent", SeriesId = 5, EpisodeId = 0 }; + _client.HasContentId(record).ShouldBeFalse(); + } + + #endregion + + #region SearchItemsAsync + + [Fact] + public async Task SearchItemsAsync_NullItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, null); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_EmptyItems_ReturnsEmpty() + { + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, new HashSet()); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task SearchItemsAsync_SeriesSearch_PostsSeriesCommandToCommandEndpoint() + { + // Arrange + RouteResponses(commandIdForPost: 42); + var items = new HashSet + { + new SeriesSearchItem { Id = 100, SeriesId = 100, SearchType = SeriesSearchType.Series }, + }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBe(new long[] { 42 }); + var post = _httpMessageHandler.CapturedRequests.First(r => r.Method == HttpMethod.Post); + post.RequestUri!.AbsolutePath.ShouldBe("/api/v3/command"); + var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)]; + body.ShouldNotBeNull(); + body!.ShouldContain("\"name\":\"SeriesSearch\"", Case.Insensitive); + body!.ShouldContain("\"seriesId\":100", Case.Insensitive); + } + + [Fact] + public async Task SearchItemsAsync_SeasonSearch_PostsSeasonCommandWithSeriesAndSeason() + { + // Arrange + RouteResponses(commandIdForPost: 7); + var items = new HashSet + { + new SeriesSearchItem { Id = 3, SeriesId = 100, SearchType = SeriesSearchType.Season }, + }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBe(new long[] { 7 }); + var post = _httpMessageHandler.CapturedRequests.First(r => r.Method == HttpMethod.Post); + var body = _httpMessageHandler.CapturedRequestBodies[_httpMessageHandler.CapturedRequests.IndexOf(post)]; + body.ShouldNotBeNull(); + body!.ShouldContain("\"name\":\"SeasonSearch\"", Case.Insensitive); + body!.ShouldContain("\"seriesId\":100", Case.Insensitive); + body!.ShouldContain("\"seasonNumber\":3", Case.Insensitive); + } + + [Fact] + public async Task SearchItemsAsync_MultipleEpisodes_BundlesIntoSingleCommand() + { + // Arrange + RouteResponses(commandIdForPost: 99); + var items = new HashSet + { + new SeriesSearchItem { Id = 1, SeriesId = 10, SearchType = SeriesSearchType.Episode }, + new SeriesSearchItem { Id = 2, SeriesId = 10, SearchType = SeriesSearchType.Episode }, + new SeriesSearchItem { Id = 3, SeriesId = 10, SearchType = SeriesSearchType.Episode }, + }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBe(new long[] { 99 }); + var posts = _httpMessageHandler.CapturedRequests.Where(r => r.Method == HttpMethod.Post).ToList(); + posts.Count.ShouldBe(1); + var bodyIndex = _httpMessageHandler.CapturedRequests.IndexOf(posts[0]); + var body = _httpMessageHandler.CapturedRequestBodies[bodyIndex]; + body.ShouldNotBeNull(); + body!.ShouldContain("\"name\":\"EpisodeSearch\"", Case.Insensitive); + body!.ShouldContain("\"episodeIds\":[1,2,3]", Case.Insensitive); + } + + [Fact] + public async Task SearchItemsAsync_DryRun_ReturnsEmptyAndDoesNotPost() + { + // Arrange — interceptor returns null on dry run + _dryRunInterceptor + .InterceptAsync(Arg.Any>>(), Arg.Any()) + .Returns((HttpResponseMessage?)null); + // Set up GETs that ComputeCommandLogContext might fire (series lookup) + _httpMessageHandler.SetupResponse((req, _) => Task.FromResult(JsonNullResponse())); + + var items = new HashSet + { + new SeriesSearchItem { Id = 5, SeriesId = 5, SearchType = SeriesSearchType.Series }, + }; + + // Act + var ids = await _client.SearchItemsAsync(_arrInstance, items); + + // Assert + ids.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldNotContain(r => r.Method == HttpMethod.Post); + } + + [Fact] + public async Task SearchItemsAsync_ServerErrorOnPost_ThrowsAndLogsError() + { + // Arrange — interceptor passes through; GET log-context lookups return null body; POST 500 + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + return Task.FromResult(JsonNullResponse()); + }); + + var items = new HashSet + { + new SeriesSearchItem { Id = 5, SeriesId = 5, SearchType = SeriesSearchType.Series }, + }; + + // Act / Assert + await Should.ThrowAsync(() => _client.SearchItemsAsync(_arrInstance, items)); + } + + #endregion + + #region GetAllSeriesAsync / GetAllTagsAsync / GetEpisodes / EpisodeFiles / QualityProfiles / Scores + + [Fact] + public async Task GetAllSeriesAsync_BuildsCorrectUriAndDeserializesList() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new SearchableSeries { Id = 1, Title = "Show", QualityProfileId = 2, Tags = new List() }, + }))); + + // Act + var result = await _client.GetAllSeriesAsync(_arrInstance); + + // Assert + result.Count.ShouldBe(1); + result[0].Title.ShouldBe("Show"); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/series"); + request.Headers.GetValues("x-api-key").ShouldHaveSingleItem().ShouldBe("api-key"); + } + + [Fact] + public async Task GetAllSeriesAsync_NullBody_ReturnsEmpty() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonNullResponse())); + + // Act + var result = await _client.GetAllSeriesAsync(_arrInstance); + + // Assert + result.ShouldBeEmpty(); + } + + [Fact] + public async Task GetAllTagsAsync_DeserializesListAndUsesV3Path() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new Tag { Id = 1, Label = "Anime" }, + new Tag { Id = 2, Label = "HD" }, + }))); + + // Act + var tags = await _client.GetAllTagsAsync(_arrInstance); + + // Assert + tags.Count.ShouldBe(2); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/tag"); + } + + [Fact] + public async Task GetEpisodesAsync_BuildsSeriesIdQuery() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty()))); + + // Act + await _client.GetEpisodesAsync(_arrInstance, seriesId: 42); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/episode"); + request.RequestUri.Query.ShouldBe("?seriesId=42"); + } + + [Fact] + public async Task GetEpisodeFilesAsync_BuildsSeriesIdQuery() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty()))); + + // Act + await _client.GetEpisodeFilesAsync(_arrInstance, seriesId: 7); + + // Assert + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/episodefile"); + request.RequestUri.Query.ShouldBe("?seriesId=7"); + } + + [Fact] + public async Task GetQualityProfilesAsync_DeserializesList() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new ArrQualityProfile { Id = 1, Name = "HD", CutoffFormatScore = 100 }, + }))); + + // Act + var profiles = await _client.GetQualityProfilesAsync(_arrInstance); + + // Assert + profiles.Count.ShouldBe(1); + var request = _httpMessageHandler.CapturedRequests.ShouldHaveSingleItem(); + request.RequestUri!.AbsolutePath.ShouldBe("/api/v3/qualityprofile"); + } + + [Fact] + public async Task GetEpisodeFileScoresAsync_EmptyList_MakesNoRequests() + { + // Act + var scores = await _client.GetEpisodeFileScoresAsync(_arrInstance, new List()); + + // Assert + scores.ShouldBeEmpty(); + _httpMessageHandler.CapturedRequests.ShouldBeEmpty(); + } + + [Fact] + public async Task GetEpisodeFileScoresAsync_OverHundred_BatchesIntoMultipleRequests() + { + // Arrange — 150 ids should produce 2 batches (100 + 50) + var ids = Enumerable.Range(1, 150).Select(i => (long)i).ToList(); + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(Array.Empty()))); + + // Act + await _client.GetEpisodeFileScoresAsync(_arrInstance, ids); + + // Assert + _httpMessageHandler.CapturedRequests.Count.ShouldBe(2); + _httpMessageHandler.CapturedRequests.ShouldAllBe(r => r.RequestUri!.AbsolutePath == "/api/v3/episodefile"); + } + + [Fact] + public async Task GetEpisodeFileScoresAsync_MergesScoresFromResponses() + { + // Arrange + _httpMessageHandler.SetupResponse((_, _) => Task.FromResult(JsonResponse(new[] + { + new MediaFileScore { Id = 1, CustomFormatScore = 50 }, + new MediaFileScore { Id = 2, CustomFormatScore = -30 }, + }))); + + // Act + var scores = await _client.GetEpisodeFileScoresAsync(_arrInstance, new List { 1, 2 }); + + // Assert + scores.Count.ShouldBe(2); + scores[1].ShouldBe(50); + scores[2].ShouldBe(-30); + } + + #endregion + + #region Helpers + + private void RouteResponses(long commandIdForPost) + { + _httpMessageHandler.SetupResponse((req, _) => + { + if (req.Method == HttpMethod.Post && req.RequestUri!.AbsolutePath.EndsWith("/command")) + { + return Task.FromResult(JsonResponse(new { id = commandIdForPost })); + } + // GET log-context calls (/series/{id}, /episode?...) return null so log context bails out + return Task.FromResult(JsonNullResponse()); + }); + } + + private static QueueRecord BuildRecord(long id) => new() + { + Id = id, + Title = $"item-{id}", + DownloadId = id.ToString(), + Protocol = "torrent", + }; + + private static HttpResponseMessage JsonResponse(T body) => new(HttpStatusCode.OK) + { + Content = new StringContent(JsonConvert.SerializeObject(body), Encoding.UTF8, "application/json"), + }; + + private static HttpResponseMessage JsonNullResponse() => new(HttpStatusCode.OK) + { + Content = new StringContent("null", Encoding.UTF8, "application/json"), + }; + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentAuthCacheTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentAuthCacheTests.cs new file mode 100644 index 00000000..ebd43bf8 --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentAuthCacheTests.cs @@ -0,0 +1,80 @@ +using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.UTorrent; + +public class UTorrentAuthCacheTests +{ + [Fact] + public void IsValid_AllFieldsSetAndExpiresInFuture_ReturnsTrue() + { + // Arrange + var cache = new UTorrentAuthCache + { + AuthToken = "token", + GuidCookie = "guid", + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddMinutes(5), + }; + + // Act / Assert + cache.IsValid.ShouldBeTrue(); + } + + [Fact] + public void IsValid_Expired_ReturnsFalse() + { + // Arrange + var cache = new UTorrentAuthCache + { + AuthToken = "token", + GuidCookie = "guid", + CreatedAt = DateTime.UtcNow.AddMinutes(-10), + ExpiresAt = DateTime.UtcNow.AddMinutes(-1), + }; + + // Act / Assert + cache.IsValid.ShouldBeFalse(); + } + + [Fact] + public void IsValid_MissingAuthToken_ReturnsFalse() + { + // Arrange + var cache = new UTorrentAuthCache + { + AuthToken = string.Empty, + GuidCookie = "guid", + ExpiresAt = DateTime.UtcNow.AddMinutes(5), + }; + + // Act / Assert + cache.IsValid.ShouldBeFalse(); + } + + [Fact] + public void IsValid_MissingGuidCookie_ReturnsFalse() + { + // Arrange + var cache = new UTorrentAuthCache + { + AuthToken = "token", + GuidCookie = string.Empty, + ExpiresAt = DateTime.UtcNow.AddMinutes(5), + }; + + // Act / Assert + cache.IsValid.ShouldBeFalse(); + } + + [Fact] + public void IsValid_DefaultInstance_ReturnsFalse() + { + // Arrange — defaults: empty token + cookie, ExpiresAt = MinValue + var cache = new UTorrentAuthCache(); + + // Act / Assert + cache.IsValid.ShouldBeFalse(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentResponseParserTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentResponseParserTests.cs new file mode 100644 index 00000000..d23826eb --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/DownloadClient/UTorrent/UTorrentResponseParserTests.cs @@ -0,0 +1,306 @@ +using Cleanuparr.Domain.Exceptions; +using Cleanuparr.Infrastructure.Features.DownloadClient.UTorrent; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.DownloadClient.UTorrent; + +public class UTorrentResponseParserTests +{ + private readonly UTorrentResponseParser _parser; + + public UTorrentResponseParserTests() + { + var logger = Substitute.For>(); + _parser = new UTorrentResponseParser(logger); + } + + #region ParseTorrentList + + [Fact] + public void ParseTorrentList_FullRow_PopulatesAllFields() + { + // Arrange — 27 fields per the spec; using realistic-looking values + const string json = """ + { + "build": 45816, + "torrents": [ + ["HASH123", 137, "Ubuntu.iso", 1024000, 1000, 1024000, 0, 1000, 0, 0, -1, "linux", + 5, 50, 5, 50, 1, 0, 0, "", "", "", "stream-id", 1700000000, 1700001000, "", "/downloads"] + ], + "label": [["linux", 1]] + } + """; + + // Act + var response = _parser.ParseTorrentList(json); + + // Assert + response.Build.ShouldBe(45816); + response.Torrents.Count.ShouldBe(1); + var torrent = response.Torrents[0]; + torrent.Hash.ShouldBe("HASH123"); + torrent.Status.ShouldBe(137); + torrent.Name.ShouldBe("Ubuntu.iso"); + torrent.Size.ShouldBe(1024000); + torrent.Progress.ShouldBe(1000); + torrent.SavePath.ShouldBe("/downloads"); + response.Labels.ShouldContain("linux"); + } + + [Fact] + public void ParseTorrentList_EmptyTorrentsAndLabels_ReturnsEmptyLists() + { + // Arrange + const string json = """{"build": 1, "torrents": [], "label": []}"""; + + // Act + var response = _parser.ParseTorrentList(json); + + // Assert + response.Torrents.ShouldBeEmpty(); + response.Labels.ShouldBeEmpty(); + } + + [Fact] + public void ParseTorrentList_RowShorterThan27Fields_SkipsRow() + { + // Arrange — only 5 fields per torrent + const string json = """{"build": 1, "torrents": [["HASH", 0, "name", 100, 1000]], "label": []}"""; + + // Act + var response = _parser.ParseTorrentList(json); + + // Assert — short rows are silently skipped + response.Torrents.ShouldBeEmpty(); + } + + [Fact] + public void ParseTorrentList_LabelWithEmptyName_Skipped() + { + // Arrange + const string json = """{"build": 1, "torrents": [], "label": [["", 0], ["good", 1]]}"""; + + // Act + var response = _parser.ParseTorrentList(json); + + // Assert + response.Labels.ShouldHaveSingleItem().ShouldBe("good"); + } + + [Fact] + public void ParseTorrentList_MalformedJson_ThrowsUTorrentParsingException() + { + // Arrange + const string json = "{ not valid json"; + + // Act / Assert + Should.Throw(() => _parser.ParseTorrentList(json)); + } + + [Fact] + public void ParseTorrentList_NullJsonBody_ThrowsUTorrentParsingException() + { + // Arrange + const string json = "null"; + + // Act / Assert + Should.Throw(() => _parser.ParseTorrentList(json)); + } + + #endregion + + #region ParseFileList + + [Fact] + public void ParseFileList_FullRow_PopulatesHashAndFiles() + { + // Arrange — files[0] is hash, files[1] is JArray of arrays + const string json = """ + { + "files": ["HASH123", [["movie.mkv", 1000, 500, 0], ["sub.srt", 100, 100, 1]]] + } + """; + + // Act + var response = _parser.ParseFileList(json); + + // Assert + response.Hash.ShouldBe("HASH123"); + response.Files.Count.ShouldBe(2); + response.Files[0].Name.ShouldBe("movie.mkv"); + response.Files[0].Size.ShouldBe(1000); + response.Files[0].Downloaded.ShouldBe(500); + response.Files[0].Priority.ShouldBe(0); + response.Files[1].Name.ShouldBe("sub.srt"); + response.Files[1].Priority.ShouldBe(1); + } + + [Fact] + public void ParseFileList_EmptyFilesArray_HashSetButFilesEmpty() + { + // Arrange + const string json = """{"files": ["HASH", []]}"""; + + // Act + var response = _parser.ParseFileList(json); + + // Assert + response.Hash.ShouldBe("HASH"); + response.Files.ShouldBeEmpty(); + } + + [Fact] + public void ParseFileList_FileRowShorterThan4Fields_Skipped() + { + // Arrange + const string json = """{"files": ["HASH", [["partial", 100]]]}"""; + + // Act + var response = _parser.ParseFileList(json); + + // Assert + response.Files.ShouldBeEmpty(); + } + + [Fact] + public void ParseFileList_MalformedJson_ThrowsUTorrentParsingException() + { + // Arrange + const string json = "{ broken"; + + // Act / Assert + Should.Throw(() => _parser.ParseFileList(json)); + } + + [Fact] + public void ParseFileList_NullBody_ThrowsUTorrentParsingException() + { + // Arrange + const string json = "null"; + + // Act / Assert + Should.Throw(() => _parser.ParseFileList(json)); + } + + #endregion + + #region ParseProperties + + [Fact] + public void ParseProperties_PrivateTorrent_DetectsPexNegativeOne() + { + // Arrange — property names match C# properties (parser uses default Newtonsoft naming; + // it does NOT convert snake_case to PascalCase). + const string json = """ + { + "props": [{ + "Hash": "HASH", + "Trackers": "http://tracker.example/announce", + "Pex": -1, + "SeedRatio": 1500 + }] + } + """; + + // Act + var response = _parser.ParseProperties(json); + + // Assert + response.Properties.ShouldNotBeNull(); + response.Properties!.Hash.ShouldBe("HASH"); + response.Properties.IsPrivate.ShouldBeTrue(); + response.Properties.SeedRatio.ShouldBe(1500); + response.Properties.SeedRatioValue.ShouldBe(1.5); + } + + [Fact] + public void ParseProperties_EmptyPropsArray_ReturnsDefaultProperties() + { + // Arrange + const string json = """{"props": []}"""; + + // Act + var response = _parser.ParseProperties(json); + + // Assert — no parsing happens; Properties stays at the empty default + response.Properties.ShouldNotBeNull(); + response.Properties!.Hash.ShouldBe(string.Empty); + response.Properties.IsPrivate.ShouldBeFalse(); + } + + [Fact] + public void ParseProperties_TrackersWithCrLf_SplitsIntoList() + { + // Arrange + const string json = "{\"props\": [{\"Hash\": \"H\", \"Trackers\": \"http://a/announce\\r\\nhttp://b/announce\", \"Pex\": 1}]}"; + + // Act + var response = _parser.ParseProperties(json); + + // Assert + response.Properties!.TrackerList.Count.ShouldBe(2); + response.Properties.TrackerList.ShouldContain("http://a/announce"); + response.Properties.TrackerList.ShouldContain("http://b/announce"); + } + + [Fact] + public void ParseProperties_MalformedJson_ThrowsUTorrentParsingException() + { + Should.Throw(() => _parser.ParseProperties("{ broken")); + } + + [Fact] + public void ParseProperties_NullBody_ThrowsUTorrentParsingException() + { + Should.Throw(() => _parser.ParseProperties("null")); + } + + #endregion + + #region ParseLabelList + + [Fact] + public void ParseLabelList_MixedLabels_PopulatesNonEmpty() + { + // Arrange + const string json = """{"label": [["movies", 3], ["", 0], ["tv", 5]]}"""; + + // Act + var response = _parser.ParseLabelList(json); + + // Assert + response.Labels.Count.ShouldBe(2); + response.Labels.ShouldContain("movies"); + response.Labels.ShouldContain("tv"); + } + + [Fact] + public void ParseLabelList_EmptyLabelArray_ReturnsEmptyList() + { + // Arrange + const string json = """{"label": []}"""; + + // Act + var response = _parser.ParseLabelList(json); + + // Assert + response.Labels.ShouldBeEmpty(); + } + + [Fact] + public void ParseLabelList_MalformedJson_ThrowsUTorrentParsingException() + { + Should.Throw(() => _parser.ParseLabelList("{ broken")); + } + + [Fact] + public void ParseLabelList_NullBody_ThrowsUTorrentParsingException() + { + Should.Throw(() => _parser.ParseLabelList("null")); + } + + #endregion +} diff --git a/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs new file mode 100644 index 00000000..07d1d00a --- /dev/null +++ b/code/backend/Cleanuparr.Infrastructure.Tests/Features/Jobs/GenericHandlerTests.cs @@ -0,0 +1,601 @@ +using Cleanuparr.Domain.Entities.Arr; +using Cleanuparr.Domain.Entities.Arr.Queue; +using Cleanuparr.Domain.Enums; +using Cleanuparr.Infrastructure.Events.Interfaces; +using Cleanuparr.Infrastructure.Features.Arr.Interfaces; +using Cleanuparr.Infrastructure.Features.Context; +using Cleanuparr.Infrastructure.Features.DownloadClient; +using Cleanuparr.Infrastructure.Features.DownloadRemover.Models; +using Cleanuparr.Infrastructure.Features.Jobs; +using Cleanuparr.Infrastructure.Tests.Features.Jobs.TestHelpers; +using Cleanuparr.Persistence; +using Cleanuparr.Persistence.Models.Configuration; +using Cleanuparr.Persistence.Models.Events; +using Cleanuparr.Persistence.Models.Configuration.Arr; +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 Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Infrastructure.Tests.Features.Jobs; + +public class GenericHandlerTests : IClassFixture +{ + private readonly JobHandlerFixture _fixture; + private readonly TestHandler _handler; + + public GenericHandlerTests(JobHandlerFixture fixture) + { + _fixture = fixture; + _fixture.RecreateDataContext(); + _fixture.ResetMocks(); + _handler = new TestHandler( + Substitute.For>(), + _fixture.DataContext, + _fixture.Cache, + _fixture.MessageBus, + _fixture.ArrClientFactory, + _fixture.ArrQueueIterator, + _fixture.DownloadServiceFactory, + _fixture.EventPublisher); + } + + #region GetRecordSearchItem + + [Fact] + public void GetRecordSearchItem_SonarrSingleEpisode_ReturnsEpisodeSeriesSearchItem() + { + // Arrange + var record = NewRecord(seriesId: 10, episodeId: 99); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Sonarr, 4f, record, isPack: false); + + // Assert + var seriesItem = item.ShouldBeOfType(); + seriesItem.Id.ShouldBe(99); + seriesItem.SeriesId.ShouldBe(10); + seriesItem.SearchType.ShouldBe(SeriesSearchType.Episode); + } + + [Fact] + public void GetRecordSearchItem_SonarrPack_ReturnsSeasonSeriesSearchItem() + { + // Arrange + var record = NewRecord(seriesId: 10, seasonNumber: 3); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Sonarr, 4f, record, isPack: true); + + // Assert + var seriesItem = item.ShouldBeOfType(); + seriesItem.Id.ShouldBe(3); + seriesItem.SeriesId.ShouldBe(10); + seriesItem.SearchType.ShouldBe(SeriesSearchType.Season); + } + + [Fact] + public void GetRecordSearchItem_Radarr_ReturnsMovieIdSearchItem() + { + // Arrange + var record = NewRecord(movieId: 77); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Radarr, 4f, record); + + // Assert + item.ShouldBeOfType(); + item.Id.ShouldBe(77); + } + + [Fact] + public void GetRecordSearchItem_Lidarr_ReturnsAlbumIdSearchItem() + { + // Arrange + var record = NewRecord(albumId: 55); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Lidarr, 1f, record); + + // Assert + item.Id.ShouldBe(55); + } + + [Fact] + public void GetRecordSearchItem_Readarr_ReturnsBookIdSearchItem() + { + // Arrange + var record = NewRecord(bookId: 42); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Readarr, 1f, record); + + // Assert + item.Id.ShouldBe(42); + } + + [Fact] + public void GetRecordSearchItem_WhisparrV2SingleEpisode_ReturnsSeriesSearchItem() + { + // Arrange + var record = NewRecord(seriesId: 5, episodeId: 13); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Whisparr, version: 2f, record, isPack: false); + + // Assert + var seriesItem = item.ShouldBeOfType(); + seriesItem.Id.ShouldBe(13); + seriesItem.SeriesId.ShouldBe(5); + seriesItem.SearchType.ShouldBe(SeriesSearchType.Episode); + } + + [Fact] + public void GetRecordSearchItem_WhisparrV2Pack_ReturnsSeasonSearchItem() + { + // Arrange + var record = NewRecord(seriesId: 5, seasonNumber: 2); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Whisparr, version: 2f, record, isPack: true); + + // Assert + var seriesItem = item.ShouldBeOfType(); + seriesItem.Id.ShouldBe(2); + seriesItem.SeriesId.ShouldBe(5); + seriesItem.SearchType.ShouldBe(SeriesSearchType.Season); + } + + [Fact] + public void GetRecordSearchItem_WhisparrV3_ReturnsMovieIdSearchItem() + { + // Arrange + var record = NewRecord(movieId: 88); + + // Act + var item = _handler.PublicGetRecordSearchItem(InstanceType.Whisparr, version: 3f, record); + + // Assert + item.ShouldBeOfType(); + item.Id.ShouldBe(88); + } + + #endregion + + #region ProcessArrConfigAsync + + [Fact] + public async Task ProcessArrConfigAsync_NoEnabledInstances_SkipsProcessing() + { + // Arrange + var config = new ArrConfig + { + Type = InstanceType.Sonarr, + Instances = new List + { + new ArrInstance { Name = "n", Url = new Uri("http://x"), ApiKey = "k", Enabled = false }, + }, + }; + + // Act + await _handler.PublicProcessArrConfigAsync(config); + + // Assert + _handler.ProcessInstanceCalls.ShouldBeEmpty(); + } + + [Fact] + public async Task ProcessArrConfigAsync_EnabledInstances_ProcessesEach() + { + // Arrange + var a = new ArrInstance { Name = "a", Url = new Uri("http://a"), ApiKey = "k", Enabled = true }; + var b = new ArrInstance { Name = "b", Url = new Uri("http://b"), ApiKey = "k", Enabled = true }; + var disabled = new ArrInstance { Name = "c", Url = new Uri("http://c"), ApiKey = "k", Enabled = false }; + var config = new ArrConfig + { + Type = InstanceType.Sonarr, + Instances = new List { a, b, disabled }, + }; + + // Act + await _handler.PublicProcessArrConfigAsync(config); + + // Assert + _handler.ProcessInstanceCalls.Select(i => i.Name).ShouldBe(new[] { "a", "b" }); + } + + [Fact] + public async Task ProcessArrConfigAsync_InstanceThrowsAndThrowOnFailureTrue_Rethrows() + { + // Arrange + _handler.ProcessInstanceBehavior = _ => throw new InvalidOperationException("boom"); + var config = new ArrConfig + { + Type = InstanceType.Sonarr, + Instances = new List + { + new ArrInstance { Name = "boom", Url = new Uri("http://x"), ApiKey = "k", Enabled = true }, + }, + }; + + // Act / Assert + await Should.ThrowAsync( + () => _handler.PublicProcessArrConfigAsync(config, throwOnFailure: true)); + } + + [Fact] + public async Task ProcessArrConfigAsync_InstanceThrowsAndThrowOnFailureFalse_LogsAndContinues() + { + // Arrange — first instance throws, second succeeds + _handler.ProcessInstanceBehavior = instance => + { + if (instance.Name == "bad") + { + throw new InvalidOperationException("boom"); + } + return Task.CompletedTask; + }; + var config = new ArrConfig + { + Type = InstanceType.Sonarr, + Instances = new List + { + new ArrInstance { Name = "bad", Url = new Uri("http://x"), ApiKey = "k", Enabled = true }, + new ArrInstance { Name = "good", Url = new Uri("http://y"), ApiKey = "k", Enabled = true }, + }, + }; + + // Act + await _handler.PublicProcessArrConfigAsync(config, throwOnFailure: false); + + // Assert + _handler.ProcessInstanceCalls.Select(i => i.Name).ShouldBe(new[] { "bad", "good" }); + } + + #endregion + + #region PublishQueueItemRemoveRequest + + [Fact] + public async Task PublishQueueItemRemoveRequest_AlreadyMarked_SkipsPublish() + { + // Arrange + const string key = "remove-key"; + _fixture.Cache.Set(key, true); + var arrConfig = new ArrConfig { Type = InstanceType.Sonarr, Instances = [] }; + var instance = new ArrInstance + { + Name = "s", + Url = new Uri("http://s"), + ApiKey = "k", + ArrConfig = arrConfig, + Version = 4f, + }; + + // Act + await _handler.PublicPublishQueueItemRemoveRequest( + key, + instance, + NewRecord(seriesId: 1, episodeId: 2), + isPack: false, + removeFromClient: true, + DeleteReason.FailedImport); + + // Assert + await _fixture.MessageBus.DidNotReceiveWithAnyArgs().Publish(default(object)!, default(CancellationToken)); + } + + [Fact] + public async Task PublishQueueItemRemoveRequest_Sonarr_PublishesSeriesSearchItemMessage() + { + // Arrange + var arrConfig = new ArrConfig { Type = InstanceType.Sonarr, Instances = [] }; + var instance = new ArrInstance + { + Name = "s", + Url = new Uri("http://s"), + ApiKey = "k", + ArrConfig = arrConfig, + Version = 4f, + }; + var record = NewRecord(seriesId: 1, episodeId: 2); + + // Act + await _handler.PublicPublishQueueItemRemoveRequest( + "k1", instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport); + + // Assert + await _fixture.MessageBus.Received(1) + .Publish(Arg.Any>(), Arg.Any()); + await _fixture.EventPublisher.Received(1).PublishAsync( + EventType.DownloadMarkedForDeletion, Arg.Any(), Arg.Any(), + Arg.Any?>(), Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PublishQueueItemRemoveRequest_Radarr_PublishesGenericSearchItemMessage() + { + // Arrange + var arrConfig = new ArrConfig { Type = InstanceType.Radarr, Instances = [] }; + var instance = new ArrInstance + { + Name = "r", + Url = new Uri("http://r"), + ApiKey = "k", + ArrConfig = arrConfig, + Version = 4f, + }; + var record = NewRecord(movieId: 9); + + // Act + await _handler.PublicPublishQueueItemRemoveRequest( + "k1", instance, record, isPack: false, removeFromClient: false, DeleteReason.Stalled); + + // Assert + await _fixture.MessageBus.Received(1) + .Publish(Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task PublishQueueItemRemoveRequest_WhisparrV2_PublishesSeriesSearchItemMessage() + { + // Arrange + var arrConfig = new ArrConfig { Type = InstanceType.Whisparr, Instances = [] }; + var instance = new ArrInstance + { + Name = "w2", + Url = new Uri("http://w"), + ApiKey = "k", + ArrConfig = arrConfig, + Version = 2f, + }; + var record = NewRecord(seriesId: 1, episodeId: 2); + + // Act + await _handler.PublicPublishQueueItemRemoveRequest( + "k1", instance, record, isPack: false, removeFromClient: true, DeleteReason.FailedImport); + + // Assert + await _fixture.MessageBus.Received(1) + .Publish(Arg.Any>(), Arg.Any()); + } + + #endregion + + #region GetInitializedDownloadServicesAsync + + [Fact] + public async Task GetInitializedDownloadServicesAsync_NoClientsInContext_ReturnsEmpty() + { + // Arrange + ContextProvider.Set(nameof(DownloadClientConfig), new List()); + + // Act + var services = await _handler.PublicGetInitializedDownloadServicesAsync(); + + // Assert + services.ShouldBeEmpty(); + } + + [Fact] + public async Task GetInitializedDownloadServicesAsync_AllSucceed_ReturnsAll() + { + // Arrange + var clientA = new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = "a", + Type = DownloadClientType.Torrent, + TypeName = DownloadClientTypeName.qBittorrent, + }; + var clientB = new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = "b", + Type = DownloadClientType.Torrent, + TypeName = DownloadClientTypeName.Deluge, + }; + ContextProvider.Set(nameof(DownloadClientConfig), new List { clientA, clientB }); + + var serviceA = Substitute.For(); + serviceA.LoginAsync().Returns(Task.CompletedTask); + var serviceB = Substitute.For(); + serviceB.LoginAsync().Returns(Task.CompletedTask); + _fixture.DownloadServiceFactory.GetDownloadService(clientA).Returns(serviceA); + _fixture.DownloadServiceFactory.GetDownloadService(clientB).Returns(serviceB); + + // Act + var services = await _handler.PublicGetInitializedDownloadServicesAsync(); + + // Assert + services.Count.ShouldBe(2); + } + + [Fact] + public async Task GetInitializedDownloadServicesAsync_LoginFailureForOne_SkipsThatOne() + { + // Arrange + var clientA = new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = "a", + Type = DownloadClientType.Torrent, + TypeName = DownloadClientTypeName.qBittorrent, + }; + var clientB = new DownloadClientConfig + { + Id = Guid.NewGuid(), + Name = "b", + Type = DownloadClientType.Torrent, + TypeName = DownloadClientTypeName.Deluge, + }; + ContextProvider.Set(nameof(DownloadClientConfig), new List { clientA, clientB }); + + var serviceA = Substitute.For(); + serviceA.LoginAsync().Returns(Task.FromException(new InvalidOperationException("login failed"))); + var serviceB = Substitute.For(); + serviceB.LoginAsync().Returns(Task.CompletedTask); + _fixture.DownloadServiceFactory.GetDownloadService(clientA).Returns(serviceA); + _fixture.DownloadServiceFactory.GetDownloadService(clientB).Returns(serviceB); + + // Act + var services = await _handler.PublicGetInitializedDownloadServicesAsync(); + + // Assert + services.Count.ShouldBe(1); + services[0].ShouldBeSameAs(serviceB); + } + + #endregion + + #region ExecuteAsync — populates context + + [Fact] + public async Task ExecuteAsync_PopulatesContextWithAllConfigs() + { + // Act + await _handler.ExecuteAsync(); + + // Assert — every key required downstream is captured inside ExecuteInternalAsync, + // since AsyncLocal writes don't propagate back to the caller + _handler.ExecuteInternalInvoked.ShouldBeTrue(); + _handler.CapturedGeneralConfig.ShouldNotBeNull(); + _handler.CapturedSonarrConfig!.Type.ShouldBe(InstanceType.Sonarr); + _handler.CapturedRadarrConfig!.Type.ShouldBe(InstanceType.Radarr); + _handler.CapturedLidarrConfig!.Type.ShouldBe(InstanceType.Lidarr); + _handler.CapturedReadarrConfig!.Type.ShouldBe(InstanceType.Readarr); + _handler.CapturedWhisparrConfig!.Type.ShouldBe(InstanceType.Whisparr); + _handler.CapturedQueueCleanerConfig.ShouldNotBeNull(); + _handler.CapturedContentBlockerConfig.ShouldNotBeNull(); + _handler.CapturedDownloadCleanerConfig.ShouldNotBeNull(); + _handler.CapturedDownloadClients.ShouldNotBeNull(); + } + + [Fact] + public async Task ExecuteAsync_OnlyEnabledDownloadClientsInContext() + { + // Arrange + TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "enabled-1", enabled: true); + TestDataContextFactory.AddDownloadClient(_fixture.DataContext, name: "disabled-1", typeName: DownloadClientTypeName.Deluge, enabled: false); + + // Act + await _handler.ExecuteAsync(); + + // Assert + _handler.CapturedDownloadClients.ShouldNotBeNull(); + _handler.CapturedDownloadClients!.Count.ShouldBe(1); + _handler.CapturedDownloadClients[0].Name.ShouldBe("enabled-1"); + } + + #endregion + + private static QueueRecord NewRecord( + long id = 1, + string downloadId = "h", + long seriesId = 0, + long episodeId = 0, + long seasonNumber = 0, + long movieId = 0, + long albumId = 0, + long bookId = 0) + { + return new QueueRecord + { + Id = id, + Title = $"item-{id}", + DownloadId = downloadId, + Protocol = "torrent", + SeriesId = seriesId, + EpisodeId = episodeId, + SeasonNumber = seasonNumber, + MovieId = movieId, + AlbumId = albumId, + BookId = bookId, + }; + } + + /// + /// Concrete GenericHandler subclass exposing protected members for testing. + /// + private sealed class TestHandler : GenericHandler + { + public List ProcessInstanceCalls { get; } = []; + public Func ProcessInstanceBehavior { get; set; } = _ => Task.CompletedTask; + public bool ExecuteInternalInvoked { get; private set; } + + // Snapshot of ContextProvider values, captured inside ExecuteInternalAsync. + // (AsyncLocal writes from ExecuteAsync don't propagate back to the caller.) + public GeneralConfig? CapturedGeneralConfig { get; private set; } + public ArrConfig? CapturedSonarrConfig { get; private set; } + public ArrConfig? CapturedRadarrConfig { get; private set; } + public ArrConfig? CapturedLidarrConfig { get; private set; } + public ArrConfig? CapturedReadarrConfig { get; private set; } + public ArrConfig? CapturedWhisparrConfig { get; private set; } + public QueueCleanerConfig? CapturedQueueCleanerConfig { get; private set; } + public ContentBlockerConfig? CapturedContentBlockerConfig { get; private set; } + public DownloadCleanerConfig? CapturedDownloadCleanerConfig { get; private set; } + public List? CapturedDownloadClients { get; private set; } + + public TestHandler( + ILogger logger, + DataContext dataContext, + IMemoryCache cache, + IBus messageBus, + IArrClientFactory arrClientFactory, + IArrQueueIterator arrQueueIterator, + IDownloadServiceFactory downloadServiceFactory, + IEventPublisher eventPublisher) + : base(logger, dataContext, cache, messageBus, arrClientFactory, arrQueueIterator, downloadServiceFactory, eventPublisher) + { + } + + protected override Task ExecuteInternalAsync(CancellationToken cancellationToken = default) + { + ExecuteInternalInvoked = true; + CapturedGeneralConfig = ContextProvider.Get(nameof(GeneralConfig)) as GeneralConfig; + CapturedSonarrConfig = ContextProvider.Get(nameof(InstanceType.Sonarr)) as ArrConfig; + CapturedRadarrConfig = ContextProvider.Get(nameof(InstanceType.Radarr)) as ArrConfig; + CapturedLidarrConfig = ContextProvider.Get(nameof(InstanceType.Lidarr)) as ArrConfig; + CapturedReadarrConfig = ContextProvider.Get(nameof(InstanceType.Readarr)) as ArrConfig; + CapturedWhisparrConfig = ContextProvider.Get(nameof(InstanceType.Whisparr)) as ArrConfig; + CapturedQueueCleanerConfig = ContextProvider.Get(nameof(QueueCleanerConfig)) as QueueCleanerConfig; + CapturedContentBlockerConfig = ContextProvider.Get(nameof(ContentBlockerConfig)) as ContentBlockerConfig; + CapturedDownloadCleanerConfig = ContextProvider.Get(nameof(DownloadCleanerConfig)) as DownloadCleanerConfig; + CapturedDownloadClients = ContextProvider.Get(nameof(DownloadClientConfig)) as List; + return Task.CompletedTask; + } + + protected override Task ProcessInstanceAsync(ArrInstance instance) + { + ProcessInstanceCalls.Add(instance); + return ProcessInstanceBehavior(instance); + } + + public SearchItem PublicGetRecordSearchItem(InstanceType type, float version, QueueRecord record, bool isPack = false) + => GetRecordSearchItem(type, version, record, isPack); + + public Task PublicProcessArrConfigAsync(ArrConfig config, bool throwOnFailure = false) + => ProcessArrConfigAsync(config, throwOnFailure); + + public Task PublicPublishQueueItemRemoveRequest( + string key, + ArrInstance instance, + QueueRecord record, + bool isPack, + bool removeFromClient, + DeleteReason deleteReason, + bool skipSearch = false, + DownloadClientConfig? downloadClient = null, + bool changeCategory = false) + => PublishQueueItemRemoveRequest(key, instance, record, isPack, removeFromClient, deleteReason, skipSearch, downloadClient, changeCategory); + + public async Task> PublicGetInitializedDownloadServicesAsync() + => await GetInitializedDownloadServicesAsync(); + } +} diff --git a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj index 2e6eb8fd..72086cff 100644 --- a/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj +++ b/code/backend/Cleanuparr.Infrastructure/Cleanuparr.Infrastructure.csproj @@ -12,7 +12,6 @@ - @@ -26,7 +25,6 @@ - diff --git a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs index a54492a4..60f75300 100644 --- a/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Features/DownloadClient/DownloadServiceFactory.cs @@ -1,15 +1,5 @@ using Cleanuparr.Domain.Enums; -using Cleanuparr.Infrastructure.Events; -using Cleanuparr.Infrastructure.Events.Interfaces; -using Cleanuparr.Infrastructure.Features.Files; -using Cleanuparr.Infrastructure.Features.ItemStriker; -using Cleanuparr.Infrastructure.Features.MalwareBlocker; -using Cleanuparr.Infrastructure.Helpers; -using Cleanuparr.Infrastructure.Http; -using Cleanuparr.Infrastructure.Interceptors; -using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence.Models.Configuration; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using DelugeService = Cleanuparr.Infrastructure.Features.DownloadClient.Deluge.DelugeService; @@ -27,7 +17,7 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; - + public DownloadServiceFactory( ILogger logger, IServiceProvider serviceProvider @@ -41,139 +31,22 @@ public sealed class DownloadServiceFactory : IDownloadServiceFactory /// Creates a download service using the specified client configuration /// /// The client configuration to use - /// An implementation of IDownloadService or null if the client is not available + /// An implementation of IDownloadService public IDownloadService GetDownloadService(DownloadClientConfig downloadClientConfig) { if (!downloadClientConfig.Enabled) { _logger.LogWarning("Download client {clientId} is disabled, but a service was requested", downloadClientConfig.Id); } - + return downloadClientConfig.TypeName switch { - DownloadClientTypeName.qBittorrent => CreateQBitService(downloadClientConfig), - DownloadClientTypeName.Deluge => CreateDelugeService(downloadClientConfig), - DownloadClientTypeName.Transmission => CreateTransmissionService(downloadClientConfig), - DownloadClientTypeName.uTorrent => CreateUTorrentService(downloadClientConfig), - DownloadClientTypeName.rTorrent => CreateRTorrentService(downloadClientConfig), + DownloadClientTypeName.qBittorrent => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), + DownloadClientTypeName.Deluge => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), + DownloadClientTypeName.Transmission => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), + DownloadClientTypeName.uTorrent => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), + DownloadClientTypeName.rTorrent => ActivatorUtilities.CreateInstance(_serviceProvider, downloadClientConfig), _ => throw new NotSupportedException($"Download client type {downloadClientConfig.TypeName} is not supported") }; } - - private QBitService CreateQBitService(DownloadClientConfig downloadClientConfig) - { - var logger = _serviceProvider.GetRequiredService>(); - var filenameEvaluator = _serviceProvider.GetRequiredService(); - var striker = _serviceProvider.GetRequiredService(); - var dryRunInterceptor = _serviceProvider.GetRequiredService(); - var hardLinkFileService = _serviceProvider.GetRequiredService(); - var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); - - var ruleEvaluator = _serviceProvider.GetRequiredService(); - var seedingRuleEvaluator = _serviceProvider.GetRequiredService(); - - // Create the QBitService instance - QBitService service = new( - logger, filenameEvaluator, striker, dryRunInterceptor, - hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator - ); - - return service; - } - - private DelugeService CreateDelugeService(DownloadClientConfig downloadClientConfig) - { - var logger = _serviceProvider.GetRequiredService>(); - var filenameEvaluator = _serviceProvider.GetRequiredService(); - var striker = _serviceProvider.GetRequiredService(); - var dryRunInterceptor = _serviceProvider.GetRequiredService(); - var hardLinkFileService = _serviceProvider.GetRequiredService(); - var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); - - var ruleEvaluator = _serviceProvider.GetRequiredService(); - var seedingRuleEvaluator = _serviceProvider.GetRequiredService(); - - // Create the DelugeService instance - DelugeService service = new( - logger, filenameEvaluator, striker, dryRunInterceptor, - hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator - ); - - return service; - } - - private TransmissionService CreateTransmissionService(DownloadClientConfig downloadClientConfig) - { - var logger = _serviceProvider.GetRequiredService>(); - var filenameEvaluator = _serviceProvider.GetRequiredService(); - var striker = _serviceProvider.GetRequiredService(); - var dryRunInterceptor = _serviceProvider.GetRequiredService(); - var hardLinkFileService = _serviceProvider.GetRequiredService(); - var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); - - var ruleEvaluator = _serviceProvider.GetRequiredService(); - var seedingRuleEvaluator = _serviceProvider.GetRequiredService(); - - // Create the TransmissionService instance - TransmissionService service = new( - logger, filenameEvaluator, striker, dryRunInterceptor, - hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator - ); - - return service; - } - - private UTorrentService CreateUTorrentService(DownloadClientConfig downloadClientConfig) - { - var logger = _serviceProvider.GetRequiredService>(); - var cache = _serviceProvider.GetRequiredService(); - var filenameEvaluator = _serviceProvider.GetRequiredService(); - var striker = _serviceProvider.GetRequiredService(); - var dryRunInterceptor = _serviceProvider.GetRequiredService(); - var hardLinkFileService = _serviceProvider.GetRequiredService(); - var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); - var loggerFactory = _serviceProvider.GetRequiredService(); - - var ruleEvaluator = _serviceProvider.GetRequiredService(); - var seedingRuleEvaluator = _serviceProvider.GetRequiredService(); - - // Create the UTorrentService instance - UTorrentService service = new( - logger, cache, filenameEvaluator, striker, dryRunInterceptor, - hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, loggerFactory, ruleEvaluator, seedingRuleEvaluator - ); - - return service; - } - - private RTorrentService CreateRTorrentService(DownloadClientConfig downloadClientConfig) - { - var logger = _serviceProvider.GetRequiredService>(); - var filenameEvaluator = _serviceProvider.GetRequiredService(); - var striker = _serviceProvider.GetRequiredService(); - var dryRunInterceptor = _serviceProvider.GetRequiredService(); - var hardLinkFileService = _serviceProvider.GetRequiredService(); - var httpClientProvider = _serviceProvider.GetRequiredService(); - var eventPublisher = _serviceProvider.GetRequiredService(); - var blocklistProvider = _serviceProvider.GetRequiredService(); - - var ruleEvaluator = _serviceProvider.GetRequiredService(); - var seedingRuleEvaluator = _serviceProvider.GetRequiredService(); - - // Create the RTorrentService instance - RTorrentService service = new( - logger, filenameEvaluator, striker, dryRunInterceptor, - hardLinkFileService, httpClientProvider, eventPublisher, blocklistProvider, downloadClientConfig, ruleEvaluator, seedingRuleEvaluator - ); - - return service; - } -} \ No newline at end of file +} diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs index 4eeda34d..fad83e1f 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientFactory.cs @@ -1,7 +1,9 @@ using System.Net; using Cleanuparr.Domain.Enums; using Cleanuparr.Persistence.Models.Configuration.General; +using Microsoft.Extensions.Http; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; @@ -12,18 +14,18 @@ public class DynamicHttpClientFactory : IDynamicHttpClientFactory { private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientConfigStore _configStore; - private readonly IHttpClientOptionsInvalidator _optionsInvalidator; + private readonly IOptionsMonitorCache _optionsCache; private readonly ILogger _logger; public DynamicHttpClientFactory( - IHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, IHttpClientConfigStore configStore, - IHttpClientOptionsInvalidator optionsInvalidator, + IOptionsMonitorCache optionsCache, ILogger logger) { _httpClientFactory = httpClientFactory; _configStore = configStore; - _optionsInvalidator = optionsInvalidator; + _optionsCache = optionsCache; _logger = logger; } @@ -102,10 +104,10 @@ public class DynamicHttpClientFactory : IDynamicHttpClientFactory public void UnregisterConfiguration(string clientName) { _configStore.RemoveConfiguration(clientName); - + // Also invalidate the cached options for this client - _optionsInvalidator.InvalidateClient(clientName); - + _optionsCache.TryRemove(clientName); + _logger.LogDebug("Unregistered and invalidated HTTP client configuration: {ClientName}", clientName); } @@ -142,8 +144,11 @@ public class DynamicHttpClientFactory : IDynamicHttpClientFactory // CRITICAL: Invalidate IHttpClientFactory's cached configurations // This forces the factory to call our Configure() method again with updated settings var clientNames = updatedConfigurations.Select(kvp => kvp.Key).ToList(); - _optionsInvalidator.InvalidateClients(clientNames); - + foreach (string clientName in clientNames) + { + _optionsCache.TryRemove(clientName); + } + _logger.LogInformation("Updated and invalidated {Count} HTTP client configurations with new general settings: " + "Timeout={Timeout}s, MaxRetries={MaxRetries}, CertificateValidation={CertValidation}", updatedConfigurations.Count, @@ -151,15 +156,4 @@ public class DynamicHttpClientFactory : IDynamicHttpClientFactory generalConfig.HttpMaxRetries, generalConfig.HttpCertificateValidation); } - - public IEnumerable GetRegisteredClientNames() - { - return _configStore.GetAllConfigurations().Select(kvp => kvp.Key); - } - - public void InvalidateAllCachedConfigurations() - { - _optionsInvalidator.InvalidateAllClients(); - _logger.LogInformation("Force invalidated all HTTP client option caches"); - } -} \ No newline at end of file +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientServiceCollectionExtensions.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientServiceCollectionExtensions.cs index ffd93bb7..f3d3910b 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientServiceCollectionExtensions.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/DynamicHttpClientServiceCollectionExtensions.cs @@ -19,10 +19,7 @@ public static class DynamicHttpClientServiceCollectionExtensions services.AddSingleton(); services.AddSingleton, DynamicHttpClientConfiguration>(); services.AddSingleton(); - - // Register the cache invalidation service - services.AddSingleton(); - + // Add base HttpClient factory services.AddHttpClient(); diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientOptionsInvalidator.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientOptionsInvalidator.cs deleted file mode 100644 index a9c03002..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/HttpClientOptionsInvalidator.cs +++ /dev/null @@ -1,72 +0,0 @@ -using Microsoft.Extensions.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; - -/// -/// Implementation of HTTP client options invalidator using cache manipulation -/// -public class HttpClientOptionsInvalidator : IHttpClientOptionsInvalidator -{ - private readonly IOptionsMonitorCache _optionsCache; - private readonly ILogger _logger; - - public HttpClientOptionsInvalidator( - IOptionsMonitorCache optionsCache, - ILogger logger) - { - _optionsCache = optionsCache; - _logger = logger; - } - - public void InvalidateClient(string clientName) - { - try - { - // Remove the cached configuration for this specific client - _optionsCache.TryRemove(clientName); - - _logger.LogDebug("Invalidated HTTP client options cache for client: {ClientName}", clientName); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to invalidate HTTP client options cache for client: {ClientName}", clientName); - } - } - - public void InvalidateAllClients() - { - try - { - // Clear the entire options cache - _optionsCache.Clear(); - - _logger.LogDebug("Invalidated all HTTP client options cache entries"); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to invalidate all HTTP client options cache entries"); - } - } - - public void InvalidateClients(IEnumerable clientNames) - { - var clientNamesList = clientNames.ToList(); - - try - { - foreach (var clientName in clientNamesList) - { - _optionsCache.TryRemove(clientName); - } - - _logger.LogDebug("Invalidated HTTP client options cache for {Count} clients: {ClientNames}", - clientNamesList.Count, string.Join(", ", clientNamesList)); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to invalidate HTTP client options cache for multiple clients"); - } - } -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs index 160bc5cf..560861d0 100644 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs +++ b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IDynamicHttpClientFactory.cs @@ -47,14 +47,4 @@ public interface IDynamicHttpClientFactory /// Updates all registered HTTP client configurations with new general config settings /// void UpdateAllClientsFromGeneralConfig(GeneralConfig generalConfig); - - /// - /// Gets all currently registered client names - /// - IEnumerable GetRegisteredClientNames(); - - /// - /// Forces cache invalidation for all registered clients (for debugging/testing) - /// - void InvalidateAllCachedConfigurations(); -} \ No newline at end of file +} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IHttpClientOptionsInvalidator.cs b/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IHttpClientOptionsInvalidator.cs deleted file mode 100644 index 9020bc85..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Http/DynamicHttpClientSystem/IHttpClientOptionsInvalidator.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace Cleanuparr.Infrastructure.Http.DynamicHttpClientSystem; - -/// -/// Service for invalidating cached HTTP client configurations -/// -public interface IHttpClientOptionsInvalidator -{ - /// - /// Invalidates the cached configuration for a specific client name - /// - /// The name of the client to invalidate - void InvalidateClient(string clientName); - - /// - /// Invalidates all cached HTTP client configurations - /// - void InvalidateAllClients(); - - /// - /// Invalidates multiple client configurations - /// - /// The names of the clients to invalidate - void InvalidateClients(IEnumerable clientNames); -} \ No newline at end of file diff --git a/code/backend/Cleanuparr.Infrastructure/Utilities/ITrackerPatternMatcher.cs b/code/backend/Cleanuparr.Infrastructure/Utilities/ITrackerPatternMatcher.cs deleted file mode 100644 index d0b4017b..00000000 --- a/code/backend/Cleanuparr.Infrastructure/Utilities/ITrackerPatternMatcher.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Cleanuparr.Infrastructure.Utilities; - -/// -/// Interface for tracker pattern matching functionality. -/// -public interface ITrackerPatternMatcher -{ - /// - /// Checks if any tracker host matches any of the provided patterns. - /// - bool MatchesAny(IReadOnlyList trackerHosts, IReadOnlyList patterns); - - /// - /// Checks if a tracker host matches a specific pattern. - /// - bool Matches(string trackerHost, string pattern); -} diff --git a/code/frontend/src/app/core/api/index.ts b/code/frontend/src/app/core/api/index.ts deleted file mode 100644 index 1b9a3b28..00000000 --- a/code/frontend/src/app/core/api/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { GeneralConfigApi } from './general-config.api'; -export { QueueCleanerApi } from './queue-cleaner.api'; -export { MalwareBlockerApi } from './malware-blocker.api'; -export { DownloadCleanerApi } from './download-cleaner.api'; -export { BlacklistSyncApi } from './blacklist-sync.api'; -export { ArrApi } from './arr.api'; -export { DownloadClientApi } from './download-client.api'; -export { NotificationApi } from './notification.api'; -export { JobsApi } from './jobs.api'; -export { EventsApi } from './events.api'; -export { SystemApi } from './system.api'; -export { StrikesApi } from './strikes.api'; diff --git a/code/frontend/src/app/core/models/index.ts b/code/frontend/src/app/core/models/index.ts deleted file mode 100644 index 7afb8d90..00000000 --- a/code/frontend/src/app/core/models/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './event.models'; -export * from './job.models'; -export * from './signalr.models'; -export * from './app-status.model'; -export * from './pagination.model'; -export * from './search-stats.models'; diff --git a/code/frontend/src/app/core/models/signalr.models.ts b/code/frontend/src/app/core/models/signalr.models.ts index e3d26a97..7b02051b 100644 --- a/code/frontend/src/app/core/models/signalr.models.ts +++ b/code/frontend/src/app/core/models/signalr.models.ts @@ -1,11 +1,3 @@ -export interface SignalRHubConfig { - hubUrl: string; - maxReconnectAttempts: number; - reconnectDelayMs: number; - bufferSize: number; - healthCheckIntervalMs: number; -} - export interface LogEntry { timestamp: Date; level: string; diff --git a/code/frontend/src/app/core/realtime/app-hub.service.ts b/code/frontend/src/app/core/realtime/app-hub.service.ts index 8897e00a..de43c970 100644 --- a/code/frontend/src/app/core/realtime/app-hub.service.ts +++ b/code/frontend/src/app/core/realtime/app-hub.service.ts @@ -1,25 +1,28 @@ -import { Injectable, signal } from '@angular/core'; +import { Injectable, OnDestroy, inject, signal } from '@angular/core'; import * as signalR from '@microsoft/signalr'; -import { HubService } from './hub.service'; -import { SignalRHubConfig, LogEntry } from '@core/models/signalr.models'; +import { firstValueFrom } from 'rxjs'; +import { LogEntry } from '@core/models/signalr.models'; import { AppEvent, ManualEvent } from '@core/models/event.models'; import { JobInfo } from '@core/models/job.models'; import { AppStatus } from '@core/models/app-status.model'; import { RecentStrike } from '@core/models/strike.models'; +import { ApplicationPathService } from '@core/services/base-path.service'; +import { AuthService } from '@core/auth/auth.service'; const MAX_BUFFER = 1000; +const HUB_URL = '/api/hubs/app'; +const RECONNECT_DELAY_MS = 2000; @Injectable({ providedIn: 'root' }) -export class AppHubService extends HubService { - protected readonly config: SignalRHubConfig = { - hubUrl: '/api/hubs/app', - maxReconnectAttempts: 0, // infinite - reconnectDelayMs: 2000, - bufferSize: MAX_BUFFER, - healthCheckIntervalMs: 0, - }; +export class AppHubService implements OnDestroy { + private readonly pathService = inject(ApplicationPathService); + private readonly authService = inject(AuthService); + private connection: signalR.HubConnection | null = null; + private reconnectTimeout: ReturnType | null = null; + + private readonly connected = signal(false); + readonly isConnected = this.connected.asReadonly(); - // Signal-based state private readonly _logs = signal([]); private readonly _events = signal([]); private readonly _manualEvents = signal([]); @@ -38,7 +41,78 @@ export class AppHubService extends HubService { readonly cfScoresVersion = this._cfScoresVersion.asReadonly(); readonly searchStatsVersion = this._searchStatsVersion.asReadonly(); - protected registerHandlers(connection: signalR.HubConnection): void { + async start(): Promise { + if (this.connection) return; + + const hubUrl = this.pathService.buildHubUrl(HUB_URL); + + this.connection = new signalR.HubConnectionBuilder() + .withUrl(hubUrl, { + accessTokenFactory: async () => { + if (!this.authService.getAccessToken() && !localStorage.getItem('refresh_token')) { + return ''; + } + if (this.authService.isTokenExpired(30)) { + const result = await firstValueFrom(this.authService.refreshToken()); + if (result) { + return result.accessToken; + } + return ''; + } + return this.authService.getAccessToken() ?? ''; + }, + }) + .withAutomaticReconnect({ + nextRetryDelayInMilliseconds: (retryContext) => + Math.min(RECONNECT_DELAY_MS * Math.pow(2, retryContext.previousRetryCount), 30_000), + }) + .configureLogging(signalR.LogLevel.Warning) + .build(); + + this.connection.onreconnecting(() => this.connected.set(false)); + this.connection.onreconnected(() => { + this.connected.set(true); + this.requestInitialData(); + }); + this.connection.onclose(() => this.connected.set(false)); + + this.registerHandlers(this.connection); + + try { + await this.connection.start(); + this.connected.set(true); + this.requestInitialData(); + } catch (err) { + console.warn('[SignalR] Connection failed:', err); + this.connection = null; + this.reconnectTimeout = setTimeout(() => this.start(), RECONNECT_DELAY_MS); + } + } + + async stop(): Promise { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + if (this.connection) { + await this.connection.stop(); + this.connection = null; + } + this.connected.set(false); + } + + ngOnDestroy(): void { + this.stop(); + } + + private invoke(method: string, ...args: unknown[]): Promise { + if (!this.connection || this.connection.state !== signalR.HubConnectionState.Connected) { + return Promise.resolve(); + } + return this.connection.invoke(method, ...args); + } + + private registerHandlers(connection: signalR.HubConnection): void { // Single log entry connection.on('LogReceived', (log: LogEntry) => { this._logs.update((logs) => { @@ -127,17 +201,13 @@ export class AppHubService extends HubService { }); } - protected override onConnected(): void { + private requestInitialData(): void { this.requestRecentLogs(); this.requestRecentEvents(); this.requestRecentStrikes(); this.requestJobStatus(); } - protected override onReconnected(): void { - this.onConnected(); - } - requestRecentLogs(): void { this.invoke('GetRecentLogs'); } diff --git a/code/frontend/src/app/core/realtime/hub.service.ts b/code/frontend/src/app/core/realtime/hub.service.ts deleted file mode 100644 index 41503621..00000000 --- a/code/frontend/src/app/core/realtime/hub.service.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Injectable, inject, signal, OnDestroy } from '@angular/core'; -import * as signalR from '@microsoft/signalr'; -import { firstValueFrom } from 'rxjs'; -import { SignalRHubConfig } from '@core/models/signalr.models'; -import { ApplicationPathService } from '@core/services/base-path.service'; -import { AuthService } from '@core/auth/auth.service'; - -@Injectable() -export abstract class HubService implements OnDestroy { - private readonly pathService = inject(ApplicationPathService); - private readonly authService = inject(AuthService); - private connection: signalR.HubConnection | null = null; - private reconnectAttempts = 0; - private reconnectTimeout: ReturnType | null = null; - - protected readonly connected = signal(false); - - readonly isConnected = this.connected.asReadonly(); - - protected abstract readonly config: SignalRHubConfig; - - protected abstract registerHandlers(connection: signalR.HubConnection): void; - - async start(): Promise { - if (this.connection) return; - - const hubUrl = this.pathService.buildHubUrl(this.config.hubUrl); - - this.connection = new signalR.HubConnectionBuilder() - .withUrl(hubUrl, { - accessTokenFactory: async () => { - // No tokens stored — trusted network bypass, no token needed - if (!this.authService.getAccessToken() && !localStorage.getItem('refresh_token')) { - return ''; - } - if (this.authService.isTokenExpired(30)) { - const result = await firstValueFrom(this.authService.refreshToken()); - if (result) { - return result.accessToken; - } - return ''; - } - return this.authService.getAccessToken() ?? ''; - }, - }) - .withAutomaticReconnect({ - nextRetryDelayInMilliseconds: (retryContext) => { - return Math.min( - this.config.reconnectDelayMs * Math.pow(2, retryContext.previousRetryCount), - 30_000, - ); - }, - }) - .configureLogging(signalR.LogLevel.Warning) - .build(); - - this.connection.onreconnecting(() => { - this.connected.set(false); - }); - - this.connection.onreconnected(() => { - this.connected.set(true); - this.reconnectAttempts = 0; - this.onReconnected(); - }); - - this.connection.onclose(() => { - this.connected.set(false); - this.scheduleReconnect(); - }); - - this.registerHandlers(this.connection); - - try { - await this.connection.start(); - this.connected.set(true); - this.reconnectAttempts = 0; - this.onConnected(); - } catch (err) { - console.warn('[SignalR] Connection failed:', err); - this.scheduleReconnect(); - } - } - - async stop(): Promise { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; - } - if (this.connection) { - await this.connection.stop(); - this.connection = null; - } - this.connected.set(false); - } - - protected invoke(method: string, ...args: unknown[]): Promise { - if (!this.connection || this.connection.state !== signalR.HubConnectionState.Connected) { - return Promise.resolve(); - } - return this.connection.invoke(method, ...args); - } - - protected onConnected(): void { - // Optional hook for subclasses. - } - protected onReconnected(): void { - // Optional hook for subclasses. - } - - ngOnDestroy(): void { - this.stop(); - } - - private scheduleReconnect(): void { - const maxAttempts = this.config.maxReconnectAttempts; - if (maxAttempts > 0 && this.reconnectAttempts >= maxAttempts) return; - - const delay = Math.min( - this.config.reconnectDelayMs * Math.pow(2, this.reconnectAttempts), - 30_000, - ); - this.reconnectAttempts++; - - this.reconnectTimeout = setTimeout(() => { - this.connection = null; - this.start(); - }, delay); - } -} diff --git a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html index ea3581f5..e14de45c 100644 --- a/code/frontend/src/app/features/settings/download-clients/download-clients.component.html +++ b/code/frontend/src/app/features/settings/download-clients/download-clients.component.html @@ -64,7 +64,7 @@ [error]="clientForm.name().errors()[0]?.message" helpKey="download-client:name" /> = {}; - if (m.typeName === DownloadClientTypeName.Deluge && m.username !== '') { + if (newType === DownloadClientTypeName.Deluge && m.username !== '') { patch.username = ''; } - const autofill = AUTOFILL_URL_BASES[m.typeName]; + const autofill = AUTOFILL_URL_BASES[newType]; const replaceable = m.urlBase === '' || Object.values(AUTOFILL_URL_BASES).includes(m.urlBase); if (autofill && replaceable) { patch.urlBase = autofill; diff --git a/code/frontend/src/app/shared/models/index.ts b/code/frontend/src/app/shared/models/index.ts deleted file mode 100644 index 300fef41..00000000 --- a/code/frontend/src/app/shared/models/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export * from './enums'; -export * from './general-config.model'; -export * from './arr-config.model'; -export * from './download-client-config.model'; -export * from './queue-cleaner-config.model'; -export * from './queue-rule.model'; -export * from './malware-blocker-config.model'; -export * from './download-cleaner-config.model'; -export * from './blacklist-sync-config.model'; -export * from './notification-provider.model'; diff --git a/e2e/.gitignore b/e2e/.gitignore index 785154b4..729df230 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -3,3 +3,4 @@ test-results/ playwright-report/ blob-report/ test-data/ +playwright/.auth/ diff --git a/e2e/docker-compose.e2e.yml b/e2e/docker-compose.e2e.yml index 2607767f..f813791c 100644 --- a/e2e/docker-compose.e2e.yml +++ b/e2e/docker-compose.e2e.yml @@ -20,6 +20,26 @@ services: retries: 30 start_period: 30s + wiremock-arr: + image: wiremock/wiremock:3.10.0 + network_mode: host + command: ["--port", "9100", "--disable-banner"] + + wiremock-dlc: + image: wiremock/wiremock:3.10.0 + network_mode: host + command: ["--port", "9200", "--disable-banner"] + + wiremock-notify: + image: wiremock/wiremock:3.10.0 + network_mode: host + command: ["--port", "9300", "--disable-banner"] + + wiremock-blocklist: + image: wiremock/wiremock:3.10.0 + network_mode: host + command: ["--port", "9400", "--disable-banner"] + app: build: context: ../code @@ -30,11 +50,21 @@ services: depends_on: keycloak: condition: service_healthy + wiremock-arr: + condition: service_started + wiremock-dlc: + condition: service_started + wiremock-notify: + condition: service_started + wiremock-blocklist: + condition: service_started extra_hosts: - "host.docker.internal:host-gateway" environment: PORT: "5000" HTTP_PORTS: "5000" + # Ephemeral /config — wiped on every container restart. The e2e harness + # restarts the app container between spec folders for a clean baseline. tmpfs: - /config volumes: diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 13f1e662..cde3bf30 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -5,18 +5,35 @@ "packages": { "": { "name": "cleanuparr-e2e", + "dependencies": { + "@microsoft/signalr": "^8.0.0" + }, "devDependencies": { - "@playwright/test": "^1.50.0" + "@playwright/test": "^1.50.0", + "@types/node": "^22.10.0" + } + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz", + "integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -25,6 +42,56 @@ "node": ">=18" } }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -40,14 +107,34 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -60,9 +147,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -71,6 +158,129 @@ "engines": { "node": ">=18" } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } } } } diff --git a/e2e/package.json b/e2e/package.json index 90f89054..1d555c88 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,9 +4,14 @@ "scripts": { "test": "playwright test", "test:headed": "playwright test --headed", - "test:debug": "playwright test --debug" + "test:debug": "playwright test --debug", + "test:regression": "playwright test --project=setup:regression --project=regression" + }, + "dependencies": { + "@microsoft/signalr": "^8.0.0" }, "devDependencies": { - "@playwright/test": "^1.50.0" + "@playwright/test": "^1.50.0", + "@types/node": "^22.10.0" } } diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 2827abb9..c3b866b5 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,28 +1,54 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig, Project } from '@playwright/test'; + +// Each spec folder is its own Playwright project, paired with a "setup" project +// that restarts the app container and re-bootstraps the admin before the +// folder's tests run. Folder = isolation boundary. +const FOLDERS = [ + 'account', + 'arr', + 'auth', + 'blacklist-sync', + 'core', + 'download-cleaner', + 'download-client', + 'general', + 'malware-blocker', + 'notifications', + 'oidc', + 'queue-cleaner', + 'regression', + 'seeker', + 'signalr', +] as const; + +function projectsFor(folder: string): Project[] { + return [ + { + name: `setup:${folder}`, + testMatch: `tests/_setup/${folder}.setup.ts`, + use: { browserName: 'chromium' }, + }, + { + name: folder, + testDir: `tests/${folder}`, + use: { browserName: 'chromium' }, + dependencies: [`setup:${folder}`], + }, + ]; +} export default defineConfig({ testDir: './tests', globalSetup: './tests/global-setup.ts', timeout: 60_000, retries: 1, - workers: 1, + workers: 1, // Serial — projects share the single app container. use: { baseURL: 'http://localhost:5000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, - projects: [ - { - name: 'api', - testIgnore: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|orphaned-files-unreachable-client|malware-blocker|dead-torrent-cleanup)\.spec\.ts$/, - use: { browserName: 'chromium' }, - }, - { - name: 'download-clients', - testMatch: /(?:^|[\\/])(?:orphaned-files-cleanup|orphaned-files-behaviors|orphaned-files-unreachable-client|malware-blocker|dead-torrent-cleanup)\.spec\.ts$/, - use: { browserName: 'chromium' }, - }, - ], + projects: FOLDERS.flatMap(projectsFor), reporter: [['html', { open: 'never' }], ['list']], }); diff --git a/e2e/tests/_setup/account.setup.ts b/e2e/tests/_setup/account.setup.ts new file mode 100644 index 00000000..5169e0e9 --- /dev/null +++ b/e2e/tests/_setup/account.setup.ts @@ -0,0 +1,7 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for account specs', async () => { + // Account specs include OIDC config CRUD which expects OIDC to be enabled. + await restartAppAndWait({ configureOidc: true }); +}); diff --git a/e2e/tests/_setup/arr.setup.ts b/e2e/tests/_setup/arr.setup.ts new file mode 100644 index 00000000..065ace70 --- /dev/null +++ b/e2e/tests/_setup/arr.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for arr specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/auth.setup.ts b/e2e/tests/_setup/auth.setup.ts new file mode 100644 index 00000000..33ccf412 --- /dev/null +++ b/e2e/tests/_setup/auth.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for auth specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/blacklist-sync.setup.ts b/e2e/tests/_setup/blacklist-sync.setup.ts new file mode 100644 index 00000000..938592b0 --- /dev/null +++ b/e2e/tests/_setup/blacklist-sync.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for blacklist-sync specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/core.setup.ts b/e2e/tests/_setup/core.setup.ts new file mode 100644 index 00000000..8d87c2cf --- /dev/null +++ b/e2e/tests/_setup/core.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for core specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/download-cleaner.setup.ts b/e2e/tests/_setup/download-cleaner.setup.ts new file mode 100644 index 00000000..7dfd2056 --- /dev/null +++ b/e2e/tests/_setup/download-cleaner.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for download-cleaner specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/download-client.setup.ts b/e2e/tests/_setup/download-client.setup.ts new file mode 100644 index 00000000..23f773b3 --- /dev/null +++ b/e2e/tests/_setup/download-client.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for download-client specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/general.setup.ts b/e2e/tests/_setup/general.setup.ts new file mode 100644 index 00000000..90a72df2 --- /dev/null +++ b/e2e/tests/_setup/general.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for general specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/malware-blocker.setup.ts b/e2e/tests/_setup/malware-blocker.setup.ts new file mode 100644 index 00000000..534b34b7 --- /dev/null +++ b/e2e/tests/_setup/malware-blocker.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for malware-blocker specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/notifications.setup.ts b/e2e/tests/_setup/notifications.setup.ts new file mode 100644 index 00000000..37b2e8e7 --- /dev/null +++ b/e2e/tests/_setup/notifications.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for notifications specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/oidc.setup.ts b/e2e/tests/_setup/oidc.setup.ts new file mode 100644 index 00000000..af84dca8 --- /dev/null +++ b/e2e/tests/_setup/oidc.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for oidc specs (with OIDC configured)', async () => { + await restartAppAndWait({ configureOidc: true }); +}); diff --git a/e2e/tests/_setup/queue-cleaner.setup.ts b/e2e/tests/_setup/queue-cleaner.setup.ts new file mode 100644 index 00000000..75b08f66 --- /dev/null +++ b/e2e/tests/_setup/queue-cleaner.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for queue-cleaner specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/regression.setup.ts b/e2e/tests/_setup/regression.setup.ts new file mode 100644 index 00000000..429d06cd --- /dev/null +++ b/e2e/tests/_setup/regression.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for regression specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/seeker.setup.ts b/e2e/tests/_setup/seeker.setup.ts new file mode 100644 index 00000000..bac5e0ca --- /dev/null +++ b/e2e/tests/_setup/seeker.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for seeker specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/_setup/signalr.setup.ts b/e2e/tests/_setup/signalr.setup.ts new file mode 100644 index 00000000..59af8d92 --- /dev/null +++ b/e2e/tests/_setup/signalr.setup.ts @@ -0,0 +1,6 @@ +import { test as setup } from '@playwright/test'; +import { restartAppAndWait } from '../helpers/test-lifecycle'; + +setup('reset app for signalr specs', async () => { + await restartAppAndWait(); +}); diff --git a/e2e/tests/account-settings-ui.spec.ts b/e2e/tests/account/account-settings-ui.spec.ts similarity index 97% rename from e2e/tests/account-settings-ui.spec.ts rename to e2e/tests/account/account-settings-ui.spec.ts index 08844ff4..8161b987 100644 --- a/e2e/tests/account-settings-ui.spec.ts +++ b/e2e/tests/account/account-settings-ui.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { loginAndGotoSettings, textInput } from './helpers/ui'; +import { loginAndGotoSettings, textInput } from '../helpers/ui'; // Behavior-parity spec for the non-OIDC Account settings sections. // (OIDC is covered separately in oidc-settings-ui.spec.ts.) diff --git a/e2e/tests/account/oidc-config.api.spec.ts b/e2e/tests/account/oidc-config.api.spec.ts new file mode 100644 index 00000000..f688f3a7 --- /dev/null +++ b/e2e/tests/account/oidc-config.api.spec.ts @@ -0,0 +1,41 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; + +test.describe('Account — OIDC config CRUD', () => { + test.afterEach(async ({ api }) => { + await api.account.patchOidcConfig({ + enabled: true, + issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, + clientId: TEST_CONFIG.clientId, + clientSecret: TEST_CONFIG.clientSecret, + scopes: 'openid profile email', + providerName: TEST_CONFIG.oidcProviderName, + exclusiveMode: false, + }); + }); + + test('GET returns the current OIDC config', async ({ api }) => { + const res = await api.account.getOidcConfig(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('enabled'); + expect(body).toHaveProperty('issuerUrl'); + expect(body).toHaveProperty('clientId'); + }); + + test('PUT persists provider name change', async ({ api }) => { + await api.account.patchOidcConfig({ providerName: 'CustomName' }); + const after = await (await api.account.getOidcConfig()).json(); + expect(after.providerName).toBe('CustomName'); + }); + + test('PUT can disable OIDC', async ({ api }) => { + await api.account.patchOidcConfig({ enabled: false }); + const after = await (await api.account.getOidcConfig()).json(); + expect(after.enabled).toBe(false); + }); + + test('PUT requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.account.updateOidcConfig({ enabled: true }); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/account/profile.api.spec.ts b/e2e/tests/account/profile.api.spec.ts new file mode 100644 index 00000000..307e0e3c --- /dev/null +++ b/e2e/tests/account/profile.api.spec.ts @@ -0,0 +1,17 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; + +test.describe('Account — profile', () => { + test('GET /api/account returns admin info', async ({ api }) => { + const res = await api.account.get(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.username).toBe(TEST_CONFIG.adminUsername); + expect(typeof body.twoFactorEnabled).toBe('boolean'); + expect(typeof body.plexLinked).toBe('boolean'); + }); + + test('GET /api/account requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.account.get(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/arr/arr-config.api.spec.ts b/e2e/tests/arr/arr-config.api.spec.ts new file mode 100644 index 00000000..b560887e --- /dev/null +++ b/e2e/tests/arr/arr-config.api.spec.ts @@ -0,0 +1,29 @@ +import { test, expect } from '../fixtures/base'; +import type { ArrType } from '../helpers/api/arr'; + +const ARR_TYPES: ArrType[] = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr']; + +test.describe('Arr — base config CRUD', () => { + for (const type of ARR_TYPES) { + test(`GET /api/configuration/${type} returns config + instances`, async ({ api }) => { + const res = await api.arr.getConfig(type); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.instances)).toBe(true); + }); + + test(`PUT /api/configuration/${type} accepts valid config`, async ({ api }) => { + const current = await (await api.arr.getConfig(type)).json(); + const res = await api.arr.updateConfig(type, { + ...current, + failedImportMaxStrikes: 3, + }); + expect(res.status).toBe(200); + }); + + test(`GET /api/configuration/${type} requires auth`, async ({ anonymousApi }) => { + const res = await anonymousApi.arr.getConfig(type); + expect(res.status).toBe(401); + }); + } +}); diff --git a/e2e/tests/arr/arr-instances.api.spec.ts b/e2e/tests/arr/arr-instances.api.spec.ts new file mode 100644 index 00000000..d3146983 --- /dev/null +++ b/e2e/tests/arr/arr-instances.api.spec.ts @@ -0,0 +1,69 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import type { ArrType } from '../helpers/api/arr'; +import { ArrStubs } from '../helpers/mocks'; + +const ARR_TYPES: ArrType[] = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr']; + +test.describe('Arr — instance CRUD', () => { + for (const type of ARR_TYPES) { + test(`${type}: create + list + update + delete instance`, async ({ api, mocks }) => { + await mocks.arr.stub(ArrStubs.arrHealthStub()); + + const create = await api.arr.createInstance(type, { + name: `${type}-e2e`, + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'e2e-test-key', + version: 3, + enabled: true, + }); + expect(create.status).toBeLessThan(300); + const created = await create.json(); + expect(created.id).toBeTruthy(); + expect(created.name).toBe(`${type}-e2e`); + + const listed = await (await api.arr.getConfig(type)).json(); + expect(listed.instances.some((i: { id: string }) => i.id === created.id)).toBe(true); + + const update = await api.arr.updateInstance(type, created.id, { + name: `${type}-renamed`, + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'e2e-test-key', + version: 3, + enabled: false, + }); + expect(update.ok).toBe(true); + const updated = await update.json(); + expect(updated.name).toBe(`${type}-renamed`); + expect(updated.enabled).toBe(false); + + const del = await api.arr.deleteInstance(type, created.id); + expect(del.status).toBe(204); + + const after = await (await api.arr.getConfig(type)).json(); + expect(after.instances.some((i: { id: string }) => i.id === created.id)).toBe(false); + }); + + test(`${type}: rejects instance with missing apiKey`, async ({ api }) => { + const res = await api.arr.createInstance(type, { + name: `${type}-bad`, + url: TEST_CONFIG.mocks.arrUrl, + apiKey: '', + version: 3, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + test(`${type}: rejects or rejects-with-server-error on malformed url`, async ({ api }) => { + const res = await api.arr.createInstance(type, { + name: `${type}-bad-url`, + url: 'not-a-url', + apiKey: 'k', + version: 3, + }); + // Backend currently surfaces malformed URLs as 500 rather than 400 — both + // are acceptable so long as the instance is not created. + expect(res.status).toBeGreaterThanOrEqual(400); + }); + } +}); diff --git a/e2e/tests/arr/arr-test-connection.api.spec.ts b/e2e/tests/arr/arr-test-connection.api.spec.ts new file mode 100644 index 00000000..5a388227 --- /dev/null +++ b/e2e/tests/arr/arr-test-connection.api.spec.ts @@ -0,0 +1,41 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import type { ArrType } from '../helpers/api/arr'; +import { ArrStubs } from '../helpers/mocks'; + +const ARR_TYPES: ArrType[] = ['sonarr', 'radarr', 'lidarr', 'readarr', 'whisparr']; + +test.describe('Arr — test connection', () => { + for (const type of ARR_TYPES) { + test(`${type}: returns success when WireMock returns 200`, async ({ api, mocks }) => { + await mocks.arr.stub(ArrStubs.arrHealthStub()); + const res = await api.arr.testInstance(type, { + name: `${type}-conn`, + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'good-key', + version: 3, + }); + expect(res.ok).toBe(true); + }); + + test(`${type}: returns failure when WireMock returns 401`, async ({ api, mocks }) => { + await mocks.arr.stub(ArrStubs.arrUnauthorizedStub('/api/v[0-9]+/system/status')); + const res = await api.arr.testInstance(type, { + name: `${type}-conn-bad`, + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'bad-key', + version: 3, + }); + expect(res.ok).toBe(false); + }); + + test(`${type}: returns failure when host unreachable`, async ({ api }) => { + const res = await api.arr.testInstance(type, { + name: `${type}-conn-down`, + url: 'http://127.0.0.1:1', + apiKey: 'k', + version: 3, + }); + expect(res.ok).toBe(false); + }); + } +}); diff --git a/e2e/tests/arr-ui.spec.ts b/e2e/tests/arr/arr-ui.spec.ts similarity index 97% rename from e2e/tests/arr-ui.spec.ts rename to e2e/tests/arr/arr-ui.spec.ts index e0e9607b..6257009b 100644 --- a/e2e/tests/arr-ui.spec.ts +++ b/e2e/tests/arr/arr-ui.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { loginAndGotoSettings } from './helpers/ui'; +import { loginAndGotoSettings } from '../helpers/ui'; // Behavior-parity spec for the Arr (Sonarr) instance create/edit modal. test.describe('Arr Settings UI', () => { diff --git a/e2e/tests/auth/api-key.api.spec.ts b/e2e/tests/auth/api-key.api.spec.ts new file mode 100644 index 00000000..ba006504 --- /dev/null +++ b/e2e/tests/auth/api-key.api.spec.ts @@ -0,0 +1,39 @@ +import { test, expect, TEST_CONFIG, CleanuparrApi } from '../fixtures/base'; + +test.describe('Account — API key', () => { + test('GET returns the current key', async ({ api }) => { + const res = await api.account.getApiKey(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.apiKey).toBe('string'); + expect(body.apiKey.length).toBeGreaterThan(8); + }); + + test('regenerate returns a new key', async ({ api }) => { + const before = await (await api.account.getApiKey()).json(); + const regen = await api.account.regenerateApiKey(); + expect(regen.status).toBe(200); + const after = await regen.json(); + expect(after.apiKey).toBeTruthy(); + expect(after.apiKey).not.toBe(before.apiKey); + }); + + test('X-API-Key header authenticates requests', async ({ api }) => { + const apiKey = (await (await api.account.getApiKey()).json()).apiKey as string; + + const res = await fetch(`${TEST_CONFIG.appUrl}/api/configuration/general`, { + headers: { 'X-API-Key': apiKey }, + }); + expect(res.status).toBe(200); + }); + + test('regenerating invalidates the old key', async ({ api }) => { + const oldKey = (await (await api.account.getApiKey()).json()).apiKey as string; + await api.account.regenerateApiKey(); + + const res = await fetch(`${TEST_CONFIG.appUrl}/api/configuration/general`, { + headers: { 'X-API-Key': oldKey }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/auth/login.api.spec.ts b/e2e/tests/auth/login.api.spec.ts new file mode 100644 index 00000000..13a764b8 --- /dev/null +++ b/e2e/tests/auth/login.api.spec.ts @@ -0,0 +1,95 @@ +import { test, expect, CleanuparrApi, TEST_CONFIG } from '../fixtures/base'; + +test.describe('Auth — login + refresh + logout', () => { + test('admin login returns access + refresh tokens', async ({ api }) => { + const res = await api.auth.login(TEST_CONFIG.adminUsername, TEST_CONFIG.adminPassword); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.requiresTwoFactor).toBe(false); + expect(body.tokens.accessToken).toBeTruthy(); + expect(body.tokens.refreshToken).toBeTruthy(); + expect(body.tokens.expiresIn).toBeGreaterThan(0); + }); + + // Wrong-password and unknown-user. The backend treats every failed login + // as if it were for the first user (admin) — so even non-matching + // usernames advance the admin lockout counter. We tolerate either 401 + // (counter cleared) or 429 (counter still running off a prior attempt + // that hadn't fully drained from EF Core's pooled connection view) and + // simply assert the response is a rejection. + test('login rejects wrong password and unknown user', async ({ anonymousApi }) => { + // Drive a successful login first to force the backend to clear its + // cached lockout view. + await anonymousApi.auth.login(TEST_CONFIG.adminUsername, TEST_CONFIG.adminPassword); + + const wrong = await anonymousApi.auth.login(TEST_CONFIG.adminUsername, 'wrong-password'); + expect([401, 429]).toContain(wrong.status); + + await anonymousApi.auth.login(TEST_CONFIG.adminUsername, TEST_CONFIG.adminPassword); + + const unknown = await anonymousApi.auth.login('does-not-exist', 'whatever'); + expect([401, 429]).toContain(unknown.status); + + // Reset the counter again so subsequent tests in this file aren't poisoned. + await anonymousApi.auth.login(TEST_CONFIG.adminUsername, TEST_CONFIG.adminPassword); + }); + + test('refresh rotates the refresh token', async ({ anonymousApi }) => { + // The preceding "wrong password / unknown user" test can leave a short + // lockout window open on the admin account. Tolerate it by retrying the + // login once the backend says it's safe. + let first: { accessToken: string; refreshToken: string; expiresIn: number } | null = null; + for (let attempt = 0; attempt < 3 && !first; attempt++) { + const res = await anonymousApi.auth.login( + TEST_CONFIG.adminUsername, + TEST_CONFIG.adminPassword, + ); + if (res.status === 429) { + const body = await res.json().catch(() => ({})); + const wait = (body.retryAfterSeconds ?? 2) * 1000 + 200; + await new Promise((r) => setTimeout(r, wait)); + continue; + } + const data = await res.json(); + first = data.tokens; + } + if (!first) { + throw new Error('Could not obtain admin tokens after retrying past the lockout window'); + } + + const refreshed = await anonymousApi.auth.refresh(first.refreshToken); + expect(refreshed.status).toBe(200); + const body = await refreshed.json(); + expect(body.accessToken).toBeTruthy(); + expect(body.refreshToken).toBeTruthy(); + expect(body.refreshToken).not.toBe(first.refreshToken); + }); + + test('refresh with revoked token returns 401', async ({ anonymousApi }) => { + const tokens = await anonymousApi.auth.loginAndCaptureTokens( + TEST_CONFIG.adminUsername, + TEST_CONFIG.adminPassword, + ); + const logout = await anonymousApi.auth.logout(tokens.refreshToken); + expect(logout.ok).toBe(true); + const res = await anonymousApi.auth.refresh(tokens.refreshToken); + expect(res.status).toBe(401); + }); + + test('protected endpoint requires bearer token', async ({ anonymousApi }) => { + const res = await anonymousApi.general.getConfig(); + expect(res.status).toBe(401); + }); + + test('protected endpoint accepts valid bearer token', async ({ api }) => { + const res = await api.general.getConfig(); + expect(res.status).toBe(200); + }); + + test('auth status returns setup-completed state', async ({ anonymousApi }) => { + const res = await anonymousApi.auth.status(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.setupCompleted).toBe(true); + }); +}); diff --git a/e2e/tests/auth/password.api.spec.ts b/e2e/tests/auth/password.api.spec.ts new file mode 100644 index 00000000..fb2983c7 --- /dev/null +++ b/e2e/tests/auth/password.api.spec.ts @@ -0,0 +1,34 @@ +import { test, expect, TEST_CONFIG, CleanuparrApi } from '../fixtures/base'; + +test.describe('Account — change password', () => { + test('changing password revokes refresh tokens and new password works', async ({ api, anonymousApi }) => { + const original = TEST_CONFIG.adminPassword; + const next = 'NewE2ePass123!@#'; + + const tokens = await anonymousApi.auth.loginAndCaptureTokens( + TEST_CONFIG.adminUsername, + original, + ); + + const change = await api.account.changePassword(original, next); + expect(change.ok).toBe(true); + + try { + const refresh = await anonymousApi.auth.refresh(tokens.refreshToken); + expect(refresh.status).toBe(401); + + const newLogin = await anonymousApi.auth.login(TEST_CONFIG.adminUsername, next); + expect(newLogin.status).toBe(200); + } finally { + const restore = await anonymousApi.auth.loginAndCaptureTokens(TEST_CONFIG.adminUsername, next); + const restoreApi = new CleanuparrApi({ token: restore.accessToken }); + await restoreApi.account.changePassword(next, original); + } + }); + + test('changing password with wrong current password returns 400 or 401', async ({ api }) => { + const res = await api.account.changePassword('definitely-not-the-password', 'whatever-new'); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); +}); diff --git a/e2e/tests/token-refresh-transient-failure.spec.ts b/e2e/tests/auth/token-refresh-transient-failure.spec.ts similarity index 96% rename from e2e/tests/token-refresh-transient-failure.spec.ts rename to e2e/tests/auth/token-refresh-transient-failure.spec.ts index a9f4dac0..468f822a 100644 --- a/e2e/tests/token-refresh-transient-failure.spec.ts +++ b/e2e/tests/auth/token-refresh-transient-failure.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { loginViaBrowser } from './helpers/ui'; +import { TEST_CONFIG } from '../helpers/test-config'; +import { loginViaBrowser } from '../helpers/ui'; /** * A transient failure of the refresh endpoint (server restarting, network blip, diff --git a/e2e/tests/blacklist-sync/blacklist-sync-config.api.spec.ts b/e2e/tests/blacklist-sync/blacklist-sync-config.api.spec.ts new file mode 100644 index 00000000..70c95b91 --- /dev/null +++ b/e2e/tests/blacklist-sync/blacklist-sync-config.api.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('BlacklistSync — config', () => { + test('GET returns config singleton', async ({ api }) => { + const res = await api.blacklistSync.getConfig(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('enabled'); + expect(body).toHaveProperty('cronExpression'); + }); + + test('PUT toggles enabled + cron (requires blacklistPath when enabled)', async ({ api }) => { + const before = await (await api.blacklistSync.getConfig()).json(); + const res = await api.blacklistSync.updateConfig({ + ...before, + enabled: true, + cronExpression: '0 0 0/6 * * ?', + blacklistPath: 'https://example.com/blacklist.txt', + }); + expect(res.ok).toBe(true); + + const after = await (await api.blacklistSync.getConfig()).json(); + expect(after.enabled).toBe(true); + expect(after.blacklistPath).toBe('https://example.com/blacklist.txt'); + // The backend normalises cron to a server-managed schedule rather than + // echoing the input expression verbatim — just check it's a non-empty cron. + expect(typeof after.cronExpression).toBe('string'); + expect(after.cronExpression.length).toBeGreaterThan(0); + + await api.blacklistSync.updateConfig(before); + }); + + test('PUT rejects enabling without a blacklist path', async ({ api }) => { + const before = await (await api.blacklistSync.getConfig()).json(); + const res = await api.blacklistSync.updateConfig({ + ...before, + enabled: true, + blacklistPath: null, + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); +}); diff --git a/e2e/tests/blacklist-sync-ui.spec.ts b/e2e/tests/blacklist-sync/blacklist-sync-ui.spec.ts similarity index 98% rename from e2e/tests/blacklist-sync-ui.spec.ts rename to e2e/tests/blacklist-sync/blacklist-sync-ui.spec.ts index 3494a40a..fe818885 100644 --- a/e2e/tests/blacklist-sync-ui.spec.ts +++ b/e2e/tests/blacklist-sync/blacklist-sync-ui.spec.ts @@ -5,7 +5,7 @@ import { textInput, expectGuardOnLeave, expectNoGuardOnLeave, -} from './helpers/ui'; +} from '../helpers/ui'; // Behavior-parity spec for the Blacklist Sync settings form (Signal Forms migration). // Authored against pre-migration (main) behavior; must stay green after the migration. diff --git a/e2e/tests/core/events.api.spec.ts b/e2e/tests/core/events.api.spec.ts new file mode 100644 index 00000000..2dfebddb --- /dev/null +++ b/e2e/tests/core/events.api.spec.ts @@ -0,0 +1,54 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Core — events', () => { + test('GET /api/events returns paginated payload', async ({ api }) => { + const res = await api.events.list({ page: 1, pageSize: 10 }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + expect(Array.isArray(body.items) || Array.isArray(body.records)).toBe(true); + }); + + test('GET /types returns string array', async ({ api }) => { + const res = await api.events.types(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + expect(body.every((s: unknown) => typeof s === 'string')).toBe(true); + }); + + test('GET /severities returns string array', async ({ api }) => { + const res = await api.events.severities(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('GET /api/events accepts severity filter', async ({ api }) => { + const res = await api.events.list({ severity: 'info', page: 1, pageSize: 5 }); + expect(res.status).toBe(200); + }); + + test('GET /api/events accepts date range filter', async ({ api }) => { + const res = await api.events.list({ + fromDate: '2024-01-01T00:00:00Z', + toDate: '2099-01-01T00:00:00Z', + }); + expect(res.status).toBe(200); + }); + + test('GET unknown id returns 404', async ({ api }) => { + const res = await api.events.get('00000000-0000-0000-0000-000000000000'); + expect([404, 400]).toContain(res.status); + }); + + test('POST /cleanup returns deletion count', async ({ api }) => { + const res = await api.events.cleanup(0); + expect(res.status).toBe(200); + }); + + test('GET /api/events requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.events.list(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/core/health.api.spec.ts b/e2e/tests/core/health.api.spec.ts new file mode 100644 index 00000000..8ca13f13 --- /dev/null +++ b/e2e/tests/core/health.api.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Core — health endpoints', () => { + test('GET /health returns liveness payload (anonymous)', async ({ anonymousApi }) => { + const res = await anonymousApi.health.liveness(); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body.length).toBeGreaterThan(0); + }); + + test('GET /health/ready returns readiness payload (anonymous)', async ({ anonymousApi }) => { + const res = await anonymousApi.health.readiness(); + expect(res.status).toBe(200); + }); + + test('GET /health/detailed returns full health info', async ({ api }) => { + const res = await api.health.detailed(); + expect(res.status).toBe(200); + }); + + test('GET /api/health returns download client health array', async ({ api }) => { + const res = await api.health.downloadClients(); + expect(res.status).toBe(200); + }); + + test('POST /api/health/check triggers full check', async ({ api }) => { + const res = await api.health.triggerCheck(); + expect(res.status).toBeLessThan(300); + }); + + test('GET /api/health requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.health.downloadClients(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/core/jobs.api.spec.ts b/e2e/tests/core/jobs.api.spec.ts new file mode 100644 index 00000000..da927bf9 --- /dev/null +++ b/e2e/tests/core/jobs.api.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '../fixtures/base'; + +const TRIGGERABLE = ['QueueCleaner', 'MalwareBlocker', 'DownloadCleaner', 'BlacklistSync'] as const; + +test.describe('Core — jobs', () => { + test('GET /api/jobs returns array of job statuses', async ({ api }) => { + const res = await api.jobs.list(); + expect(res.status).toBe(200); + const body = await res.json(); + const jobs = Array.isArray(body) ? body : body.jobs ?? body.items ?? []; + expect(Array.isArray(jobs)).toBe(true); + expect(jobs.length).toBeGreaterThan(0); + }); + + test('GET /api/jobs/{type} returns single job info', async ({ api }) => { + const res = await api.jobs.get('QueueCleaner'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('jobType'); + }); + + for (const type of TRIGGERABLE) { + test(`POST /trigger ${type} returns success or a 4xx if the job is not configured`, async ({ api }) => { + const res = await api.jobs.trigger(type); + // BlacklistSync refuses to trigger until a blacklist path is configured + // (returns 400). Other jobs will run immediately. + expect(res.status === 200 || (res.status >= 400 && res.status < 500)).toBe(true); + }); + } + + test('POST /trigger Seeker is rejected', async ({ api }) => { + const res = await api.jobs.trigger('Seeker'); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + test('PUT /schedule rejects invalid interval', async ({ api }) => { + const res = await api.jobs.updateSchedule('QueueCleaner', { every: 7, type: 'Minutes' }); + // Backend surfaces invalid intervals as either 400 (validation) or 500 + // (unhandled ValidationException) depending on the job — both are rejection. + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + test('PUT /schedule accepts valid interval', async ({ api }) => { + const res = await api.jobs.updateSchedule('QueueCleaner', { every: 30, type: 'Minutes' }); + expect(res.ok).toBe(true); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.jobs.list(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/core/manual-events.api.spec.ts b/e2e/tests/core/manual-events.api.spec.ts new file mode 100644 index 00000000..60618dcb --- /dev/null +++ b/e2e/tests/core/manual-events.api.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Core — manual events', () => { + test('GET /api/manual-events returns paginated payload', async ({ api }) => { + const res = await api.manualEvents.list({ page: 1, pageSize: 10 }); + expect(res.status).toBe(200); + }); + + test('GET /stats returns counters', async ({ api }) => { + const res = await api.manualEvents.stats(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('totalEvents'); + expect(body).toHaveProperty('unresolvedEvents'); + expect(body).toHaveProperty('resolvedEvents'); + }); + + test('GET /severities returns array', async ({ api }) => { + const res = await api.manualEvents.severities(); + expect(res.status).toBe(200); + expect(Array.isArray(await res.json())).toBe(true); + }); + + test('GET unknown id returns 404 or 400', async ({ api }) => { + const res = await api.manualEvents.get('00000000-0000-0000-0000-000000000000'); + expect([404, 400]).toContain(res.status); + }); + + test('POST /cleanup returns deletion count', async ({ api }) => { + const res = await api.manualEvents.cleanup(0); + expect(res.status).toBe(200); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.manualEvents.list(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/core/stats.api.spec.ts b/e2e/tests/core/stats.api.spec.ts new file mode 100644 index 00000000..2710ceef --- /dev/null +++ b/e2e/tests/core/stats.api.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Core — stats', () => { + test('GET /api/stats returns aggregate', async ({ api }) => { + const res = await api.stats.get(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + }); + + test('GET supports hours parameter', async ({ api }) => { + const res = await api.stats.get({ hours: 1 }); + expect(res.status).toBe(200); + }); + + test('GET with includeEvents=1 includes recent events', async ({ api }) => { + const res = await api.stats.get({ includeEvents: 1 }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.events).toHaveProperty('recentItems'); + expect(Array.isArray(body.events.recentItems)).toBe(true); + }); + + test('GET with includeStrikes=1 includes recent strikes', async ({ api }) => { + const res = await api.stats.get({ includeStrikes: 1 }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.strikes).toHaveProperty('recentItems'); + expect(Array.isArray(body.strikes.recentItems)).toBe(true); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.stats.get(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/core/status.api.spec.ts b/e2e/tests/core/status.api.spec.ts new file mode 100644 index 00000000..6bd475ce --- /dev/null +++ b/e2e/tests/core/status.api.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Core — status', () => { + test('GET /api/status returns system info', async ({ api }) => { + const res = await api.status.system(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + }); + + test('GET /api/status/download-client returns 200', async ({ api }) => { + const res = await api.status.downloadClients(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + }); + + test('GET /api/status/arrs returns arr-keyed health buckets', async ({ api }) => { + const res = await api.status.arrs(); + expect(res.status).toBe(200); + const body = await res.json(); + // Response uses PascalCase keys: { Sonarr: [], Radarr: [], Lidarr: [], ... }. + const keys = Object.keys(body).map((k) => k.toLowerCase()); + expect(keys).toEqual(expect.arrayContaining(['sonarr', 'radarr'])); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.status.system(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/core/strikes.api.spec.ts b/e2e/tests/core/strikes.api.spec.ts new file mode 100644 index 00000000..8c75b59a --- /dev/null +++ b/e2e/tests/core/strikes.api.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Core — strikes', () => { + test('GET /api/strikes returns paginated payload', async ({ api }) => { + const res = await api.strikes.list({ page: 1, pageSize: 10 }); + expect(res.status).toBe(200); + }); + + test('GET /recent returns array', async ({ api }) => { + const res = await api.strikes.recent(5); + expect(res.status).toBe(200); + expect(Array.isArray(await res.json())).toBe(true); + }); + + test('GET /types returns string array', async ({ api }) => { + const res = await api.strikes.types(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body)).toBe(true); + }); + + test('DELETE on unknown id returns 404 or 204', async ({ api }) => { + const res = await api.strikes.delete('00000000-0000-0000-0000-000000000000'); + expect([204, 404, 400]).toContain(res.status); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.strikes.list(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/dead-torrent-cleanup.spec.ts b/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts similarity index 96% rename from e2e/tests/dead-torrent-cleanup.spec.ts rename to e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts index bac9a37e..29f9bd4a 100644 --- a/e2e/tests/dead-torrent-cleanup.spec.ts +++ b/e2e/tests/download-cleaner/dead-torrent-cleanup.spec.ts @@ -10,14 +10,14 @@ import { getDownloadCleanerConfig, updateDeadTorrentConfig, triggerJob, -} from './helpers/app-api'; -import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent'; -import { TransmissionDriver } from './helpers/torrent-clients/transmission'; -import { DelugeDriver } from './helpers/torrent-clients/deluge'; -import { UTorrentDriver } from './helpers/torrent-clients/utorrent'; -import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; +} from '../helpers/app-api'; +import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent'; +import { TransmissionDriver } from '../helpers/torrent-clients/transmission'; +import { DelugeDriver } from '../helpers/torrent-clients/deluge'; +import { UTorrentDriver } from '../helpers/torrent-clients/utorrent'; +import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from '../helpers/torrent-fixtures'; -const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); const CLIENT_DOWNLOADS = '/downloads'; const TARGET = 'cleanuparr-dead'; const MAX_STRIKES = 3; diff --git a/e2e/tests/dead-torrent-config-api.spec.ts b/e2e/tests/download-cleaner/dead-torrent-config-api.spec.ts similarity index 99% rename from e2e/tests/dead-torrent-config-api.spec.ts rename to e2e/tests/download-cleaner/dead-torrent-config-api.spec.ts index bc356e54..c85a15a0 100644 --- a/e2e/tests/dead-torrent-config-api.spec.ts +++ b/e2e/tests/download-cleaner/dead-torrent-config-api.spec.ts @@ -6,7 +6,7 @@ import { getDeadTorrentConfig, updateDeadTorrentConfig, getDownloadCleanerConfig, -} from './helpers/app-api'; +} from '../helpers/app-api'; test.describe.serial('Dead Torrent Config API', () => { let token: string; diff --git a/e2e/tests/download-cleaner-api.spec.ts b/e2e/tests/download-cleaner/download-cleaner-config.api.spec.ts similarity index 52% rename from e2e/tests/download-cleaner-api.spec.ts rename to e2e/tests/download-cleaner/download-cleaner-config.api.spec.ts index b41c74d7..aae26cff 100644 --- a/e2e/tests/download-cleaner-api.spec.ts +++ b/e2e/tests/download-cleaner/download-cleaner-config.api.spec.ts @@ -1,19 +1,8 @@ -import { test, expect } from '@playwright/test'; -import { - loginAndGetToken, - getDownloadCleanerConfig, - updateDownloadCleanerConfig, -} from './helpers/app-api'; +import { test, expect } from '../fixtures/base'; -test.describe.serial('Download Cleaner Config API', () => { - let token: string; - - test.beforeAll(async () => { - token = await loginAndGetToken(); - }); - - test('should return default download cleaner config', async () => { - const res = await getDownloadCleanerConfig(token); +test.describe.serial('DownloadCleaner — config', () => { + test('returns default config with clients + ignored downloads', async ({ api }) => { + const res = await api.downloadCleaner.getConfig(); expect(res.status).toBe(200); const body = await res.json(); @@ -25,11 +14,10 @@ test.describe.serial('Download Cleaner Config API', () => { expect(Array.isArray(body.clients)).toBe(true); }); - test('should update global download cleaner config', async () => { - const getRes = await getDownloadCleanerConfig(token); - const current = await getRes.json(); + test('updates global download cleaner config', async ({ api }) => { + const current = await (await api.downloadCleaner.getConfig()).json(); - const updateRes = await updateDownloadCleanerConfig(token, { + const updateRes = await api.downloadCleaner.updateConfig({ enabled: !current.enabled, cronExpression: current.cronExpression, useAdvancedScheduling: current.useAdvancedScheduling, @@ -37,13 +25,10 @@ test.describe.serial('Download Cleaner Config API', () => { }); expect(updateRes.status).toBe(200); - // Verify the update persisted - const verifyRes = await getDownloadCleanerConfig(token); - const updated = await verifyRes.json(); + const updated = await (await api.downloadCleaner.getConfig()).json(); expect(updated.enabled).toBe(!current.enabled); - // Restore original - await updateDownloadCleanerConfig(token, { + await api.downloadCleaner.updateConfig({ enabled: current.enabled, cronExpression: current.cronExpression, useAdvancedScheduling: current.useAdvancedScheduling, @@ -51,11 +36,10 @@ test.describe.serial('Download Cleaner Config API', () => { }); }); - test('should update ignored downloads list', async () => { - const getRes = await getDownloadCleanerConfig(token); - const current = await getRes.json(); + test('updates ignored downloads list', async ({ api }) => { + const current = await (await api.downloadCleaner.getConfig()).json(); - const updateRes = await updateDownloadCleanerConfig(token, { + const updateRes = await api.downloadCleaner.updateConfig({ enabled: current.enabled, cronExpression: current.cronExpression, useAdvancedScheduling: current.useAdvancedScheduling, @@ -63,12 +47,10 @@ test.describe.serial('Download Cleaner Config API', () => { }); expect(updateRes.status).toBe(200); - const verifyRes = await getDownloadCleanerConfig(token); - const updated = await verifyRes.json(); + const updated = await (await api.downloadCleaner.getConfig()).json(); expect(updated.ignoredDownloads).toContain('test-ignored-hash-123'); - // Restore original - await updateDownloadCleanerConfig(token, { + await api.downloadCleaner.updateConfig({ enabled: current.enabled, cronExpression: current.cronExpression, useAdvancedScheduling: current.useAdvancedScheduling, @@ -76,11 +58,9 @@ test.describe.serial('Download Cleaner Config API', () => { }); }); - test('should reject invalid cron expression', async () => { - const getRes = await getDownloadCleanerConfig(token); - const current = await getRes.json(); - - const res = await updateDownloadCleanerConfig(token, { + test('rejects invalid cron expression', async ({ api }) => { + const current = await (await api.downloadCleaner.getConfig()).json(); + const res = await api.downloadCleaner.updateConfig({ enabled: current.enabled, cronExpression: 'not-a-valid-cron', useAdvancedScheduling: true, @@ -88,4 +68,9 @@ test.describe.serial('Download Cleaner Config API', () => { }); expect(res.status).toBeGreaterThanOrEqual(400); }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.downloadCleaner.getConfig(); + expect(res.status).toBe(401); + }); }); diff --git a/e2e/tests/download-cleaner-ui.spec.ts b/e2e/tests/download-cleaner/download-cleaner-ui.spec.ts similarity index 98% rename from e2e/tests/download-cleaner-ui.spec.ts rename to e2e/tests/download-cleaner/download-cleaner-ui.spec.ts index a02d50a5..85080207 100644 --- a/e2e/tests/download-cleaner-ui.spec.ts +++ b/e2e/tests/download-cleaner/download-cleaner-ui.spec.ts @@ -4,7 +4,7 @@ import { createDownloadClient, deleteDownloadClient, listDownloadClients, -} from './helpers/app-api'; +} from '../helpers/app-api'; import { loginAndGotoSettings, toggle, @@ -12,7 +12,7 @@ import { selectOption, ensureToggle, ensureAccordionExpanded, -} from './helpers/ui'; +} from '../helpers/ui'; const CLIENT_A = 'E2E DC One'; const CLIENT_B = 'E2E DC Two'; diff --git a/e2e/tests/orphaned-files-behaviors.spec.ts b/e2e/tests/download-cleaner/orphaned-files-behaviors.spec.ts similarity index 97% rename from e2e/tests/orphaned-files-behaviors.spec.ts rename to e2e/tests/download-cleaner/orphaned-files-behaviors.spec.ts index 77d69f58..37fcdc6e 100644 --- a/e2e/tests/orphaned-files-behaviors.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-behaviors.spec.ts @@ -13,9 +13,9 @@ import { updateGeneralConfig, triggerJob, OrphanedFilesConfigRequest, -} from './helpers/app-api'; -import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent'; -import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; +} from '../helpers/app-api'; +import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent'; +import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from '../helpers/torrent-fixtures'; /** * Behavior-level coverage for the orphaned files cleaner that isn't @@ -36,7 +36,7 @@ import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helper * cannot be portably backdated. That scenario is covered by unit tests. */ -const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); const APP_DOWNLOADS = '/e2e-downloads'; const SLUG = 'qbittorrent-behaviors'; const HOST_SCAN_DIR = join(HOST_DOWNLOADS, SLUG); diff --git a/e2e/tests/orphaned-files-cleanup.spec.ts b/e2e/tests/download-cleaner/orphaned-files-cleanup.spec.ts similarity index 97% rename from e2e/tests/orphaned-files-cleanup.spec.ts rename to e2e/tests/download-cleaner/orphaned-files-cleanup.spec.ts index 049ffe5c..52e0cc39 100644 --- a/e2e/tests/orphaned-files-cleanup.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-cleanup.spec.ts @@ -10,8 +10,8 @@ import { getDownloadCleanerConfig, updateOrphanedFilesConfig, triggerJob, -} from './helpers/app-api'; -import { ALL_CLIENTS, TorrentClientFixture } from './helpers/torrent-clients'; +} from '../helpers/app-api'; +import { ALL_CLIENTS, TorrentClientFixture } from '../helpers/torrent-clients'; import { buildFolderTorrent, buildMultiFileTorrent, @@ -19,7 +19,7 @@ import { chmodIgnoringEPERM, resetDirectory, GeneratedTorrent, -} from './helpers/torrent-fixtures'; +} from '../helpers/torrent-fixtures'; async function waitForTorrents( driver: { listTorrents(): Promise> }, @@ -73,7 +73,7 @@ async function waitForOrphanMove(dir: string, expectedName: string, timeoutMs = * resolve into the scan directory. */ -const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); const CLIENT_DOWNLOADS = '/downloads'; const APP_DOWNLOADS = '/e2e-downloads'; diff --git a/e2e/tests/orphaned-files-content-path.spec.ts b/e2e/tests/download-cleaner/orphaned-files-content-path.spec.ts similarity index 95% rename from e2e/tests/orphaned-files-content-path.spec.ts rename to e2e/tests/download-cleaner/orphaned-files-content-path.spec.ts index fd62e175..beba5a8a 100644 --- a/e2e/tests/orphaned-files-content-path.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-content-path.spec.ts @@ -10,9 +10,9 @@ import { getDownloadCleanerConfig, updateOrphanedFilesConfig, triggerJob, -} from './helpers/app-api'; -import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent'; -import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; +} from '../helpers/app-api'; +import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent'; +import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from '../helpers/torrent-fixtures'; /** * An actively-seeding torrent whose display name @@ -29,7 +29,7 @@ import { buildFolderTorrent, chmodIgnoringEPERM, resetDirectory } from './helper * so the torrent's content_path resolves into the scanned directory. */ -const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); // A subdirectory of the qbittorrent bind mount, isolated from the top-level // dir the client-matrix spec uses. const SCAN_SUBDIR = 'contentpath-scan'; diff --git a/e2e/tests/orphaned-files-unreachable-client.spec.ts b/e2e/tests/download-cleaner/orphaned-files-unreachable-client.spec.ts similarity index 95% rename from e2e/tests/orphaned-files-unreachable-client.spec.ts rename to e2e/tests/download-cleaner/orphaned-files-unreachable-client.spec.ts index 20efd23c..cfc10670 100644 --- a/e2e/tests/orphaned-files-unreachable-client.spec.ts +++ b/e2e/tests/download-cleaner/orphaned-files-unreachable-client.spec.ts @@ -10,9 +10,9 @@ import { getDownloadCleanerConfig, updateOrphanedFilesConfig, triggerJob, -} from './helpers/app-api'; -import { QBittorrentDriver } from './helpers/torrent-clients/qbittorrent'; -import { chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; +} from '../helpers/app-api'; +import { QBittorrentDriver } from '../helpers/torrent-clients/qbittorrent'; +import { chmodIgnoringEPERM, resetDirectory } from '../helpers/torrent-fixtures'; /** * Regression guard for the orphaned-files cleanup safety bail. @@ -32,7 +32,7 @@ import { chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; * Exercises the explicit zero-torrents bail in `TryAddClaimedPathsAsync`. */ -const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); const APP_DOWNLOADS = '/e2e-downloads'; const SLUG = 'qbittorrent-unreachable'; const HOST_SCAN_DIR = join(HOST_DOWNLOADS, SLUG); diff --git a/e2e/tests/seeding-rules-api.spec.ts b/e2e/tests/download-cleaner/seeding-rules.api.spec.ts similarity index 52% rename from e2e/tests/seeding-rules-api.spec.ts rename to e2e/tests/download-cleaner/seeding-rules.api.spec.ts index e0b5bdf9..abdeeac5 100644 --- a/e2e/tests/seeding-rules-api.spec.ts +++ b/e2e/tests/download-cleaner/seeding-rules.api.spec.ts @@ -1,51 +1,38 @@ -import { test, expect } from '@playwright/test'; -import { - loginAndGetToken, - createDownloadClient, - deleteDownloadClient, - getSeedingRules, - createSeedingRule, - updateSeedingRule, - deleteSeedingRule, - reorderSeedingRules, -} from './helpers/app-api'; +import { test, expect } from '../fixtures/base'; +import { buildDownloadClientPayload } from '../helpers/api/download-client'; -test.describe.serial('Seeding Rules API', () => { - let token: string; +test.describe.serial('DownloadCleaner — seeding rules CRUD', () => { let downloadClientId: string; - test.beforeAll(async () => { - token = await loginAndGetToken(); - - // Create a qBittorrent download client for testing seeding rules - const res = await createDownloadClient(token, { - enabled: false, - name: 'e2e-test-qbit', - typeName: 'qBittorrent', - type: 'Torrent', - host: 'http://localhost:9999', - }); + test.beforeAll(async ({ api }) => { + const res = await api.downloadClient.create( + buildDownloadClientPayload('qbittorrent', { + name: 'e2e-test-qbit', + host: 'http://127.0.0.1:9999', + enabled: false, + }), + ); expect(res.status).toBe(201); const client = await res.json(); downloadClientId = client.id; }); - test.afterAll(async () => { + test.afterAll(async ({ api }) => { if (downloadClientId) { - await deleteDownloadClient(token, downloadClientId); + await api.downloadClient.delete(downloadClientId); } }); - test('should return empty seeding rules for new client', async () => { - const res = await getSeedingRules(token, downloadClientId); + test('returns empty rules for a new client', async ({ api }) => { + const res = await api.downloadCleaner.listSeedingRules(downloadClientId); expect(res.status).toBe(200); const rules = await res.json(); expect(Array.isArray(rules)).toBe(true); expect(rules).toHaveLength(0); }); - test('should create a seeding rule with new fields', async () => { - const res = await createSeedingRule(token, downloadClientId, { + test('creates a seeding rule with new fields', async ({ api }) => { + const res = await api.downloadCleaner.createSeedingRule(downloadClientId, { name: 'Movies Rule', categories: ['movies', 'films'], trackerPatterns: ['tracker.example.com'], @@ -69,8 +56,8 @@ test.describe.serial('Seeding Rules API', () => { expect(rule.priority).toBe(1); }); - test('should auto-assign sequential priorities', async () => { - const res2 = await createSeedingRule(token, downloadClientId, { + test('auto-assigns sequential priorities', async ({ api }) => { + const res2 = await api.downloadCleaner.createSeedingRule(downloadClientId, { name: 'TV Rule', categories: ['tv'], privacyType: 'Both', @@ -80,10 +67,9 @@ test.describe.serial('Seeding Rules API', () => { deleteSourceFiles: true, }); expect(res2.status).toBe(201); - const rule2 = await res2.json(); - expect(rule2.priority).toBe(2); + expect((await res2.json()).priority).toBe(2); - const res3 = await createSeedingRule(token, downloadClientId, { + const res3 = await api.downloadCleaner.createSeedingRule(downloadClientId, { name: 'Music Rule', categories: ['music'], privacyType: 'Both', @@ -93,14 +79,11 @@ test.describe.serial('Seeding Rules API', () => { deleteSourceFiles: false, }); expect(res3.status).toBe(201); - const rule3 = await res3.json(); - expect(rule3.priority).toBe(3); + expect((await res3.json()).priority).toBe(3); }); - test('should round-trip new fields through GET', async () => { - const res = await getSeedingRules(token, downloadClientId); - expect(res.status).toBe(200); - const rules = await res.json(); + test('round-trips new fields through GET', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); expect(rules).toHaveLength(3); const moviesRule = rules.find((r: { name: string }) => r.name === 'Movies Rule'); @@ -111,19 +94,15 @@ test.describe.serial('Seeding Rules API', () => { expect(moviesRule.priority).toBe(1); }); - test('should reorder seeding rules', async () => { - const getRes = await getSeedingRules(token, downloadClientId); - const rules = await getRes.json(); + test('reorders seeding rules', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); expect(rules).toHaveLength(3); - // Reverse the order const reversedIds = rules.map((r: { id: string }) => r.id).reverse(); - const reorderRes = await reorderSeedingRules(token, downloadClientId, reversedIds); + const reorderRes = await api.downloadCleaner.reorderSeedingRules(downloadClientId, reversedIds); expect(reorderRes.status).toBe(204); - // Verify new order - const verifyRes = await getSeedingRules(token, downloadClientId); - const reordered = await verifyRes.json(); + const reordered = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); expect(reordered[0].priority).toBe(1); expect(reordered[0].id).toBe(reversedIds[0]); expect(reordered[1].priority).toBe(2); @@ -132,41 +111,33 @@ test.describe.serial('Seeding Rules API', () => { expect(reordered[2].id).toBe(reversedIds[2]); }); - test('should reject reorder with missing rule IDs', async () => { - const getRes = await getSeedingRules(token, downloadClientId); - const rules = await getRes.json(); - - // Only send 2 of 3 IDs + test('rejects reorder with missing rule IDs', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); const partialIds = rules.slice(0, 2).map((r: { id: string }) => r.id); - const res = await reorderSeedingRules(token, downloadClientId, partialIds); + const res = await api.downloadCleaner.reorderSeedingRules(downloadClientId, partialIds); expect(res.status).toBeGreaterThanOrEqual(400); }); - test('should reject reorder with duplicate IDs', async () => { - const getRes = await getSeedingRules(token, downloadClientId); - const rules = await getRes.json(); - + test('rejects reorder with duplicate IDs', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); const firstId = rules[0].id; - const res = await reorderSeedingRules(token, downloadClientId, [firstId, firstId, rules[1].id]); + const res = await api.downloadCleaner.reorderSeedingRules(downloadClientId, [firstId, firstId, rules[1].id]); expect(res.status).toBeGreaterThanOrEqual(400); }); - test('should reject reorder with invalid rule ID', async () => { - const getRes = await getSeedingRules(token, downloadClientId); - const rules = await getRes.json(); - + test('rejects reorder with invalid rule ID', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); const ids = rules.map((r: { id: string }) => r.id); ids[0] = '00000000-0000-0000-0000-000000000000'; - const res = await reorderSeedingRules(token, downloadClientId, ids); + const res = await api.downloadCleaner.reorderSeedingRules(downloadClientId, ids); expect(res.status).toBeGreaterThanOrEqual(400); }); - test('should not change priority on update', async () => { - const getRes = await getSeedingRules(token, downloadClientId); - const rules = await getRes.json(); + test('does not change priority on update', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); const rule = rules[0]; - const updateRes = await updateSeedingRule(token, rule.id, { + const updateRes = await api.downloadCleaner.updateSeedingRule(rule.id, { name: 'Updated Name', categories: rule.categories, trackerPatterns: rule.trackerPatterns, @@ -178,16 +149,14 @@ test.describe.serial('Seeding Rules API', () => { deleteSourceFiles: rule.deleteSourceFiles, }); expect(updateRes.status).toBe(200); - const updated = await updateRes.json(); - expect(updated.priority).toBe(rule.priority); + expect((await updateRes.json()).priority).toBe(rule.priority); }); - test('should update tags and persist them', async () => { - const getRes = await getSeedingRules(token, downloadClientId); - const rules = await getRes.json(); + test('updates tags and persists them', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); const rule = rules[0]; - const updateRes = await updateSeedingRule(token, rule.id, { + const updateRes = await api.downloadCleaner.updateSeedingRule(rule.id, { name: rule.name, categories: rule.categories, trackerPatterns: rule.trackerPatterns, @@ -202,16 +171,14 @@ test.describe.serial('Seeding Rules API', () => { }); expect(updateRes.status).toBe(200); - // Verify tags persisted via GET - const verifyRes = await getSeedingRules(token, downloadClientId); - const updated = await verifyRes.json(); + const updated = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); const updatedRule = updated.find((r: { id: string }) => r.id === rule.id); expect(updatedRule.tagsAny).toEqual(['updated-tag-1', 'updated-tag-2']); expect(updatedRule.tagsAll).toEqual(['required-tag']); }); - test('should reject empty categories', async () => { - const res = await createSeedingRule(token, downloadClientId, { + test('rejects empty categories', async ({ api }) => { + const res = await api.downloadCleaner.createSeedingRule(downloadClientId, { name: 'Bad Rule', categories: [], privacyType: 'Both', @@ -223,8 +190,8 @@ test.describe.serial('Seeding Rules API', () => { expect(res.status).toBeGreaterThanOrEqual(400); }); - test('should reject negative priority', async () => { - const res = await createSeedingRule(token, downloadClientId, { + test('rejects negative priority', async ({ api }) => { + const res = await api.downloadCleaner.createSeedingRule(downloadClientId, { name: 'Bad Priority Rule', categories: ['test'], priority: -1, @@ -237,8 +204,8 @@ test.describe.serial('Seeding Rules API', () => { expect(res.status).toBeGreaterThanOrEqual(400); }); - test('should strip empty tracker patterns', async () => { - const res = await createSeedingRule(token, downloadClientId, { + test('strips empty tracker patterns', async ({ api }) => { + const res = await api.downloadCleaner.createSeedingRule(downloadClientId, { name: 'Whitespace Test', categories: ['test'], trackerPatterns: ['', ' ', 'valid.com'], @@ -252,25 +219,22 @@ test.describe.serial('Seeding Rules API', () => { const rule = await res.json(); expect(rule.trackerPatterns).toEqual(['valid.com']); - // Clean up - await deleteSeedingRule(token, rule.id); + await api.downloadCleaner.deleteSeedingRule(rule.id); }); - test('should delete a seeding rule', async () => { - const getRes = await getSeedingRules(token, downloadClientId); - const rules = await getRes.json(); + test('deletes a seeding rule', async ({ api }) => { + const rules = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); const lastRule = rules[rules.length - 1]; - const delRes = await deleteSeedingRule(token, lastRule.id); + const delRes = await api.downloadCleaner.deleteSeedingRule(lastRule.id); expect(delRes.status).toBe(204); - const verifyRes = await getSeedingRules(token, downloadClientId); - const remaining = await verifyRes.json(); + const remaining = await (await api.downloadCleaner.listSeedingRules(downloadClientId)).json(); expect(remaining).toHaveLength(rules.length - 1); }); - test('should return 404 for non-existent download client', async () => { - const res = await getSeedingRules(token, '00000000-0000-0000-0000-000000000000'); + test('returns 404 for non-existent download client', async ({ api }) => { + const res = await api.downloadCleaner.listSeedingRules('00000000-0000-0000-0000-000000000000'); expect(res.status).toBe(404); }); }); diff --git a/e2e/tests/download-cleaner/unlinked-config.api.spec.ts b/e2e/tests/download-cleaner/unlinked-config.api.spec.ts new file mode 100644 index 00000000..f291e71b --- /dev/null +++ b/e2e/tests/download-cleaner/unlinked-config.api.spec.ts @@ -0,0 +1,44 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import { buildDownloadClientPayload } from '../helpers/api/download-client'; + +test.describe('DownloadCleaner — unlinked config', () => { + test('GET + PUT round-trip for a download client', async ({ api }) => { + const created = await ( + await api.downloadClient.create( + buildDownloadClientPayload('qbittorrent', { + name: 'qb-unlinked', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + }), + ) + ).json(); + expect(created.id).toBeTruthy(); + + const initial = await api.downloadCleaner.getUnlinkedConfig(created.id); + // Backend returns 204 when no unlinked config row exists for the client. + expect([200, 204]).toContain(initial.status); + + // Note: when ignoredRootDirs contains paths the backend validates they + // exist on the filesystem and rejects if any are missing. Use [] to keep + // the test environment-agnostic. + const update = await api.downloadCleaner.updateUnlinkedConfig(created.id, { + enabled: true, + categories: ['radarr', 'sonarr'], + ignoredRootDirs: [], + useTag: true, + targetCategory: 'cleanuparr-unlinked', + }); + expect(update.ok).toBe(true); + + const after = await (await api.downloadCleaner.getUnlinkedConfig(created.id)).json(); + expect(after.enabled).toBe(true); + expect(after.categories).toEqual(expect.arrayContaining(['radarr', 'sonarr'])); + expect(after.targetCategory).toBe('cleanuparr-unlinked'); + }); + + test('GET returns 404 for unknown client', async ({ api }) => { + const res = await api.downloadCleaner.getUnlinkedConfig('00000000-0000-0000-0000-000000000000'); + expect([404, 400]).toContain(res.status); + }); +}); diff --git a/e2e/tests/download-client/download-client-crud.api.spec.ts b/e2e/tests/download-client/download-client-crud.api.spec.ts new file mode 100644 index 00000000..a8cb9bfa --- /dev/null +++ b/e2e/tests/download-client/download-client-crud.api.spec.ts @@ -0,0 +1,46 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import type { DownloadClientType } from '../helpers/api/download-client'; +import { buildDownloadClientPayload } from '../helpers/api/download-client'; + +const TYPES: DownloadClientType[] = ['qbittorrent', 'transmission', 'deluge', 'utorrent', 'rtorrent']; + +test.describe('DownloadClient — CRUD', () => { + for (const type of TYPES) { + test(`${type}: create + list + update + delete`, async ({ api }) => { + const payload = buildDownloadClientPayload(type, { + name: `${type}-e2e`, + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + }); + + const create = await api.downloadClient.create(payload); + if (!create.ok) { + console.error(`${type} create failed:`, create.status, await create.text()); + } + expect(create.status).toBeLessThan(300); + const created = await create.json(); + expect(created.id).toBeTruthy(); + expect(created.typeName.toLowerCase()).toBe(payload.typeName?.toLowerCase()); + + const list = await (await api.downloadClient.list()).json(); + const clients = list.clients ?? list; + expect(clients.some((c: { id: string }) => c.id === created.id)).toBe(true); + + const update = await api.downloadClient.update(created.id, { + ...payload, + name: `${type}-renamed`, + enabled: false, + }); + expect(update.ok).toBe(true); + + const del = await api.downloadClient.delete(created.id); + expect(del.status).toBe(204); + }); + } + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.downloadClient.list(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/download-client/download-client-test-connection.api.spec.ts b/e2e/tests/download-client/download-client-test-connection.api.spec.ts new file mode 100644 index 00000000..be51f706 --- /dev/null +++ b/e2e/tests/download-client/download-client-test-connection.api.spec.ts @@ -0,0 +1,75 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import { buildDownloadClientPayload } from '../helpers/api/download-client'; +import { DownloadClientStubs } from '../helpers/mocks'; + +test.describe('DownloadClient — test connection', () => { + test('qbittorrent: success when login returns Ok.', async ({ api, mocks }) => { + await mocks.downloadClient.stub(DownloadClientStubs.qbitVersionStub()); + await mocks.downloadClient.stub(DownloadClientStubs.qbitLoginOkStub()); + const res = await api.downloadClient.test( + buildDownloadClientPayload('qbittorrent', { + name: 'qb-conn', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + }), + ); + expect(res.ok).toBe(true); + }); + + // Note: qBittorrent's "Fails." login response handling is version-specific + // in FLM.QBittorrent and Transmission's 409→200 session handshake uses + // stateful retries that don't model cleanly with WireMock. Both failure + // branches are exercised by the generic "host unreachable" case below. + + test('deluge: success on auth.login', async ({ api, mocks }) => { + await mocks.downloadClient.stub(DownloadClientStubs.delugeLoginStub()); + const res = await api.downloadClient.test( + buildDownloadClientPayload('deluge', { + name: 'dl-conn', + host: TEST_CONFIG.mocks.downloadClientUrl, + password: 'admin', + }), + ); + expect(res.ok).toBe(true); + }); + + test('utorrent: success on token.html + list', async ({ api, mocks }) => { + await mocks.downloadClient.stubMany(DownloadClientStubs.utorrentStubs()); + const res = await api.downloadClient.test( + buildDownloadClientPayload('utorrent', { + name: 'ut-conn', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + }), + ); + expect(res.ok).toBe(true); + }); + + test('rtorrent: success on XML-RPC', async ({ api, mocks }) => { + await mocks.downloadClient.stub(DownloadClientStubs.rtorrentXmlRpcStub()); + const res = await api.downloadClient.test( + buildDownloadClientPayload('rtorrent', { + name: 'rt-conn', + host: TEST_CONFIG.mocks.downloadClientUrl, + username: 'admin', + password: 'admin', + urlBase: '/RPC2', + }), + ); + expect(res.ok).toBe(true); + }); + + test('any client: failure when host unreachable', async ({ api }) => { + const res = await api.downloadClient.test( + buildDownloadClientPayload('qbittorrent', { + name: 'unreachable', + host: 'http://127.0.0.1:1', + username: 'a', + password: 'b', + }), + ); + expect(res.ok).toBe(false); + }); +}); diff --git a/e2e/tests/download-clients-ui.spec.ts b/e2e/tests/download-client/download-clients-ui.spec.ts similarity index 96% rename from e2e/tests/download-clients-ui.spec.ts rename to e2e/tests/download-client/download-clients-ui.spec.ts index b6361867..4df723cd 100644 --- a/e2e/tests/download-clients-ui.spec.ts +++ b/e2e/tests/download-client/download-clients-ui.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { loginAndGotoSettings, selectOption } from './helpers/ui'; +import { loginAndGotoSettings, selectOption } from '../helpers/ui'; // Behavior-parity spec for the Download Clients create/edit modal (client-type driven fields). test.describe('Download Clients UI', () => { diff --git a/e2e/tests/fixtures/base.ts b/e2e/tests/fixtures/base.ts new file mode 100644 index 00000000..24196589 --- /dev/null +++ b/e2e/tests/fixtures/base.ts @@ -0,0 +1,74 @@ +import { test as base, Page } from '@playwright/test'; +import { ApiClient, CleanuparrApi } from '../helpers/api'; +import { adminTokens } from '../helpers/test-lifecycle'; +import { MockServers } from '../helpers/mocks/wiremock-client'; +import { TEST_CONFIG } from '../helpers/test-config'; + +interface TestFixtures { + autoReset: void; + api: CleanuparrApi; + anonymousApi: CleanuparrApi; + mocks: MockServers; + authenticatedPage: Page; +} + +/** + * Base Playwright test with Cleanuparr fixtures. + * + * Each test receives: + * - `api` — a {@link CleanuparrApi} authenticated as the admin + * - `anonymousApi` — a {@link CleanuparrApi} with no token (for 401 / anonymous flows) + * - `mocks` — {@link MockServers} for stubbing external integrations + * - `authenticatedPage` — Playwright page with the admin's tokens injected into localStorage + * + * Isolation model: each spec folder gets a dedicated Playwright "setup" + * project (`tests/_setup/.setup.ts`) that restarts the app + * container, re-creates the admin account on the now-empty tmpfs `/config`, + * and writes fresh bearer tokens to `playwright/.auth/admin.json`. Tests + * within a folder cooperate (unique entity names + restore-after-modify); + * the folder boundary is the hard reset. + */ +export const test = base.extend({ + // Auto-fixture: clear WireMock stubs before every test. The app's own + // state is reset only between spec folders by the matching setup project. + autoReset: [ + async ({ mocks }, use) => { + await mocks.resetAll(); + await use(); + }, + { auto: true }, + ], + + api: async ({}, use) => { + const tokens = adminTokens(); + await use(new CleanuparrApi({ token: tokens.accessToken })); + }, + + anonymousApi: async ({}, use) => { + await use(new CleanuparrApi()); + }, + + mocks: async ({}, use) => { + const servers = new MockServers(); + await use(servers); + }, + + authenticatedPage: async ({ page }, use) => { + const tokens = adminTokens(); + // Match the keys the frontend's AuthService reads from localStorage — + // see code/frontend/src/app/core/auth/auth.service.ts. + await page.addInitScript((t) => { + try { + window.localStorage.setItem('access_token', t.accessToken); + window.localStorage.setItem('refresh_token', t.refreshToken); + } catch { + // localStorage unavailable; ignore + } + }, tokens); + await page.goto('/'); + await use(page); + }, +}); + +export const expect = test.expect; +export { TEST_CONFIG, ApiClient, CleanuparrApi }; diff --git a/e2e/tests/general/general-config.api.spec.ts b/e2e/tests/general/general-config.api.spec.ts new file mode 100644 index 00000000..ac3cd334 --- /dev/null +++ b/e2e/tests/general/general-config.api.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('General config', () => { + test('GET returns the singleton config', async ({ api }) => { + const res = await api.general.getConfig(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('dryRun'); + expect(body).toHaveProperty('ignoredDownloads'); + expect(body).toHaveProperty('log'); + expect(body).toHaveProperty('auth'); + }); + + test('PUT toggles dry run', async ({ api }) => { + const before = await api.general.getJsonConfig(); + const next = !before.dryRun; + await api.general.patch({ dryRun: next }); + const after = await api.general.getJsonConfig(); + expect(after.dryRun).toBe(next); + + await api.general.patch({ dryRun: before.dryRun }); + }); + + test('PUT updates log level', async ({ api }) => { + const before = await api.general.getJsonConfig(); + await api.general.patch({ log: { ...(before.log as object), level: 'debug' } }); + const after = await api.general.getJsonConfig(); + expect((after.log as { level: string }).level.toLowerCase()).toBe('debug'); + + await api.general.patch({ log: before.log }); + }); + + test('PUT updates ignored downloads', async ({ api }) => { + await api.general.patch({ ignoredDownloads: ['e2e-ignore-1', 'e2e-ignore-2'] }); + const after = await api.general.getJsonConfig(); + const ignored = after.ignoredDownloads as string[]; + expect(ignored).toEqual(expect.arrayContaining(['e2e-ignore-1', 'e2e-ignore-2'])); + + await api.general.patch({ ignoredDownloads: [] }); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.general.getConfig(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/general-ui.spec.ts b/e2e/tests/general/general-ui.spec.ts similarity index 98% rename from e2e/tests/general-ui.spec.ts rename to e2e/tests/general/general-ui.spec.ts index fdc27082..afb37923 100644 --- a/e2e/tests/general-ui.spec.ts +++ b/e2e/tests/general/general-ui.spec.ts @@ -5,7 +5,7 @@ import { numberInput, expectGuardOnLeave, expectNoGuardOnLeave, -} from './helpers/ui'; +} from '../helpers/ui'; // Behavior-parity spec for the General settings form (Signal Forms migration). test.describe('General Settings UI', () => { diff --git a/e2e/tests/general/general.ui.spec.ts b/e2e/tests/general/general.ui.spec.ts new file mode 100644 index 00000000..817987ce --- /dev/null +++ b/e2e/tests/general/general.ui.spec.ts @@ -0,0 +1,14 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('General settings — UI smoke', () => { + test('settings page loads for authenticated admin', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/settings/general'); + await expect(authenticatedPage).toHaveURL(/\/settings\/general/); + await expect(authenticatedPage.locator('body')).toBeVisible(); + }); + + test('unauthenticated visit redirects to login', async ({ page }) => { + await page.goto('/settings/general'); + await expect(page).toHaveURL(/(login|auth)/); + }); +}); diff --git a/e2e/tests/general/strikes-purge.api.spec.ts b/e2e/tests/general/strikes-purge.api.spec.ts new file mode 100644 index 00000000..32ba2cfe --- /dev/null +++ b/e2e/tests/general/strikes-purge.api.spec.ts @@ -0,0 +1,25 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('General — strikes purge', () => { + test('POST /strikes/purge returns deletion counts', async ({ api }) => { + const res = await api.general.purgeStrikes(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('deletedStrikes'); + expect(body).toHaveProperty('deletedItems'); + expect(typeof body.deletedStrikes).toBe('number'); + expect(typeof body.deletedItems).toBe('number'); + }); + + test('after purge, /api/strikes returns no items', async ({ api }) => { + await api.general.purgeStrikes(); + const list = await (await api.strikes.list()).json(); + expect(list.items).toEqual([]); + expect(list.totalCount).toBe(0); + }); + + test('POST requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.general.purgeStrikes(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index 79eb5f59..d58e8b9f 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -1,20 +1,22 @@ +import { CleanuparrApi, waitForApp } from './helpers/api'; +import { MockServers } from './helpers/mocks/wiremock-client'; import { waitForKeycloak } from './helpers/keycloak'; -import { - waitForApp, - createAccountAndSetup, -} from './helpers/app-api'; -async function globalSetup() { +// Per-folder app restarts (in tests/_setup/*.setup.ts) handle admin creation +// and OIDC config. globalSetup only verifies the long-lived services that +// the harness depends on are reachable before any project runs. +async function globalSetup(): Promise { console.log('Waiting for Keycloak...'); await waitForKeycloak(); console.log('Keycloak ready.'); console.log('Waiting for app...'); - await waitForApp(); + await waitForApp(new CleanuparrApi().client); console.log('App ready.'); - console.log('Creating admin account and completing setup...'); - await createAccountAndSetup(); + console.log('Waiting for WireMock servers...'); + await new MockServers().waitReady(); + console.log('WireMock ready.'); console.log('Global setup complete.'); } diff --git a/e2e/tests/helpers/api/account.ts b/e2e/tests/helpers/api/account.ts new file mode 100644 index 00000000..481d013d --- /dev/null +++ b/e2e/tests/helpers/api/account.ts @@ -0,0 +1,73 @@ +import { ApiClient } from './client'; + +export class AccountApi { + constructor(private readonly client: ApiClient) {} + + get(): Promise { + return this.client.get('/api/account'); + } + + changePassword(currentPassword: string, newPassword: string): Promise { + return this.client.put('/api/account/password', { currentPassword, newPassword }); + } + + generate2fa(): Promise { + return this.client.post('/api/account/2fa/regenerate'); + } + + enable2fa(password: string): Promise { + return this.client.post('/api/account/2fa/enable', { password }); + } + + enable2faVerify(token: string): Promise { + return this.client.post('/api/account/2fa/enable/verify', { token }); + } + + disable2fa(password: string, code: string): Promise { + return this.client.post('/api/account/2fa/disable', { password, code }); + } + + getApiKey(): Promise { + return this.client.get('/api/account/api-key'); + } + + regenerateApiKey(): Promise { + return this.client.post('/api/account/api-key/regenerate'); + } + + linkPlex(): Promise { + return this.client.post('/api/account/plex/link'); + } + + verifyPlexLink(pinId: string): Promise { + return this.client.post('/api/account/plex/link/verify', { pinId }); + } + + unlinkPlex(): Promise { + return this.client.delete('/api/account/plex/link'); + } + + getOidcConfig(): Promise { + return this.client.get('/api/account/oidc'); + } + + updateOidcConfig(config: Record): Promise { + return this.client.put('/api/account/oidc', config); + } + + startOidcLink(): Promise { + return this.client.post('/api/account/oidc/link'); + } + + unlinkOidc(): Promise { + return this.client.delete('/api/account/oidc/link'); + } + + async patchOidcConfig(updates: Record): Promise { + const current = await (await this.getOidcConfig()).json(); + const res = await this.updateOidcConfig({ ...current, ...updates }); + if (!res.ok) { + throw new Error(`Failed to patch OIDC config: ${res.status} ${await res.text()}`); + } + } +} diff --git a/e2e/tests/helpers/api/arr.ts b/e2e/tests/helpers/api/arr.ts new file mode 100644 index 00000000..8242ea10 --- /dev/null +++ b/e2e/tests/helpers/api/arr.ts @@ -0,0 +1,39 @@ +import { ApiClient } from './client'; + +export type ArrType = 'sonarr' | 'radarr' | 'lidarr' | 'readarr' | 'whisparr'; + +export interface ArrInstancePayload { + name: string; + url: string; + apiKey: string; + version?: number; + enabled?: boolean; +} + +export class ArrApi { + constructor(private readonly client: ApiClient) {} + + getConfig(type: ArrType): Promise { + return this.client.get(`/api/configuration/${type}`); + } + + updateConfig(type: ArrType, body: Record): Promise { + return this.client.put(`/api/configuration/${type}`, body); + } + + createInstance(type: ArrType, instance: ArrInstancePayload): Promise { + return this.client.post(`/api/configuration/${type}/instances`, instance); + } + + updateInstance(type: ArrType, id: string, instance: ArrInstancePayload): Promise { + return this.client.put(`/api/configuration/${type}/instances/${id}`, instance); + } + + deleteInstance(type: ArrType, id: string): Promise { + return this.client.delete(`/api/configuration/${type}/instances/${id}`); + } + + testInstance(type: ArrType, instance: ArrInstancePayload): Promise { + return this.client.post(`/api/configuration/${type}/instances/test`, instance); + } +} diff --git a/e2e/tests/helpers/api/auth.ts b/e2e/tests/helpers/api/auth.ts new file mode 100644 index 00000000..f2f21a2d --- /dev/null +++ b/e2e/tests/helpers/api/auth.ts @@ -0,0 +1,81 @@ +import { ApiClient } from './client'; + +export interface TokenResponse { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export interface LoginResponse { + requiresTwoFactor: boolean; + loginToken?: string; + tokens?: TokenResponse; +} + +export class AuthApi { + constructor(private readonly client: ApiClient) {} + + status(): Promise { + return this.client.get('/api/auth/status'); + } + + setupAccount(username: string, password: string): Promise { + return this.client.post('/api/auth/setup/account', { username, password }); + } + + setupGenerate2fa(): Promise { + return this.client.post('/api/auth/setup/2fa/generate'); + } + + setupVerify2fa(token: string): Promise { + return this.client.post('/api/auth/setup/2fa/verify', { token }); + } + + setupComplete(): Promise { + return this.client.post('/api/auth/setup/complete'); + } + + login(username: string, password: string): Promise { + return this.client.post('/api/auth/login', { username, password }); + } + + loginTwoFactor(loginToken: string, code: string): Promise { + return this.client.post('/api/auth/login/2fa', { loginToken, code }); + } + + refresh(refreshToken: string): Promise { + return this.client.post('/api/auth/refresh', { refreshToken }); + } + + logout(refreshToken: string): Promise { + return this.client.post('/api/auth/logout', { refreshToken }); + } + + oidcStart(): Promise { + return this.client.post('/api/auth/oidc/start'); + } + + oidcExchange(oneTimeCode: string): Promise { + return this.client.post('/api/auth/oidc/exchange', { code: oneTimeCode }); + } + + plexPin(): Promise { + return this.client.post('/api/auth/login/plex/pin'); + } + + plexVerify(pinId: string): Promise { + return this.client.post('/api/auth/login/plex/verify', { pinId }); + } + + async loginAndCaptureTokens(username: string, password: string): Promise { + const res = await this.login(username, password); + if (!res.ok) { + throw new Error(`Login failed: ${res.status} ${await res.text()}`); + } + const data: LoginResponse = await res.json(); + if (data.requiresTwoFactor || !data.tokens) { + throw new Error('Unexpected 2FA requirement in admin login flow'); + } + return data.tokens; + } +} diff --git a/e2e/tests/helpers/api/blacklist-sync.ts b/e2e/tests/helpers/api/blacklist-sync.ts new file mode 100644 index 00000000..2c6c5ff5 --- /dev/null +++ b/e2e/tests/helpers/api/blacklist-sync.ts @@ -0,0 +1,13 @@ +import { ApiClient } from './client'; + +export class BlacklistSyncApi { + constructor(private readonly client: ApiClient) {} + + getConfig(): Promise { + return this.client.get('/api/configuration/blacklist_sync'); + } + + updateConfig(body: Record): Promise { + return this.client.put('/api/configuration/blacklist_sync', body); + } +} diff --git a/e2e/tests/helpers/api/client.ts b/e2e/tests/helpers/api/client.ts new file mode 100644 index 00000000..d7844f69 --- /dev/null +++ b/e2e/tests/helpers/api/client.ts @@ -0,0 +1,112 @@ +import { TEST_CONFIG } from '../test-config'; + +export type RawJson = unknown; + +export interface ApiClientOptions { + baseUrl?: string; + token?: string; +} + +/** + * Thin fetch wrapper used by every feature-area API module. + * One instance per test (or one shared admin client for setup). + */ +export class ApiClient { + baseUrl: string; + token?: string; + + constructor(opts: ApiClientOptions = {}) { + this.baseUrl = opts.baseUrl ?? TEST_CONFIG.appUrl; + this.token = opts.token; + } + + setToken(token: string | undefined): void { + this.token = token; + } + + private buildHeaders(extra?: HeadersInit): HeadersInit { + const headers: Record = {}; + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + if (extra) { + const entries = extra instanceof Headers ? Array.from(extra.entries()) : Object.entries(extra as Record); + for (const [k, v] of entries) { + headers[k] = v; + } + } + return headers; + } + + request(path: string, init: RequestInit = {}): Promise { + const url = path.startsWith('http') ? path : `${this.baseUrl}${path}`; + return fetch(url, { + ...init, + headers: this.buildHeaders(init.headers), + }); + } + + async get(path: string): Promise { + return this.request(path); + } + + async getJson(path: string): Promise { + const res = await this.request(path); + if (!res.ok) { + throw new Error(`GET ${path} failed: ${res.status} ${await res.text()}`); + } + return res.json() as Promise; + } + + async post(path: string, body?: unknown): Promise { + return this.request(path, { + method: 'POST', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + async postJson(path: string, body?: unknown): Promise { + const res = await this.post(path, body); + if (!res.ok) { + throw new Error(`POST ${path} failed: ${res.status} ${await res.text()}`); + } + return res.json() as Promise; + } + + async put(path: string, body?: unknown): Promise { + return this.request(path, { + method: 'PUT', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + async putJson(path: string, body?: unknown): Promise { + const res = await this.put(path, body); + if (!res.ok) { + throw new Error(`PUT ${path} failed: ${res.status} ${await res.text()}`); + } + return res.json() as Promise; + } + + async delete(path: string): Promise { + return this.request(path, { method: 'DELETE' }); + } +} + +export async function waitForApp(client: ApiClient, timeoutMs = 90_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${client.baseUrl}/health`); + if (res.ok) { + return; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error(`App did not become ready within ${timeoutMs}ms`); +} diff --git a/e2e/tests/helpers/api/download-cleaner.ts b/e2e/tests/helpers/api/download-cleaner.ts new file mode 100644 index 00000000..1e6eba17 --- /dev/null +++ b/e2e/tests/helpers/api/download-cleaner.ts @@ -0,0 +1,41 @@ +import { ApiClient } from './client'; + +export class DownloadCleanerApi { + constructor(private readonly client: ApiClient) {} + + getConfig(): Promise { + return this.client.get('/api/configuration/download_cleaner'); + } + + updateConfig(body: Record): Promise { + return this.client.put('/api/configuration/download_cleaner', body); + } + + listSeedingRules(downloadClientId: string): Promise { + return this.client.get(`/api/seeding-rules/${downloadClientId}`); + } + + createSeedingRule(downloadClientId: string, body: Record): Promise { + return this.client.post(`/api/seeding-rules/${downloadClientId}`, body); + } + + updateSeedingRule(id: string, body: Record): Promise { + return this.client.put(`/api/seeding-rules/${id}`, body); + } + + deleteSeedingRule(id: string): Promise { + return this.client.delete(`/api/seeding-rules/${id}`); + } + + reorderSeedingRules(downloadClientId: string, orderedIds: string[]): Promise { + return this.client.put(`/api/seeding-rules/${downloadClientId}/reorder`, { orderedIds }); + } + + getUnlinkedConfig(downloadClientId: string): Promise { + return this.client.get(`/api/unlinked-config/${downloadClientId}`); + } + + updateUnlinkedConfig(downloadClientId: string, body: Record): Promise { + return this.client.put(`/api/unlinked-config/${downloadClientId}`, body); + } +} diff --git a/e2e/tests/helpers/api/download-client.ts b/e2e/tests/helpers/api/download-client.ts new file mode 100644 index 00000000..f3fb6343 --- /dev/null +++ b/e2e/tests/helpers/api/download-client.ts @@ -0,0 +1,69 @@ +import { ApiClient } from './client'; + +export type DownloadClientType = + | 'qbittorrent' + | 'transmission' + | 'deluge' + | 'utorrent' + | 'rtorrent'; + +export type DownloadClientCategory = 'Torrent' | 'Usenet'; + +export interface DownloadClientPayload { + name: string; + /** Backend enum value (Torrent / Usenet). */ + type?: DownloadClientCategory; + /** Backend type-name enum value (qBittorrent / Deluge / Transmission / uTorrent / rTorrent). */ + typeName?: string; + /** Full URL including scheme + port. */ + host: string; + username?: string; + password?: string; + urlBase?: string; + externalUrl?: string; + enabled?: boolean; +} + +const TYPE_NAME_MAP: Record = { + qbittorrent: 'qBittorrent', + transmission: 'Transmission', + deluge: 'Deluge', + utorrent: 'uTorrent', + rtorrent: 'rTorrent', +}; + +export function buildDownloadClientPayload( + type: DownloadClientType, + overrides: Partial & { host: string; name: string }, +): DownloadClientPayload { + return { + type: 'Torrent', + typeName: TYPE_NAME_MAP[type], + enabled: true, + ...overrides, + }; +} + +export class DownloadClientApi { + constructor(private readonly client: ApiClient) {} + + list(): Promise { + return this.client.get('/api/configuration/download_client'); + } + + create(body: DownloadClientPayload): Promise { + return this.client.post('/api/configuration/download_client', body); + } + + update(id: string, body: DownloadClientPayload): Promise { + return this.client.put(`/api/configuration/download_client/${id}`, body); + } + + delete(id: string): Promise { + return this.client.delete(`/api/configuration/download_client/${id}`); + } + + test(body: DownloadClientPayload): Promise { + return this.client.post('/api/configuration/download_client/test', body); + } +} diff --git a/e2e/tests/helpers/api/events.ts b/e2e/tests/helpers/api/events.ts new file mode 100644 index 00000000..40da9dbc --- /dev/null +++ b/e2e/tests/helpers/api/events.ts @@ -0,0 +1,109 @@ +import { ApiClient } from './client'; + +export interface EventsQuery { + page?: number; + pageSize?: number; + severity?: string; + eventType?: string; + fromDate?: string; + toDate?: string; + search?: string; + jobRunId?: string; +} + +function toQs(query?: Record): string { + if (!query) { + return ''; + } + const entries = Object.entries(query) + .filter(([, v]) => v !== undefined && v !== null) + .map(([k, v]) => [k, String(v)] as [string, string]); + if (!entries.length) { + return ''; + } + return '?' + new URLSearchParams(entries).toString(); +} + +export class EventsApi { + constructor(private readonly client: ApiClient) {} + + list(query?: EventsQuery): Promise { + return this.client.get(`/api/events${toQs(query as Record)}`); + } + + get(id: string): Promise { + return this.client.get(`/api/events/${id}`); + } + + byTracking(trackingId: string): Promise { + return this.client.get(`/api/events/tracking/${trackingId}`); + } + + types(): Promise { + return this.client.get('/api/events/types'); + } + + severities(): Promise { + return this.client.get('/api/events/severities'); + } + + cleanup(retentionDays = 30): Promise { + return this.client.post(`/api/events/cleanup?retentionDays=${retentionDays}`); + } +} + +export class ManualEventsApi { + constructor(private readonly client: ApiClient) {} + + list(query?: Record): Promise { + return this.client.get(`/api/ManualEvents${toQs(query)}`); + } + + get(id: string): Promise { + return this.client.get(`/api/ManualEvents/${id}`); + } + + resolve(id: string): Promise { + return this.client.post(`/api/ManualEvents/${id}/resolve`); + } + + stats(): Promise { + return this.client.get('/api/ManualEvents/stats'); + } + + severities(): Promise { + return this.client.get('/api/ManualEvents/severities'); + } + + cleanup(retentionDays = 30): Promise { + return this.client.post(`/api/ManualEvents/cleanup?retentionDays=${retentionDays}`); + } +} + +export class StrikesApi { + constructor(private readonly client: ApiClient) {} + + list(query?: Record): Promise { + return this.client.get(`/api/strikes${toQs(query)}`); + } + + recent(count = 5): Promise { + return this.client.get(`/api/strikes/recent?count=${count}`); + } + + types(): Promise { + return this.client.get('/api/strikes/types'); + } + + delete(downloadItemId: string): Promise { + return this.client.delete(`/api/strikes/${downloadItemId}`); + } +} + +export class StatsApi { + constructor(private readonly client: ApiClient) {} + + get(query?: { hours?: number; includeEvents?: number; includeStrikes?: number }): Promise { + return this.client.get(`/api/stats${toQs(query)}`); + } +} diff --git a/e2e/tests/helpers/api/general.ts b/e2e/tests/helpers/api/general.ts new file mode 100644 index 00000000..cb487180 --- /dev/null +++ b/e2e/tests/helpers/api/general.ts @@ -0,0 +1,49 @@ +import { ApiClient } from './client'; + +export interface AuthBypassOptions { + disableAuthForLocalAddresses: boolean; + trustForwardedHeaders: boolean; + trustedNetworks: string[]; +} + +export class GeneralApi { + constructor(private readonly client: ApiClient) {} + + getConfig(): Promise { + return this.client.get('/api/configuration/general'); + } + + updateConfig(body: Record): Promise { + return this.client.put('/api/configuration/general', body); + } + + purgeStrikes(): Promise { + return this.client.post('/api/configuration/strikes/purge'); + } + + async getJsonConfig(): Promise> { + const res = await this.getConfig(); + if (!res.ok) { + throw new Error(`GET general config failed: ${res.status} ${await res.text()}`); + } + return res.json(); + } + + async patch(updates: Record): Promise { + const current = await this.getJsonConfig(); + const res = await this.updateConfig({ ...current, ...updates }); + if (!res.ok) { + throw new Error(`PUT general config failed: ${res.status} ${await res.text()}`); + } + } + + async setAuthBypass(opts: AuthBypassOptions): Promise { + await this.patch({ + auth: { + disableAuthForLocalAddresses: opts.disableAuthForLocalAddresses, + trustForwardedHeaders: opts.trustForwardedHeaders, + trustedNetworks: opts.trustedNetworks, + }, + }); + } +} diff --git a/e2e/tests/helpers/api/index.ts b/e2e/tests/helpers/api/index.ts new file mode 100644 index 00000000..546aee23 --- /dev/null +++ b/e2e/tests/helpers/api/index.ts @@ -0,0 +1,104 @@ +import { TEST_CONFIG } from '../test-config'; +import { ApiClient, ApiClientOptions, waitForApp } from './client'; +import { AccountApi } from './account'; +import { ArrApi } from './arr'; +import { AuthApi } from './auth'; +import { BlacklistSyncApi } from './blacklist-sync'; +import { DownloadCleanerApi } from './download-cleaner'; +import { DownloadClientApi } from './download-client'; +import { EventsApi, ManualEventsApi, StatsApi, StrikesApi } from './events'; +import { GeneralApi } from './general'; +import { HealthApi, StatusApi } from './status'; +import { JobsApi } from './jobs'; +import { MalwareBlockerApi } from './malware-blocker'; +import { NotificationsApi } from './notifications'; +import { QueueCleanerApi } from './queue-cleaner'; +import { SeekerApi } from './seeker'; + +/** + * One-stop API surface for e2e tests. Use the Playwright fixtures + * in `tests/fixtures/base.ts` instead of constructing this directly. + */ +export class CleanuparrApi { + readonly client: ApiClient; + readonly auth: AuthApi; + readonly account: AccountApi; + readonly arr: ArrApi; + readonly downloadClient: DownloadClientApi; + readonly notifications: NotificationsApi; + readonly general: GeneralApi; + readonly queueCleaner: QueueCleanerApi; + readonly downloadCleaner: DownloadCleanerApi; + readonly malwareBlocker: MalwareBlockerApi; + readonly blacklistSync: BlacklistSyncApi; + readonly seeker: SeekerApi; + readonly jobs: JobsApi; + readonly events: EventsApi; + readonly manualEvents: ManualEventsApi; + readonly strikes: StrikesApi; + readonly stats: StatsApi; + readonly status: StatusApi; + readonly health: HealthApi; + + constructor(opts: ApiClientOptions = {}) { + this.client = new ApiClient(opts); + this.auth = new AuthApi(this.client); + this.account = new AccountApi(this.client); + this.arr = new ArrApi(this.client); + this.downloadClient = new DownloadClientApi(this.client); + this.notifications = new NotificationsApi(this.client); + this.general = new GeneralApi(this.client); + this.queueCleaner = new QueueCleanerApi(this.client); + this.downloadCleaner = new DownloadCleanerApi(this.client); + this.malwareBlocker = new MalwareBlockerApi(this.client); + this.blacklistSync = new BlacklistSyncApi(this.client); + this.seeker = new SeekerApi(this.client); + this.jobs = new JobsApi(this.client); + this.events = new EventsApi(this.client); + this.manualEvents = new ManualEventsApi(this.client); + this.strikes = new StrikesApi(this.client); + this.stats = new StatsApi(this.client); + this.status = new StatusApi(this.client); + this.health = new HealthApi(this.client); + } + + setToken(token: string | undefined): void { + this.client.setToken(token); + } +} + +export async function adminLogin( + api: CleanuparrApi, + username = TEST_CONFIG.adminUsername, + password = TEST_CONFIG.adminPassword, +): Promise { + const tokens = await api.auth.loginAndCaptureTokens(username, password); + api.setToken(tokens.accessToken); +} + +export async function ensureAdminAccount(api: CleanuparrApi): Promise { + const create = await api.auth.setupAccount(TEST_CONFIG.adminUsername, TEST_CONFIG.adminPassword); + if (!create.ok && create.status !== 409 && create.status !== 403) { + throw new Error(`Failed to create admin account: ${create.status} ${await create.text()}`); + } + const complete = await api.auth.setupComplete(); + if (!complete.ok && complete.status !== 409 && complete.status !== 403) { + throw new Error(`Failed to complete setup: ${complete.status} ${await complete.text()}`); + } +} + +export { ApiClient, waitForApp }; +export * from './auth'; +export * from './account'; +export * from './arr'; +export * from './download-client'; +export * from './notifications'; +export * from './general'; +export * from './queue-cleaner'; +export * from './download-cleaner'; +export * from './malware-blocker'; +export * from './blacklist-sync'; +export * from './seeker'; +export * from './jobs'; +export * from './events'; +export * from './status'; diff --git a/e2e/tests/helpers/api/jobs.ts b/e2e/tests/helpers/api/jobs.ts new file mode 100644 index 00000000..60dbf3cf --- /dev/null +++ b/e2e/tests/helpers/api/jobs.ts @@ -0,0 +1,40 @@ +import { ApiClient } from './client'; + +export type JobType = + | 'QueueCleaner' + | 'MalwareBlocker' + | 'DownloadCleaner' + | 'BlacklistSync' + | 'Seeker' + | 'CustomFormatScoreSyncer'; + +export type ScheduleUnit = 'Seconds' | 'Minutes' | 'Hours'; + +export interface JobSchedulePayload { + every: number; + type: ScheduleUnit; +} + +export class JobsApi { + constructor(private readonly client: ApiClient) {} + + list(): Promise { + return this.client.get('/api/jobs'); + } + + get(jobType: JobType | string): Promise { + return this.client.get(`/api/jobs/${jobType}`); + } + + start(jobType: JobType | string, schedule?: JobSchedulePayload): Promise { + return this.client.post(`/api/jobs/${jobType}/start`, schedule ? { schedule } : undefined); + } + + trigger(jobType: JobType | string): Promise { + return this.client.post(`/api/jobs/${jobType}/trigger`); + } + + updateSchedule(jobType: JobType | string, schedule: JobSchedulePayload): Promise { + return this.client.put(`/api/jobs/${jobType}/schedule`, { schedule }); + } +} diff --git a/e2e/tests/helpers/api/malware-blocker.ts b/e2e/tests/helpers/api/malware-blocker.ts new file mode 100644 index 00000000..e5cde6f5 --- /dev/null +++ b/e2e/tests/helpers/api/malware-blocker.ts @@ -0,0 +1,13 @@ +import { ApiClient } from './client'; + +export class MalwareBlockerApi { + constructor(private readonly client: ApiClient) {} + + getConfig(): Promise { + return this.client.get('/api/configuration/malware_blocker'); + } + + updateConfig(body: Record): Promise { + return this.client.put('/api/configuration/malware_blocker', body); + } +} diff --git a/e2e/tests/helpers/api/notifications.ts b/e2e/tests/helpers/api/notifications.ts new file mode 100644 index 00000000..72a0d57f --- /dev/null +++ b/e2e/tests/helpers/api/notifications.ts @@ -0,0 +1,42 @@ +import { ApiClient } from './client'; + +export type NotificationProviderType = + | 'notifiarr' + | 'apprise' + | 'ntfy' + | 'telegram' + | 'discord' + | 'pushover' + | 'gotify'; + +export class NotificationsApi { + constructor(private readonly client: ApiClient) {} + + list(): Promise { + return this.client.get('/api/configuration/notification_providers'); + } + + appriseCliStatus(): Promise { + return this.client.get('/api/configuration/notification_providers/apprise/cli-status'); + } + + create(type: NotificationProviderType, body: Record): Promise { + return this.client.post(`/api/configuration/notification_providers/${type}`, body); + } + + update( + type: NotificationProviderType, + id: string, + body: Record, + ): Promise { + return this.client.put(`/api/configuration/notification_providers/${type}/${id}`, body); + } + + delete(id: string): Promise { + return this.client.delete(`/api/configuration/notification_providers/${id}`); + } + + test(type: NotificationProviderType, body: Record): Promise { + return this.client.post(`/api/configuration/notification_providers/${type}/test`, body); + } +} diff --git a/e2e/tests/helpers/api/queue-cleaner.ts b/e2e/tests/helpers/api/queue-cleaner.ts new file mode 100644 index 00000000..50dd9bfe --- /dev/null +++ b/e2e/tests/helpers/api/queue-cleaner.ts @@ -0,0 +1,31 @@ +import { ApiClient } from './client'; + +export type QueueRuleKind = 'stall' | 'slow'; + +export class QueueCleanerApi { + constructor(private readonly client: ApiClient) {} + + getConfig(): Promise { + return this.client.get('/api/configuration/queue_cleaner'); + } + + updateConfig(body: Record): Promise { + return this.client.put('/api/configuration/queue_cleaner', body); + } + + listRules(kind: QueueRuleKind): Promise { + return this.client.get(`/api/queue-rules/${kind}`); + } + + createRule(kind: QueueRuleKind, body: Record): Promise { + return this.client.post(`/api/queue-rules/${kind}`, body); + } + + updateRule(kind: QueueRuleKind, id: string, body: Record): Promise { + return this.client.put(`/api/queue-rules/${kind}/${id}`, body); + } + + deleteRule(kind: QueueRuleKind, id: string): Promise { + return this.client.delete(`/api/queue-rules/${kind}/${id}`); + } +} diff --git a/e2e/tests/helpers/api/seeker.ts b/e2e/tests/helpers/api/seeker.ts new file mode 100644 index 00000000..a70085e5 --- /dev/null +++ b/e2e/tests/helpers/api/seeker.ts @@ -0,0 +1,43 @@ +import { ApiClient } from './client'; + +export class SeekerApi { + constructor(private readonly client: ApiClient) {} + + getConfig(): Promise { + return this.client.get('/api/configuration/seeker'); + } + + updateConfig(body: Record): Promise { + return this.client.put('/api/configuration/seeker', body); + } + + listCustomFormatScores(query?: Record): Promise { + const qs = query ? '?' + new URLSearchParams(query).toString() : ''; + return this.client.get(`/api/seeker/cf-scores${qs}`); + } + + listCustomFormatScoreUpgrades(): Promise { + return this.client.get('/api/seeker/cf-scores/upgrades'); + } + + listCustomFormatScoreInstances(): Promise { + return this.client.get('/api/seeker/cf-scores/instances'); + } + + getCustomFormatScoreStats(): Promise { + return this.client.get('/api/seeker/cf-scores/stats'); + } + + getCustomFormatScoreHistory(instanceId: string, itemId: string): Promise { + return this.client.get(`/api/seeker/cf-scores/${instanceId}/${itemId}/history`); + } + + getSearchStatsSummary(): Promise { + return this.client.get('/api/seeker/search-stats/summary'); + } + + getSearchEvents(query?: Record): Promise { + const qs = query ? '?' + new URLSearchParams(query).toString() : ''; + return this.client.get(`/api/seeker/search-stats/events${qs}`); + } +} diff --git a/e2e/tests/helpers/api/signalr.ts b/e2e/tests/helpers/api/signalr.ts new file mode 100644 index 00000000..bee96d3c --- /dev/null +++ b/e2e/tests/helpers/api/signalr.ts @@ -0,0 +1,61 @@ +import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; +import { TEST_CONFIG } from '../test-config'; + +export interface SignalRConnectOptions { + accessToken: string; + hubUrl: string; + baseUrl?: string; +} + +/** + * Build a SignalR connection to the given hub. Caller is responsible for `start()` and `stop()`. + */ +export function buildHubConnection(opts: SignalRConnectOptions): HubConnection { + const baseUrl = opts.baseUrl ?? TEST_CONFIG.appUrl; + return new HubConnectionBuilder() + .withUrl(`${baseUrl}${opts.hubUrl}`, { + accessTokenFactory: () => opts.accessToken, + }) + .configureLogging(LogLevel.Warning) + .build(); +} + +/** + * Subscribes to a SignalR client method. The .NET hub publishes events with + * PascalCase names (`EventsReceived`), but the @microsoft/signalr Node client + * matches them after lowercasing on the wire — so we register both casings + * defensively so neither side has to know which is being used. + */ +export async function waitForEvent( + connection: HubConnection, + eventName: string, + predicate: (payload: T) => boolean = () => true, + timeoutMs = 30_000, +): Promise { + const lowered = eventName.toLowerCase(); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + connection.off(eventName, handler); + if (lowered !== eventName) { + connection.off(lowered, handler); + } + reject(new Error(`Timed out waiting for SignalR event "${eventName}"`)); + }, timeoutMs); + + const handler = (payload: T) => { + if (predicate(payload)) { + clearTimeout(timeout); + connection.off(eventName, handler); + if (lowered !== eventName) { + connection.off(lowered, handler); + } + resolve(payload); + } + }; + + connection.on(eventName, handler); + if (lowered !== eventName) { + connection.on(lowered, handler); + } + }); +} diff --git a/e2e/tests/helpers/api/status.ts b/e2e/tests/helpers/api/status.ts new file mode 100644 index 00000000..02e0662b --- /dev/null +++ b/e2e/tests/helpers/api/status.ts @@ -0,0 +1,49 @@ +import { ApiClient } from './client'; + +export class StatusApi { + constructor(private readonly client: ApiClient) {} + + system(): Promise { + return this.client.get('/api/status'); + } + + downloadClients(): Promise { + return this.client.get('/api/status/download-client'); + } + + arrs(): Promise { + return this.client.get('/api/status/arrs'); + } +} + +export class HealthApi { + constructor(private readonly client: ApiClient) {} + + liveness(): Promise { + return this.client.get('/health'); + } + + readiness(): Promise { + return this.client.get('/health/ready'); + } + + detailed(): Promise { + return this.client.get('/health/detailed'); + } + + downloadClients(): Promise { + return this.client.get('/api/health'); + } + + downloadClient(id: string): Promise { + return this.client.get(`/api/health/${id}`); + } + + triggerCheck(): Promise { + return this.client.post('/api/health/check'); + } + + triggerCheckOne(id: string): Promise { + return this.client.post(`/api/health/check/${id}`); + } +} diff --git a/e2e/tests/helpers/arr-stub.ts b/e2e/tests/helpers/arr-stub.ts deleted file mode 100644 index 378a4398..00000000 --- a/e2e/tests/helpers/arr-stub.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { createServer, IncomingMessage, Server, ServerResponse } from 'node:http'; -import { AddressInfo } from 'node:net'; - -/** - * Minimal Sonarr/Radarr stub for e2e tests. - * - * The Playwright runner hosts this stub on the test machine. The Cleanuparr - * app container reaches it via `host.docker.internal` — Docker Desktop adds - * that name automatically, and Linux CI gets it through the `extra_hosts: - * host.docker.internal:host-gateway` entry in docker-compose.e2e.yml. The - * stub only models the three endpoints the MalwareBlocker job touches: - * - * - `GET /api/v3/system/status` — minimal version payload - * - `GET /api/v3/queue?page=N&pageSize=M` — returns whatever records the - * test set via `setQueue(...)`; supports paging only enough to satisfy - * the iterator (`totalRecords` + `records` for page 1) - * - `DELETE /api/v3/queue/{id}` — records the id so the test can assert - * the arr-side removal happened, then returns 200 - * - * Any other request returns 200 with an empty body to avoid spurious errors - * if Cleanuparr probes endpoints we haven't modeled. - */ -export interface StubQueueRecord { - id: number; - downloadId: string; - title: string; - protocol: 'torrent' | 'usenet'; - seriesId: number; - episodeId: number; - seasonNumber?: number; - status?: string; - trackedDownloadStatus?: string; - trackedDownloadState?: string; - sizeLeft?: number; -} - -export interface StubDeleteCall { - id: number; - removeFromClient: boolean; - blocklist: boolean; - changeCategory: boolean; -} - -export class ArrStubServer { - private server: Server | null = null; - private queue: StubQueueRecord[] = []; - private deletes: StubDeleteCall[] = []; - private queueRequestCount = 0; - private listenPort = 0; - - async start(port = 9100): Promise { - if (this.server) { - throw new Error('ArrStubServer already started'); - } - - this.server = createServer((req, res) => this.handle(req, res)); - - await new Promise((resolve, reject) => { - const onError = (err: Error) => reject(err); - this.server!.once('error', onError); - // Bind on all interfaces so the cleanuparr container can reach us - // via `host.docker.internal`. 127.0.0.1 would only accept local - // connections, which Docker Desktop cannot route to. - this.server!.listen(port, '0.0.0.0', () => { - this.server!.off('error', onError); - const addr = this.server!.address() as AddressInfo; - this.listenPort = addr.port; - resolve(); - }); - }); - } - - async stop(): Promise { - if (!this.server) { - return; - } - await new Promise((resolve, reject) => { - this.server!.close((err) => (err ? reject(err) : resolve())); - }); - this.server = null; - } - - get port(): number { - return this.listenPort; - } - - get url(): string { - return `http://127.0.0.1:${this.listenPort}`; - } - - /** URL the cleanuparr container should use to reach this stub. */ - get containerUrl(): string { - return `http://host.docker.internal:${this.listenPort}`; - } - - setQueue(records: StubQueueRecord[]): void { - this.queue = records; - } - - resetCounters(): void { - this.deletes = []; - this.queueRequestCount = 0; - } - - getDeletes(): StubDeleteCall[] { - return [...this.deletes]; - } - - /** - * Resolves once the stub has received at least one `GET /api/v3/queue` - * request since the last {@link resetCounters} — i.e. once a MalwareBlocker - * iteration has actually run against this stub. - */ - async waitForQueueRequest(timeoutMs = 15_000): Promise { - return this.waitForQueueRequestCount(1, timeoutMs); - } - - /** - * Resolves once the stub has received at least `n` queue requests since the - * last {@link resetCounters}. Useful for negative assertions: waiting for - * the second iteration to start proves the first one finished end-to-end - * (Quartz won't fire the next cron tick until the previous run completes). - */ - async waitForQueueRequestCount(n: number, timeoutMs = 30_000): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (this.queueRequestCount >= n) { - return true; - } - await new Promise((r) => setTimeout(r, 200)); - } - return false; - } - - private handle(req: IncomingMessage, res: ServerResponse): void { - const url = req.url ?? ''; - const method = req.method ?? 'GET'; - - if (method === 'GET' && url.startsWith('/api/v3/system/status')) { - this.sendJson(res, 200, { version: '4.0.0.0', appName: 'Sonarr' }); - return; - } - - if (method === 'GET' && url.startsWith('/api/v3/queue')) { - this.queueRequestCount++; - this.sendJson(res, 200, { - page: 1, - pageSize: this.queue.length || 10, - totalRecords: this.queue.length, - records: this.queue.map((r) => ({ - id: r.id, - downloadId: r.downloadId, - title: r.title, - protocol: r.protocol, - seriesId: r.seriesId, - episodeId: r.episodeId, - seasonNumber: r.seasonNumber ?? 1, - status: r.status ?? 'downloading', - trackedDownloadStatus: r.trackedDownloadStatus ?? 'ok', - trackedDownloadState: r.trackedDownloadState ?? 'downloading', - statusMessages: [], - sizeLeft: r.sizeLeft ?? 0, - })), - }); - return; - } - - const deleteMatch = method === 'DELETE' && /^\/api\/v3\/queue\/(\d+)(?:\?|$)/.exec(url); - if (deleteMatch) { - const id = Number(deleteMatch[1]); - const query = url.includes('?') ? url.slice(url.indexOf('?') + 1) : ''; - const params = new URLSearchParams(query); - this.deletes.push({ - id, - removeFromClient: params.get('removeFromClient') === 'true', - blocklist: params.get('blocklist') === 'true', - changeCategory: params.get('changeCategory') === 'true', - }); - this.queue = this.queue.filter((r) => r.id !== id); - res.statusCode = 200; - res.end(); - return; - } - - res.statusCode = 200; - res.end(); - } - - private sendJson(res: ServerResponse, status: number, body: unknown): void { - const payload = JSON.stringify(body); - res.statusCode = status; - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Content-Length', Buffer.byteLength(payload).toString()); - res.end(payload); - } -} diff --git a/e2e/tests/helpers/mocks/arr-stubs.ts b/e2e/tests/helpers/mocks/arr-stubs.ts new file mode 100644 index 00000000..41b7c363 --- /dev/null +++ b/e2e/tests/helpers/mocks/arr-stubs.ts @@ -0,0 +1,113 @@ +import type { Mapping, WireMockClient } from './wiremock-client'; + +/** + * Convenience stub bundles for Sonarr/Radarr/Lidarr/Readarr/Whisparr. + * Tests register only the stubs they need; defaults can be pulled in via {@link applyArrDefaults}. + */ + +export interface ArrHealthStubOptions { + apiKey?: string; + status?: number; + version?: string; +} + +export function arrHealthStub(opts: ArrHealthStubOptions = {}): Mapping { + return { + request: { + method: 'GET', + urlPathPattern: '/api/v[0-9]+/system/status', + ...(opts.apiKey + ? { headers: { 'X-Api-Key': { equalTo: opts.apiKey } } } + : {}), + }, + response: { + status: opts.status ?? 200, + jsonBody: { version: opts.version ?? '4.0.0', appName: 'Sonarr' }, + }, + }; +} + +export function arrUnauthorizedStub(urlPathPattern = '/api/v3/.*'): Mapping { + return { + request: { method: 'ANY', urlPathPattern }, + response: { status: 401, jsonBody: { message: 'Unauthorized' } }, + priority: 10, + }; +} + +export function arrEmptyQueueStub(): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/queue' }, + response: { + status: 200, + jsonBody: { page: 1, pageSize: 50, totalRecords: 0, records: [] }, + }, + }; +} + +export interface QueueRecord { + id: number; + title: string; + status: string; + trackedDownloadStatus?: string; + trackedDownloadState?: string; + errorMessage?: string; + downloadId: string; + protocol?: string; + estimatedCompletionTime?: string; +} + +export function arrQueueStub(records: QueueRecord[]): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/queue' }, + response: { + status: 200, + jsonBody: { + page: 1, + pageSize: records.length, + totalRecords: records.length, + records, + }, + }, + }; +} + +export function arrCommandTriggerStub(commandId = 1): Mapping { + return { + request: { method: 'POST', urlPath: '/api/v3/command' }, + response: { + status: 201, + jsonBody: { id: commandId, name: 'AutomaticSearch', status: 'queued' }, + }, + }; +} + +export function arrCommandCompletedStub(commandId: number, status = 'completed'): Mapping { + return { + request: { method: 'GET', urlPath: `/api/v3/command/${commandId}` }, + response: { status: 200, jsonBody: { id: commandId, status } }, + }; +} + +export function arrTagsStub(tags: Array<{ id: number; label: string }> = []): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/tag' }, + response: { status: 200, jsonBody: tags }, + }; +} + +export function arrCustomFormatsStub(): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/customformat' }, + response: { status: 200, jsonBody: [] }, + }; +} + +export async function applyArrDefaults(arr: WireMockClient): Promise { + await arr.stubMany([ + arrHealthStub(), + arrEmptyQueueStub(), + arrTagsStub(), + arrCustomFormatsStub(), + ]); +} diff --git a/e2e/tests/helpers/mocks/blocklist-stubs.ts b/e2e/tests/helpers/mocks/blocklist-stubs.ts new file mode 100644 index 00000000..cd9db9b5 --- /dev/null +++ b/e2e/tests/helpers/mocks/blocklist-stubs.ts @@ -0,0 +1,23 @@ +import type { Mapping, WireMockClient } from './wiremock-client'; + +const DEFAULT_BLOCKLIST = [ + '*.exe', + '*.bat', + '*.cmd', + '*.scr', + '*.com', + '*.iso', + '*.zipx', + '*.lnk', +].join('\n'); + +export function blocklistResponseStub(content: string = DEFAULT_BLOCKLIST, urlPath = '/blacklist'): Mapping { + return { + request: { method: 'GET', urlPath }, + response: { status: 200, body: content, headers: { 'Content-Type': 'text/plain' } }, + }; +} + +export async function applyBlocklistDefaults(blocklist: WireMockClient): Promise { + await blocklist.stubMany([blocklistResponseStub()]); +} diff --git a/e2e/tests/helpers/mocks/download-client-stubs.ts b/e2e/tests/helpers/mocks/download-client-stubs.ts new file mode 100644 index 00000000..66736cba --- /dev/null +++ b/e2e/tests/helpers/mocks/download-client-stubs.ts @@ -0,0 +1,160 @@ +import type { Mapping, WireMockClient } from './wiremock-client'; + +/** + * Convenience stub bundles for qBittorrent / Transmission / Deluge / uTorrent / rTorrent. + * The stubs simulate authentication handshakes and torrent listings. + */ + +export function qbitVersionStub(version = '5.0.0'): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v2/app/version' }, + response: { status: 200, body: version, headers: { 'Content-Type': 'text/plain' } }, + }; +} + +export function qbitLoginOkStub(sid = 'test-sid'): Mapping { + return { + request: { method: 'POST', urlPath: '/api/v2/auth/login' }, + response: { + status: 200, + body: 'Ok.', + headers: { 'Content-Type': 'text/plain', 'Set-Cookie': `SID=${sid}; Path=/` }, + }, + }; +} + +export function qbitLoginFailStub(): Mapping { + return { + request: { method: 'POST', urlPath: '/api/v2/auth/login' }, + response: { status: 200, body: 'Fails.', headers: { 'Content-Type': 'text/plain' } }, + priority: 10, + }; +} + +export function qbitTorrentsStub(torrents: Array> = []): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v2/torrents/info' }, + response: { status: 200, jsonBody: torrents }, + }; +} + +/** + * Transmission's real wire protocol: first POST to /transmission/rpc without an + * X-Transmission-Session-Id header returns 409 + a fresh session id; the + * client must replay the request with that header. We model both phases with + * two stubs distinguished by request-header presence. + */ +export function transmissionSessionStub(sessionId = 'test-session'): Mapping[] { + return [ + // Phase 1: client has no session id yet → reject with 409 + session header. + { + request: { + method: 'POST', + urlPath: '/transmission/rpc', + headers: { 'X-Transmission-Session-Id': { absent: true } }, + }, + response: { + status: 409, + body: '409: ConflictConflict', + headers: { + 'X-Transmission-Session-Id': sessionId, + 'Content-Type': 'text/html; charset=ISO-8859-1', + }, + }, + priority: 1, + }, + // Phase 2: client retries with the session header → success. + { + request: { + method: 'POST', + urlPath: '/transmission/rpc', + headers: { 'X-Transmission-Session-Id': { equalTo: sessionId } }, + }, + response: { + status: 200, + jsonBody: { + result: 'success', + arguments: { + version: '4.0.6', + 'rpc-version': 17, + 'rpc-version-minimum': 1, + }, + }, + headers: { + 'Content-Type': 'application/json', + 'X-Transmission-Session-Id': sessionId, + }, + }, + priority: 5, + }, + ]; +} + +export function delugeLoginStub(sessionCookie = 'test-deluge'): Mapping { + return { + request: { + method: 'POST', + urlPath: '/json', + bodyPatterns: [{ matchesJsonPath: '$.method' }], + }, + response: { + status: 200, + jsonBody: { id: 1, result: true, error: null }, + headers: { 'Set-Cookie': `_session_id=${sessionCookie}; Path=/` }, + }, + }; +} + +/** + * µTorrent flow: + * 1. GET /gui/token.html → HTML body containing `
…
` plus + * a `Set-Cookie: GUID=…` header. + * 2. GET /gui/?list=1&token=… → JSON `{"torrents":[]}` to satisfy + * {@link UTorrentResponseParser.ParseTorrentList}. + */ +export function utorrentStubs(token = 'utorrent-token', guid = 'test-guid'): Mapping[] { + return [ + { + request: { method: 'GET', urlPath: '/gui/token.html' }, + response: { + status: 200, + body: ``, + headers: { + 'Content-Type': 'text/html', + 'Set-Cookie': `GUID=${guid}; Path=/`, + }, + }, + }, + { + request: { method: 'GET', urlPath: '/gui/' }, + response: { + status: 200, + jsonBody: { torrents: [], torrentc: '0', label: [] }, + headers: { 'Content-Type': 'application/json' }, + }, + }, + ]; +} + +/** + * @deprecated Prefer {@link utorrentStubs} which also registers the list stub. + * Kept for backwards-compatible imports. + */ +export function utorrentTokenStub(token = 'utorrent-token'): Mapping { + return utorrentStubs(token)[0]; +} + +export function rtorrentXmlRpcStub(): Mapping { + return { + request: { method: 'POST', urlPath: '/RPC2' }, + response: { + status: 200, + body: '0.9.8', + headers: { 'Content-Type': 'text/xml' }, + }, + }; +} + +export async function applyQBitDefaults(dlc: WireMockClient): Promise { + await dlc.stubMany([qbitVersionStub(), qbitLoginOkStub(), qbitTorrentsStub()]); +} diff --git a/e2e/tests/helpers/mocks/index.ts b/e2e/tests/helpers/mocks/index.ts new file mode 100644 index 00000000..3dda49ae --- /dev/null +++ b/e2e/tests/helpers/mocks/index.ts @@ -0,0 +1,5 @@ +export * from './wiremock-client'; +export * as ArrStubs from './arr-stubs'; +export * as DownloadClientStubs from './download-client-stubs'; +export * as NotificationStubs from './notification-stubs'; +export * as BlocklistStubs from './blocklist-stubs'; diff --git a/e2e/tests/helpers/mocks/notification-stubs.ts b/e2e/tests/helpers/mocks/notification-stubs.ts new file mode 100644 index 00000000..41ac82cc --- /dev/null +++ b/e2e/tests/helpers/mocks/notification-stubs.ts @@ -0,0 +1,62 @@ +import type { Mapping, WireMockClient } from './wiremock-client'; + +export function discordWebhookStub(): Mapping { + return { + request: { method: 'POST', urlPathPattern: '/webhooks/.*' }, + response: { status: 204 }, + }; +} + +export function telegramSendMessageStub(): Mapping { + return { + request: { method: 'POST', urlPathPattern: '/bot.*/sendMessage' }, + response: { status: 200, jsonBody: { ok: true, result: { message_id: 1 } } }, + }; +} + +export function ntfyTopicStub(): Mapping { + return { + request: { method: 'POST', urlPathPattern: '/.*' }, + response: { status: 200, jsonBody: { id: 'mock', time: 0, expires: 0, event: 'message' } }, + }; +} + +export function gotifyMessageStub(): Mapping { + return { + request: { method: 'POST', urlPath: '/message' }, + response: { status: 200, jsonBody: { id: 1, message: 'ok' } }, + }; +} + +export function pushoverMessageStub(): Mapping { + return { + request: { method: 'POST', urlPath: '/1/messages.json' }, + response: { status: 200, jsonBody: { status: 1, request: 'mock' } }, + }; +} + +export function notifiarrStub(): Mapping { + return { + request: { method: 'POST', urlPathPattern: '/api/v1/notification/.*' }, + response: { status: 200, jsonBody: { result: 'success' } }, + }; +} + +export function appriseStub(): Mapping { + return { + request: { method: 'POST', urlPathPattern: '/notify/.*' }, + response: { status: 200 }, + }; +} + +export async function applyNotificationDefaults(notify: WireMockClient): Promise { + await notify.stubMany([ + discordWebhookStub(), + telegramSendMessageStub(), + ntfyTopicStub(), + gotifyMessageStub(), + pushoverMessageStub(), + notifiarrStub(), + appriseStub(), + ]); +} diff --git a/e2e/tests/helpers/mocks/wiremock-client.ts b/e2e/tests/helpers/mocks/wiremock-client.ts new file mode 100644 index 00000000..881c2a4c --- /dev/null +++ b/e2e/tests/helpers/mocks/wiremock-client.ts @@ -0,0 +1,185 @@ +import { TEST_CONFIG } from '../test-config'; + +export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'ANY'; + +export interface MappingRequest { + method: RequestMethod; + url?: string; + urlPath?: string; + urlPathPattern?: string; + urlPattern?: string; + bodyPatterns?: Array>; + headers?: Record< + string, + { equalTo?: string; matches?: string; contains?: string; absent?: boolean } + >; + queryParameters?: Record; +} + +export interface MappingResponse { + status?: number; + body?: string; + jsonBody?: unknown; + bodyFileName?: string; + headers?: Record; + fixedDelayMilliseconds?: number; +} + +export interface Mapping { + request: MappingRequest; + response: MappingResponse; + priority?: number; + metadata?: Record; +} + +export interface RequestLogEntry { + request: { + method: string; + url: string; + headers: Record; + body?: string; + bodyAsBase64?: string; + }; + response: { + status: number; + body?: string; + }; + loggedDate: number; +} + +/** + * Thin wrapper over the WireMock admin API. One instance per stub container. + * https://wiremock.org/docs/standalone/admin-api-reference/ + */ +export class WireMockClient { + constructor(public readonly adminUrl: string) {} + + async stub(mapping: Mapping): Promise<{ id: string }> { + const res = await fetch(`${this.adminUrl}/mappings`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mapping), + }); + if (!res.ok) { + throw new Error(`Failed to register WireMock stub at ${this.adminUrl}: ${res.status} ${await res.text()}`); + } + const data = await res.json(); + return { id: data.id }; + } + + async stubMany(mappings: Mapping[]): Promise { + await Promise.all(mappings.map((m) => this.stub(m))); + } + + async resetMappings(): Promise { + const res = await fetch(`${this.adminUrl}/mappings`, { method: 'DELETE' }); + if (!res.ok) { + throw new Error(`Failed to reset WireMock mappings at ${this.adminUrl}: ${res.status}`); + } + } + + async resetAll(): Promise { + const res = await fetch(`${this.adminUrl}/reset`, { method: 'POST' }); + if (!res.ok) { + throw new Error(`Failed to reset WireMock at ${this.adminUrl}: ${res.status}`); + } + } + + async requests(): Promise { + const res = await fetch(`${this.adminUrl}/requests`); + if (!res.ok) { + throw new Error(`Failed to fetch WireMock requests at ${this.adminUrl}: ${res.status}`); + } + const data = await res.json(); + return data.requests ?? []; + } + + async findRequests(criteria: { method?: string; urlPath?: string; urlPattern?: string }): Promise { + const res = await fetch(`${this.adminUrl}/requests/find`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(criteria), + }); + if (!res.ok) { + throw new Error(`Failed to find WireMock requests at ${this.adminUrl}: ${res.status}`); + } + const data = await res.json(); + return data.requests ?? []; + } + + async waitForRequest( + criteria: { method?: string; urlPath?: string; urlPattern?: string }, + timeoutMs = 30_000, + ): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const matches = await this.findRequests(criteria); + if (matches.length > 0) { + return matches[0]; + } + await new Promise((r) => setTimeout(r, 250)); + } + throw new Error(`Timed out waiting for WireMock request matching ${JSON.stringify(criteria)}`); + } + + async waitReady(timeoutMs = 60_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${this.adminUrl}/mappings`); + if (res.ok) { + return; + } + } catch { + // not ready yet + } + await new Promise((r) => setTimeout(r, 1_000)); + } + throw new Error(`WireMock at ${this.adminUrl} did not become ready within ${timeoutMs}ms`); + } +} + +/** + * Aggregate of all WireMock containers, one per external integration class. + * Each test holds a single instance via fixtures and resets all mappings on teardown. + */ +export class MockServers { + readonly arr: WireMockClient; + readonly downloadClient: WireMockClient; + readonly notify: WireMockClient; + readonly blocklist: WireMockClient; + + constructor() { + this.arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl); + this.downloadClient = new WireMockClient(TEST_CONFIG.mocks.downloadClientAdminUrl); + this.notify = new WireMockClient(TEST_CONFIG.mocks.notifyAdminUrl); + this.blocklist = new WireMockClient(TEST_CONFIG.mocks.blocklistAdminUrl); + } + + async resetAll(): Promise { + await Promise.all([ + this.arr.resetAll(), + this.downloadClient.resetAll(), + this.notify.resetAll(), + this.blocklist.resetAll(), + ]); + } + + async waitReady(timeoutMs = 60_000): Promise { + await Promise.all([ + this.arr.waitReady(timeoutMs), + this.downloadClient.waitReady(timeoutMs), + this.notify.waitReady(timeoutMs), + this.blocklist.waitReady(timeoutMs), + ]); + } + + get urls() { + return { + arr: TEST_CONFIG.mocks.arrUrl, + downloadClient: TEST_CONFIG.mocks.downloadClientUrl, + notify: TEST_CONFIG.mocks.notifyUrl, + blocklist: TEST_CONFIG.mocks.blocklistUrl, + }; + } +} diff --git a/e2e/tests/helpers/test-config.ts b/e2e/tests/helpers/test-config.ts index e1f18149..cc100263 100644 --- a/e2e/tests/helpers/test-config.ts +++ b/e2e/tests/helpers/test-config.ts @@ -12,4 +12,15 @@ export const TEST_CONFIG = { oidcUsername: 'testuser', oidcPassword: 'testpass', oidcProviderName: 'Keycloak', + + mocks: { + arrUrl: 'http://localhost:9100', + downloadClientUrl: 'http://localhost:9200', + notifyUrl: 'http://localhost:9300', + blocklistUrl: 'http://localhost:9400', + arrAdminUrl: 'http://localhost:9100/__admin', + downloadClientAdminUrl: 'http://localhost:9200/__admin', + notifyAdminUrl: 'http://localhost:9300/__admin', + blocklistAdminUrl: 'http://localhost:9400/__admin', + }, } as const; diff --git a/e2e/tests/helpers/test-lifecycle.ts b/e2e/tests/helpers/test-lifecycle.ts new file mode 100644 index 00000000..2e07645d --- /dev/null +++ b/e2e/tests/helpers/test-lifecycle.ts @@ -0,0 +1,84 @@ +import { execSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + CleanuparrApi, + ensureAdminAccount, + waitForApp, +} from './api'; +import { TEST_CONFIG } from './test-config'; + +export interface RestartOptions { + /** + * Configure OIDC after admin setup. Required by tests under `tests/oidc/` + * and `tests/account/oidc-config.api.spec.ts`. Defaults to `false` to keep + * setup minimal for folders that don't touch OIDC. + */ + configureOidc?: boolean; +} + +const COMPOSE_FILE = 'docker-compose.e2e.yml'; +const AUTH_FILE = path.resolve(process.cwd(), 'playwright/.auth/admin.json'); + +function restartAppContainer(): void { + execSync(`docker compose -f ${COMPOSE_FILE} restart app`, { + stdio: 'inherit', + env: process.env, + }); +} + +async function configureOidcForLegacyTests(api: CleanuparrApi): Promise { + await api.account.patchOidcConfig({ + enabled: true, + issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, + clientId: TEST_CONFIG.clientId, + clientSecret: TEST_CONFIG.clientSecret, + scopes: 'openid profile email', + providerName: TEST_CONFIG.oidcProviderName, + }); +} + +/** + * Restart the app container, re-bootstrap the admin account, and persist the + * resulting tokens to disk so subsequent tests in this folder can pick them + * up via the {@link adminTokens} helper. + * + * Called once at the start of every spec folder via the matching + * `tests/_setup/.setup.ts` file. + */ +export async function restartAppAndWait(opts: RestartOptions = {}): Promise { + restartAppContainer(); + + const api = new CleanuparrApi(); + await waitForApp(api.client); + await ensureAdminAccount(api); + + const tokens = await api.auth.loginAndCaptureTokens( + TEST_CONFIG.adminUsername, + TEST_CONFIG.adminPassword, + ); + + api.setToken(tokens.accessToken); + if (opts.configureOidc) { + await configureOidcForLegacyTests(api); + } + + fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); + fs.writeFileSync(AUTH_FILE, JSON.stringify(tokens, null, 2)); +} + +/** + * Loads the admin tokens written by the most recent folder setup. + * Throws if no setup has run (i.e. the spec was executed without its + * `setup:*` project dependency). + */ +export function adminTokens(): { accessToken: string; refreshToken: string; expiresIn: number } { + if (!fs.existsSync(AUTH_FILE)) { + throw new Error( + `Admin tokens file not found at ${AUTH_FILE}.\n` + + `Each spec folder must be run with its matching setup: project; ` + + `the setup project writes this file before tests run.`, + ); + } + return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')); +} diff --git a/e2e/tests/malware-blocker/malware-blocker-config.api.spec.ts b/e2e/tests/malware-blocker/malware-blocker-config.api.spec.ts new file mode 100644 index 00000000..87eda02d --- /dev/null +++ b/e2e/tests/malware-blocker/malware-blocker-config.api.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('MalwareBlocker — config', () => { + test('GET returns config singleton', async ({ api }) => { + const res = await api.malwareBlocker.getConfig(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('enabled'); + expect(body).toHaveProperty('cronExpression'); + }); + + test('PUT toggles enabled (requires at least one blocklist when enabling)', async ({ api }) => { + const before = await (await api.malwareBlocker.getConfig()).json(); + + const enabledWithBlocklist = { + ...before, + enabled: true, + sonarr: { + ...(before.sonarr ?? {}), + enabled: true, + blocklistType: 'Blacklist', + blocklistPath: 'https://example.com/blocklist.txt', + }, + }; + + const res = await api.malwareBlocker.updateConfig(enabledWithBlocklist); + expect(res.ok).toBe(true); + + const after = await (await api.malwareBlocker.getConfig()).json(); + expect(after.enabled).toBe(true); + + await api.malwareBlocker.updateConfig(before); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.malwareBlocker.getConfig(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/malware-blocker/malware-blocker-job-execution.api.spec.ts b/e2e/tests/malware-blocker/malware-blocker-job-execution.api.spec.ts new file mode 100644 index 00000000..a5769a33 --- /dev/null +++ b/e2e/tests/malware-blocker/malware-blocker-job-execution.api.spec.ts @@ -0,0 +1,13 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('MalwareBlocker — job execution', () => { + test('manual trigger is accepted or rejected, never a server error', async ({ api }) => { + const before = await (await api.malwareBlocker.getConfig()).json(); + await api.malwareBlocker.updateConfig({ ...before, enabled: true }); + + const trigger = await api.jobs.trigger('MalwareBlocker'); + expect(trigger.status === 200 || (trigger.status >= 400 && trigger.status < 500)).toBe(true); + + await api.malwareBlocker.updateConfig(before); + }); +}); diff --git a/e2e/tests/malware-blocker-ui.spec.ts b/e2e/tests/malware-blocker/malware-blocker-ui.spec.ts similarity index 99% rename from e2e/tests/malware-blocker-ui.spec.ts rename to e2e/tests/malware-blocker/malware-blocker-ui.spec.ts index 05be2cb0..ed4c63be 100644 --- a/e2e/tests/malware-blocker-ui.spec.ts +++ b/e2e/tests/malware-blocker/malware-blocker-ui.spec.ts @@ -6,7 +6,7 @@ import { ensureAccordionExpanded, expectGuardOnLeave, expectNoGuardOnLeave, -} from './helpers/ui'; +} from '../helpers/ui'; // Behavior-parity spec for the Malware Blocker settings form (page-level form + per-arr accordion). test.describe('Malware Blocker UI', () => { diff --git a/e2e/tests/malware-blocker.spec.ts b/e2e/tests/malware-blocker/malware-blocker.spec.ts similarity index 71% rename from e2e/tests/malware-blocker.spec.ts rename to e2e/tests/malware-blocker/malware-blocker.spec.ts index 1da1214a..ca4a5e43 100644 --- a/e2e/tests/malware-blocker.spec.ts +++ b/e2e/tests/malware-blocker/malware-blocker.spec.ts @@ -11,10 +11,11 @@ import { getMalwareBlockerConfig, updateMalwareBlockerConfig, triggerJob, -} from './helpers/app-api'; -import { ALL_CLIENTS, TorrentClientFixture } from './helpers/torrent-clients'; -import { buildMultiFileTorrent, chmodIgnoringEPERM, resetDirectory } from './helpers/torrent-fixtures'; -import { ArrStubServer } from './helpers/arr-stub'; +} from '../helpers/app-api'; +import { ALL_CLIENTS, TorrentClientFixture } from '../helpers/torrent-clients'; +import { buildMultiFileTorrent, chmodIgnoringEPERM, resetDirectory } from '../helpers/torrent-fixtures'; +import { WireMockClient, Mapping } from '../helpers/mocks/wiremock-client'; +import { TEST_CONFIG } from '../helpers/test-config'; /** * End-to-end coverage for the `DeleteIfAnyFileBlocked` setting on the @@ -27,8 +28,9 @@ import { ArrStubServer } from './helpers/arr-stub'; * the toggle on, the entire torrent must be removed and the corresponding * Sonarr queue record must be deleted via DELETE /api/v3/queue/{id}. * - * Sonarr is stubbed in-process via {@link ArrStubServer}; the cleanuparr - * app container reaches the stub through `network_mode: host`. + * Sonarr is stubbed by the shared wiremock-arr container: the queue is set + * per test via its admin API and DELETE calls are verified through its + * request journal. * * Distinct torrent names per scenario (`off-*` vs `on-*`) give each test * its own hash and therefore independent client state — without that, @@ -37,12 +39,11 @@ import { ArrStubServer } from './helpers/arr-stub'; * branch before reaching the new code path. */ -const HOST_DOWNLOADS = resolve(__dirname, '..', 'test-data', 'downloads'); +const HOST_DOWNLOADS = resolve(__dirname, '..', '..', 'test-data', 'downloads'); const CLIENT_DOWNLOADS = '/downloads'; const APP_DOWNLOADS = '/e2e-downloads'; const BLOCKLIST_REL_PATH = 'malware-blocker/blocklist.txt'; const APP_BLOCKLIST_PATH = `${APP_DOWNLOADS}/${BLOCKLIST_REL_PATH}`; -const STUB_PORT = 9100; const POLL_TIMEOUT_MS = 30_000; function farFutureCron(): string { @@ -94,6 +95,95 @@ async function torrentPresent( return list.some((t) => t.hash.toLowerCase() === target); } +interface QueueStubRecord { + id: number; + downloadId: string; + title: string; + protocol: 'torrent' | 'usenet'; + seriesId: number; + episodeId: number; +} + +interface DeleteCall { + id: number; + removeFromClient: boolean; + blocklist: boolean; +} + +function arrHealthMapping(): Mapping { + return { + request: { method: 'GET', urlPathPattern: '/api/v[0-9]+/system/status' }, + response: { status: 200, jsonBody: { version: '4.0.0.0', appName: 'Sonarr' } }, + priority: 1, + }; +} + +function arrQueueMapping(records: QueueStubRecord[]): Mapping { + return { + request: { method: 'GET', urlPath: '/api/v3/queue' }, + response: { + status: 200, + jsonBody: { + page: 1, + pageSize: records.length || 10, + totalRecords: records.length, + records: records.map((r) => ({ + id: r.id, + downloadId: r.downloadId, + title: r.title, + protocol: r.protocol, + seriesId: r.seriesId, + episodeId: r.episodeId, + seasonNumber: 1, + status: 'downloading', + trackedDownloadStatus: 'ok', + trackedDownloadState: 'downloading', + statusMessages: [], + sizeLeft: 0, + })), + }, + }, + priority: 1, + }; +} + +function arrCatchAllMapping(): Mapping { + return { + request: { method: 'ANY', urlPathPattern: '/api/v[0-9]+/.*' }, + response: { status: 200, jsonBody: {} }, + priority: 100, + }; +} + +async function stubArrQueue(arr: WireMockClient, records: QueueStubRecord[]): Promise { + await arr.resetAll(); + await arr.stubMany([arrHealthMapping(), arrQueueMapping(records), arrCatchAllMapping()]); +} + +async function queueRequestSeen(arr: WireMockClient, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const reqs = await arr.findRequests({ method: 'GET', urlPath: '/api/v3/queue' }); + if (reqs.length > 0) { + return true; + } + await new Promise((r) => setTimeout(r, 250)); + } + return false; +} + +async function readDeletes(arr: WireMockClient): Promise { + const reqs = (await arr.findRequests({ method: 'DELETE', urlPattern: '/api/v3/queue/.*' })) as unknown as Array<{ url: string }>; + return reqs.map((r) => { + const parsed = new URL(`http://stub${r.url}`); + return { + id: Number(parsed.pathname.split('/').pop()), + removeFromClient: parsed.searchParams.get('removeFromClient') === 'true', + blocklist: parsed.searchParams.get('blocklist') === 'true', + }; + }); +} + async function setMalwareBlocker( token: string, overrides: Record, @@ -114,7 +204,7 @@ async function setMalwareBlocker( test.describe.serial('Malware blocker — DeleteIfAnyFileBlocked', () => { let token: string; - const stub = new ArrStubServer(); + const arr = new WireMockClient(TEST_CONFIG.mocks.arrAdminUrl); let sonarrId: string | undefined; test.beforeAll(async () => { @@ -128,11 +218,12 @@ test.describe.serial('Malware blocker — DeleteIfAnyFileBlocked', () => { chmodIgnoringEPERM(join(HOST_DOWNLOADS, 'malware-blocker'), 0o777); writeFileSync(join(HOST_DOWNLOADS, BLOCKLIST_REL_PATH), '*.exe\n'); - await stub.start(STUB_PORT); + await arr.waitReady(); + await stubArrQueue(arr, []); const sonarrRes = await createArrInstance(token, 'sonarr', { name: 'Malware Blocker Stub', - url: stub.containerUrl, + url: TEST_CONFIG.mocks.arrUrl, apiKey: 'malware-blocker-e2e', version: 4, }); @@ -145,17 +236,17 @@ test.describe.serial('Malware blocker — DeleteIfAnyFileBlocked', () => { if (sonarrId) { await deleteArrInstance(token, 'sonarr', sonarrId); } - await stub.stop(); + await arr.resetAll(); }); for (const fixture of ALL_CLIENTS) { - runClientScenarios(fixture, () => ({ token, stub })); + runClientScenarios(fixture, () => ({ token, arr })); } }); function runClientScenarios( fixture: TorrentClientFixture, - getCtx: () => { token: string; stub: ArrStubServer }, + getCtx: () => { token: string; arr: WireMockClient }, ): void { const { driver } = fixture; const slug = SLUG_BY_TYPE[driver.typeName]; @@ -223,7 +314,7 @@ function runClientScenarios( test('toggle OFF keeps the torrent in the client', async () => { test.setTimeout(120_000); - const { token, stub } = getCtx(); + const { token, arr } = getCtx(); await setMalwareBlocker(token, { enabled: true, @@ -240,7 +331,7 @@ function runClientScenarios( }, }); - stub.setQueue([ + await stubArrQueue(arr, [ { id: 1001, downloadId: offTorrent.infoHash, @@ -250,23 +341,22 @@ function runClientScenarios( episodeId: 1, }, ]); - stub.resetCounters(); const trig = await triggerJob(token, 'MalwareBlocker'); expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); - const ran = await stub.waitForQueueRequest(POLL_TIMEOUT_MS); + const ran = await queueRequestSeen(arr, POLL_TIMEOUT_MS); expect(ran, 'malware blocker should have called the sonarr stub /api/v3/queue').toBe(true); await new Promise((r) => setTimeout(r, TRIGGER_SETTLE_MS)); - expect(stub.getDeletes(), 'no sonarr queue items should be deleted when toggle is off').toEqual([]); + expect(await readDeletes(arr), 'no sonarr queue items should be deleted when toggle is off').toEqual([]); expect(await torrentPresent(driver, offTorrent.infoHash), `expected ${offTorrent.infoHash} to remain in ${driver.typeName} (toggle off)`).toBe(true); }); test('toggle ON removes the torrent and notifies sonarr', async () => { test.setTimeout(120_000); - const { token, stub } = getCtx(); + const { token, arr } = getCtx(); await setMalwareBlocker(token, { enabled: true, @@ -283,7 +373,7 @@ function runClientScenarios( }, }); - stub.setQueue([ + await stubArrQueue(arr, [ { id: 1002, downloadId: onTorrent.infoHash, @@ -293,20 +383,19 @@ function runClientScenarios( episodeId: 2, }, ]); - stub.resetCounters(); const trig = await triggerJob(token, 'MalwareBlocker'); expect(trig.ok, `triggerJob: ${trig.status}`).toBe(true); const start = Date.now(); while (Date.now() - start < POLL_TIMEOUT_MS) { - if (stub.getDeletes().some((d) => d.id === 1002)) { + if ((await readDeletes(arr)).some((d) => d.id === 1002)) { break; } await new Promise((r) => setTimeout(r, 500)); } - const deletes = stub.getDeletes(); + const deletes = await readDeletes(arr); const onDelete = deletes.find((d) => d.id === 1002); expect(onDelete, `sonarr queue item 1002 should have been deleted (saw ${JSON.stringify(deletes)})`).toBeDefined(); expect(onDelete!.removeFromClient, 'malware blocker should ask sonarr to remove the torrent from the client').toBe(true); diff --git a/e2e/tests/notifications/apprise-cli-status.api.spec.ts b/e2e/tests/notifications/apprise-cli-status.api.spec.ts new file mode 100644 index 00000000..ffbb3bdd --- /dev/null +++ b/e2e/tests/notifications/apprise-cli-status.api.spec.ts @@ -0,0 +1,15 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Notifications — Apprise CLI status', () => { + test('GET returns availability flag', async ({ api }) => { + const res = await api.notifications.appriseCliStatus(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.available).toBe('boolean'); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.notifications.appriseCliStatus(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/notifications/discord-test-send.api.spec.ts b/e2e/tests/notifications/discord-test-send.api.spec.ts new file mode 100644 index 00000000..7fe6a845 --- /dev/null +++ b/e2e/tests/notifications/discord-test-send.api.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '../fixtures/base'; + +// The Discord provider validates the webhook URL against +// `https?://discord(app)?.com/api/webhooks/...` before any HTTP call is made, +// so WireMock cannot intercept it without DNS hijacking. We still verify the +// test endpoint surfaces a failure for an unreachable / fake host — that's +// the realistic signal the UI gets when a user pastes a bogus webhook URL. + +test.describe('Notifications — Discord test send', () => { + test('POST returns failure when webhook URL is unreachable', async ({ api }) => { + const res = await api.notifications.test('discord', { + name: 'discord-bad', + // Valid Discord URL shape so the request passes input validation, but + // points at a fake snowflake/token combination that Discord rejects. + webhookUrl: 'https://discord.com/api/webhooks/000000000000000000/cleanuparr-e2e-token', + }); + expect(res.ok).toBe(false); + }); +}); diff --git a/e2e/tests/notifications/notifications-crud.api.spec.ts b/e2e/tests/notifications/notifications-crud.api.spec.ts new file mode 100644 index 00000000..412e0096 --- /dev/null +++ b/e2e/tests/notifications/notifications-crud.api.spec.ts @@ -0,0 +1,89 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import type { NotificationProviderType } from '../helpers/api/notifications'; + +interface ProviderCase { + type: NotificationProviderType; + payload: Record; +} + +const CASES: ProviderCase[] = [ + { + type: 'notifiarr', + payload: { name: 'notifiarr-e2e', apiKey: 'notifiarr-test-apikey-12345', channelId: '12345' }, + }, + { + type: 'apprise', + payload: { + name: 'apprise-e2e', + mode: 'Api', + url: TEST_CONFIG.mocks.notifyUrl, + key: 'apprise-cfg-e2e', + tags: '', + }, + }, + { + type: 'ntfy', + payload: { + name: 'ntfy-e2e', + serverUrl: TEST_CONFIG.mocks.notifyUrl, + topics: ['cleanuparr-e2e'], + authenticationType: 'None', + priority: 'Default', + }, + }, + { + type: 'telegram', + payload: { name: 'telegram-e2e', botToken: 'BOT-TOKEN-12345', chatId: '12345' }, + }, + { + type: 'discord', + payload: { + name: 'discord-e2e', + webhookUrl: 'https://discord.com/api/webhooks/123456789/abcdefghij', + }, + }, + { + type: 'pushover', + payload: { + name: 'pushover-e2e', + apiToken: 'pushover-api-token', + userKey: 'pushover-user-key', + devices: [], + }, + }, + { + type: 'gotify', + payload: { name: 'gotify-e2e', serverUrl: TEST_CONFIG.mocks.notifyUrl, applicationToken: 'gotify-app-token' }, + }, +]; + +test.describe('Notifications — CRUD', () => { + for (const { type, payload } of CASES) { + test(`${type}: create + list + update + delete`, async ({ api }) => { + const create = await api.notifications.create(type, payload); + if (!create.ok) { + console.error(`${type} create failed:`, create.status, await create.text()); + } + expect(create.status).toBeLessThan(300); + const created = await create.json(); + expect(created.id).toBeTruthy(); + + const list = await (await api.notifications.list()).json(); + expect(JSON.stringify(list)).toContain(payload.name as string); + + const update = await api.notifications.update(type, created.id, { + ...payload, + name: `${payload.name}-renamed`, + }); + expect(update.ok).toBe(true); + + const del = await api.notifications.delete(created.id); + expect(del.status).toBe(204); + }); + } + + test('GET providers requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.notifications.list(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/notifications-ui.spec.ts b/e2e/tests/notifications/notifications-ui.spec.ts similarity index 98% rename from e2e/tests/notifications-ui.spec.ts rename to e2e/tests/notifications/notifications-ui.spec.ts index 2e1db671..303e1790 100644 --- a/e2e/tests/notifications-ui.spec.ts +++ b/e2e/tests/notifications/notifications-ui.spec.ts @@ -1,5 +1,5 @@ import { test, expect, type Page } from '@playwright/test'; -import { loginAndGotoSettings, selectOption } from './helpers/ui'; +import { loginAndGotoSettings, selectOption } from '../helpers/ui'; // Behavior-parity spec for the Notifications provider modal (discriminated union over provider type). test.describe('Notifications UI', () => { diff --git a/e2e/tests/oidc-error-display.spec.ts b/e2e/tests/oidc-error-display.spec.ts deleted file mode 100644 index f6ea7989..00000000 --- a/e2e/tests/oidc-error-display.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; - -test.describe('OIDC Error Display', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async () => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - }); - - test.afterAll(async () => { - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); - - test('callback page shows error for missing code and redirects to login', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/oidc/callback`); - - await expect(page.locator('.oidc-callback__error')).toHaveText( - 'Invalid callback - missing authorization code', - ); - await expect(page.locator('.oidc-callback__redirect')).toHaveText( - 'Redirecting to login...', - ); - - await expect(page).toHaveURL(/\/auth\/login/, { timeout: 5_000 }); - }); - - test('callback page shows error for unauthorized', async ({ page }) => { - await page.goto( - `${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=unauthorized`, - ); - - await expect(page.locator('.oidc-callback__error')).toHaveText( - 'Your account is not authorized for OIDC login', - ); - }); - - test('callback page shows error for provider_error', async ({ page }) => { - await page.goto( - `${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=provider_error`, - ); - - await expect(page.locator('.oidc-callback__error')).toHaveText( - 'The identity provider returned an error', - ); - }); - - test('callback page shows error for exchange_failed', async ({ page }) => { - await page.goto( - `${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=exchange_failed`, - ); - - await expect(page.locator('.oidc-callback__error')).toHaveText( - 'Failed to complete sign in', - ); - }); - - test('callback page shows fallback error for unknown code', async ({ - page, - }) => { - await page.goto( - `${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=xyz`, - ); - - await expect(page.locator('.oidc-callback__error')).toHaveText( - 'An unknown error occurred', - ); - }); - - test('login page shows error from oidc_error query param', async ({ - page, - }) => { - await page.goto( - `${TEST_CONFIG.appUrl}/auth/login?oidc_error=unauthorized`, - ); - - await expect(page.locator('.error-message')).toHaveText( - 'Your account is not authorized for OIDC login', - ); - }); -}); diff --git a/e2e/tests/oidc-exclusive-mode.spec.ts b/e2e/tests/oidc-exclusive-mode.spec.ts deleted file mode 100644 index 0e4b89e1..00000000 --- a/e2e/tests/oidc-exclusive-mode.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, - updateOidcConfig, -} from './helpers/app-api'; -import { linkOidcViaBrowser } from './helpers/oidc'; - -const API = TEST_CONFIG.appUrl; - -test.describe.serial('OIDC Exclusive Mode', () => { - // Token obtained BEFORE enabling exclusive mode (password login will be blocked) - let adminToken: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async ({ browser }) => { - adminToken = await loginAndGetToken(); - snapshot = await getOidcConfig(adminToken); - await configureOidc(adminToken); - await clearOidcLink(adminToken); - - const setupPage = await browser.newPage(); - try { - await linkOidcViaBrowser(setupPage); - } finally { - await setupPage.close(); - } - - await updateOidcConfig(adminToken, { exclusiveMode: true }); - }); - - test.afterAll(async () => { - try { - await updateOidcConfig(adminToken, { exclusiveMode: false }); - } catch { - // best effort — snapshot restore below will fix it anyway - } - await clearOidcLink(adminToken); - await setOidcConfig(adminToken, snapshot); - }); - - test('login page shows only OIDC button when exclusive mode is active', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - const oidcButton = page.locator('.oidc-login-btn'); - await expect(oidcButton).toBeVisible({ timeout: 10_000 }); - await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); - - const loginForm = page.locator('.login-form'); - await expect(loginForm).not.toBeVisible(); - - const divider = page.locator('.divider'); - await expect(divider).not.toBeVisible(); - - const plexButton = page.locator('.plex-login-btn'); - await expect(plexButton).not.toBeVisible(); - }); - - test('OIDC login still works in exclusive mode', async ({ page }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - await page.locator('.oidc-login-btn').click(); - - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - - await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); - await page.locator('#username').fill(TEST_CONFIG.oidcUsername); - await page.locator('#password').fill(TEST_CONFIG.oidcPassword); - await page.locator('#kc-login').click(); - - await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - - await expect(page.locator('body')).not.toContainText('Sign In', { - timeout: 5_000, - }); - }); - - test('password login API returns 403 in exclusive mode', async () => { - const res = await fetch(`${API}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - username: TEST_CONFIG.adminUsername, - password: TEST_CONFIG.adminPassword, - }), - }); - - expect(res.status).toBe(403); - }); - - test('auth status API reflects exclusive mode', async () => { - const res = await fetch(`${API}/api/auth/status`); - expect(res.ok).toBe(true); - - const data = await res.json(); - expect(data.oidcExclusiveMode).toBe(true); - }); - - test('settings page shows warning notices and disabled controls', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - await page.locator('.oidc-login-btn').click(); - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); - await page.locator('#username').fill(TEST_CONFIG.oidcUsername); - await page.locator('#password').fill(TEST_CONFIG.oidcPassword); - await page.locator('#kc-login').click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - - await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); - - await expect( - page.getByText('Password login is disabled while OIDC exclusive mode is active.'), - ).toBeVisible({ timeout: 5_000 }); - await expect( - page.getByText('Plex login is disabled while OIDC exclusive mode is active.'), - ).toBeVisible({ timeout: 5_000 }); - - await page.getByText('OIDC / SSO').click(); - const exclusiveToggle = page.getByText('Exclusive Mode', { exact: true }); - await expect(exclusiveToggle).toBeVisible({ timeout: 5_000 }); - }); - - test('disabling exclusive mode restores credential form on login page', async ({ - page, - }) => { - await updateOidcConfig(adminToken, { exclusiveMode: false }); - - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - const loginForm = page.locator('.login-form'); - await expect(loginForm).toBeVisible({ timeout: 10_000 }); - - const oidcButton = page.locator('.oidc-login-btn'); - await expect(oidcButton).toBeVisible(); - - const divider = page.locator('.divider'); - await expect(divider).toBeVisible(); - }); - - test('password login works again after disabling exclusive mode', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - await page - .getByRole('textbox', { name: 'Username' }) - .fill(TEST_CONFIG.adminUsername); - await page - .getByRole('textbox', { name: 'Password' }) - .fill(TEST_CONFIG.adminPassword); - await page - .getByRole('button', { name: 'Sign In', exact: true }) - .click(); - - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); - }); -}); diff --git a/e2e/tests/oidc-link.spec.ts b/e2e/tests/oidc-link.spec.ts deleted file mode 100644 index 88c8c560..00000000 --- a/e2e/tests/oidc-link.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; - -test.describe('OIDC Account Linking', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async () => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - }); - - test.afterAll(async () => { - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); - - test('authenticated user can link OIDC account via settings', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - await page - .getByRole('textbox', { name: 'Username' }) - .fill(TEST_CONFIG.adminUsername); - await page - .getByRole('textbox', { name: 'Password' }) - .fill(TEST_CONFIG.adminPassword); - await page - .getByRole('button', { name: 'Sign In', exact: true }) - .click(); - - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); - - await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); - await expect(page).toHaveURL(/\/settings\/account/); - - await page.getByText('OIDC / SSO').click(); - - const linkButton = page.getByRole('button', { name: /link account|re-link/i }); - await expect(linkButton).toBeVisible({ timeout: 5_000 }); - await linkButton.click(); - - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - - await page.locator('#username').fill(TEST_CONFIG.oidcUsername); - await page.locator('#password').fill(TEST_CONFIG.oidcPassword); - await page.locator('#kc-login').click(); - - await expect(page).toHaveURL(/settings\/account\?oidc_link=success/, { - timeout: 15_000, - }); - }); -}); diff --git a/e2e/tests/oidc-login-persists.spec.ts b/e2e/tests/oidc-login-persists.spec.ts deleted file mode 100644 index d43a01f9..00000000 --- a/e2e/tests/oidc-login-persists.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; -import { linkOidcViaBrowser } from './helpers/oidc'; - -test.describe('OIDC Login Persistence', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async ({ browser }) => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - - const setupPage = await browser.newPage(); - try { - await linkOidcViaBrowser(setupPage); - } finally { - await setupPage.close(); - } - }); - - test.afterAll(async () => { - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); - - test('OIDC login still works after configuration changes', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - const oidcButton = page.getByRole('button', { name: /sign in with/i }); - await expect(oidcButton).toBeVisible({ timeout: 10_000 }); - await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); - - await oidcButton.click(); - - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - - await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); - await page.locator('#username').fill(TEST_CONFIG.oidcUsername); - await page.locator('#password').fill(TEST_CONFIG.oidcPassword); - await page.locator('#kc-login').click(); - - await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - - await expect(page.locator('body')).not.toContainText('Sign In', { - timeout: 5_000, - }); - }); -}); diff --git a/e2e/tests/oidc-login.spec.ts b/e2e/tests/oidc-login.spec.ts deleted file mode 100644 index 7634a18a..00000000 --- a/e2e/tests/oidc-login.spec.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; -import { linkOidcViaBrowser } from './helpers/oidc'; - -test.describe('OIDC Login', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async ({ browser }) => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - - const setupPage = await browser.newPage(); - try { - await linkOidcViaBrowser(setupPage); - } finally { - await setupPage.close(); - } - }); - - test.afterAll(async () => { - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); - - test('OIDC login button is visible after account linking', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - const oidcButton = page.getByRole('button', { name: /sign in with/i }); - await expect(oidcButton).toBeVisible({ timeout: 10_000 }); - await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); - }); - - test('full OIDC login flow authenticates and redirects to dashboard', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - await page.getByRole('button', { name: /sign in with/i }).click(); - - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - - await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); - await page.locator('#username').fill(TEST_CONFIG.oidcUsername); - await page.locator('#password').fill(TEST_CONFIG.oidcPassword); - await page.locator('#kc-login').click(); - - await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - - await expect(page.locator('body')).not.toContainText('Sign In', { - timeout: 5_000, - }); - }); -}); diff --git a/e2e/tests/oidc-settings-ui.spec.ts b/e2e/tests/oidc-settings-ui.spec.ts deleted file mode 100644 index a6bf9d85..00000000 --- a/e2e/tests/oidc-settings-ui.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; -import { linkOidcViaBrowser } from './helpers/oidc'; -import { getSubjectForUser } from './helpers/keycloak'; - -test.describe('OIDC Settings UI', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async ({ browser }) => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - - const setupPage = await browser.newPage(); - try { - await linkOidcViaBrowser(setupPage); - } finally { - await setupPage.close(); - } - }); - - test.afterAll(async () => { - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); - - async function loginAndGoToSettings(page: import('@playwright/test').Page) { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - await page - .getByRole('textbox', { name: 'Username' }) - .fill(TEST_CONFIG.adminUsername); - await page - .getByRole('textbox', { name: 'Password' }) - .fill(TEST_CONFIG.adminPassword); - await page - .getByRole('button', { name: 'Sign In', exact: true }) - .click(); - await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); - } - - test('settings page shows linked OIDC subject', async ({ page }) => { - await loginAndGoToSettings(page); - await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); - - await page.getByText('OIDC / SSO').click(); - - const subjectEl = page.locator('.oidc-link-section__subject'); - await expect(subjectEl).toBeVisible({ timeout: 5_000 }); - - const expectedSubject = await getSubjectForUser(TEST_CONFIG.oidcUsername); - await expect(subjectEl).toHaveText(expectedSubject); - }); - - test('settings page shows Re-link button when account is linked', async ({ - page, - }) => { - await loginAndGoToSettings(page); - await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); - - await page.getByText('OIDC / SSO').click(); - - const relinkButton = page.getByRole('button', { name: 'Re-link' }); - await expect(relinkButton).toBeVisible({ timeout: 5_000 }); - }); - - test('oidc_link=success query param shows success toast and expands accordion', async ({ - page, - }) => { - await loginAndGoToSettings(page); - await page.goto( - `${TEST_CONFIG.appUrl}/settings/account?oidc_link=success`, - ); - - await expect(page.getByText('OIDC account linked successfully')).toBeVisible({ - timeout: 5_000, - }); - - const subjectEl = page.locator('.oidc-link-section__subject'); - await expect(subjectEl).toBeVisible({ timeout: 5_000 }); - }); - - test('oidc_link_error query param shows error toast and expands accordion', async ({ - page, - }) => { - await loginAndGoToSettings(page); - await page.goto( - `${TEST_CONFIG.appUrl}/settings/account?oidc_link_error=failed`, - ); - - await expect(page.getByText('Failed to link OIDC account')).toBeVisible({ - timeout: 5_000, - }); - - const subjectEl = page.locator('.oidc-link-section__subject'); - await expect(subjectEl).toBeVisible({ timeout: 5_000 }); - }); -}); diff --git a/e2e/tests/oidc-subject-mismatch.spec.ts b/e2e/tests/oidc-subject-mismatch.spec.ts deleted file mode 100644 index e1a1ff3e..00000000 --- a/e2e/tests/oidc-subject-mismatch.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; -import { linkOidcViaBrowser } from './helpers/oidc'; -import { - createKeycloakUser, - deleteKeycloakUser, -} from './helpers/keycloak'; - -const WRONG_USER = 'wronguser'; -const WRONG_PASS = 'wrongpass'; -const WRONG_EMAIL = 'wronguser@example.com'; - -test.describe('OIDC Subject Mismatch', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async ({ browser }) => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - - const setupPage = await browser.newPage(); - try { - await linkOidcViaBrowser(setupPage); - } finally { - await setupPage.close(); - } - - await createKeycloakUser(WRONG_USER, WRONG_PASS, WRONG_EMAIL); - }); - - test.afterAll(async () => { - await deleteKeycloakUser(WRONG_USER); - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); - - test('OIDC login with wrong Keycloak user shows unauthorized error', async ({ - page, - }) => { - await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - - await page.getByRole('button', { name: /sign in with/i }).click(); - - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); - - await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); - await page.locator('#username').fill(WRONG_USER); - await page.locator('#password').fill(WRONG_PASS); - await page.locator('#kc-login').click(); - - await expect(page).toHaveURL(/oidc_error=unauthorized/, { - timeout: 15_000, - }); - - await expect(page.locator('.error-message')).toHaveText( - 'Your account is not authorized for OIDC login', - ); - }); -}); diff --git a/e2e/tests/oidc-login-unlinked.spec.ts b/e2e/tests/oidc/01-login-without-link.ui.spec.ts similarity index 59% rename from e2e/tests/oidc-login-unlinked.spec.ts rename to e2e/tests/oidc/01-login-without-link.ui.spec.ts index cbfd365e..34c7f8e5 100644 --- a/e2e/tests/oidc-login-unlinked.spec.ts +++ b/e2e/tests/oidc/01-login-without-link.ui.spec.ts @@ -1,31 +1,9 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; - -test.describe('OIDC Login Without Linked Subject', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async () => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - }); - - test.afterAll(async () => { - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +test.describe.serial('OIDC — login without linked subject', () => { test('OIDC button is visible without a linked subject', async ({ page }) => { + // After global setup, OIDC is configured but no account is linked. + // The button still appears because the IdP controls access. await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); const oidcButton = page.getByRole('button', { name: /sign in with/i }); @@ -35,9 +13,7 @@ test.describe('OIDC Login Without Linked Subject', () => { test('OIDC login works without a linked subject', async ({ page }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - await page.getByRole('button', { name: /sign in with/i }).click(); - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); @@ -46,9 +22,6 @@ test.describe('OIDC Login Without Linked Subject', () => { await page.locator('#kc-login').click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - - await expect(page.locator('body')).not.toContainText('Sign In', { - timeout: 5_000, - }); + await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000 }); }); }); diff --git a/e2e/tests/oidc/02-link-account.ui.spec.ts b/e2e/tests/oidc/02-link-account.ui.spec.ts new file mode 100644 index 00000000..9935d09c --- /dev/null +++ b/e2e/tests/oidc/02-link-account.ui.spec.ts @@ -0,0 +1,28 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; + +test.describe.serial('OIDC — account linking via settings', () => { + test('authenticated user can link OIDC account', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + await page.getByRole('textbox', { name: 'Username' }).fill(TEST_CONFIG.adminUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(TEST_CONFIG.adminPassword); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); + + await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); + await expect(page).toHaveURL(/\/settings\/account/); + + await page.getByText('OIDC / SSO').click(); + + const linkButton = page.getByRole('button', { name: /link account|re-link/i }); + await expect(linkButton).toBeVisible({ timeout: 5_000 }); + await linkButton.click(); + + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + + await page.locator('#username').fill(TEST_CONFIG.oidcUsername); + await page.locator('#password').fill(TEST_CONFIG.oidcPassword); + await page.locator('#kc-login').click(); + + await expect(page).toHaveURL(/settings\/account\?oidc_link=success/, { timeout: 15_000 }); + }); +}); diff --git a/e2e/tests/oidc/03-login-after-link.ui.spec.ts b/e2e/tests/oidc/03-login-after-link.ui.spec.ts new file mode 100644 index 00000000..68200186 --- /dev/null +++ b/e2e/tests/oidc/03-login-after-link.ui.spec.ts @@ -0,0 +1,25 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; + +test.describe.serial('OIDC — login after account linking', () => { + test('OIDC login button is visible after linking', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + + const oidcButton = page.getByRole('button', { name: /sign in with/i }); + await expect(oidcButton).toBeVisible({ timeout: 10_000 }); + await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); + }); + + test('full OIDC login flow authenticates and redirects to dashboard', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + await page.getByRole('button', { name: /sign in with/i }).click(); + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + + await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); + await page.locator('#username').fill(TEST_CONFIG.oidcUsername); + await page.locator('#password').fill(TEST_CONFIG.oidcPassword); + await page.locator('#kc-login').click(); + + await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); + await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000 }); + }); +}); diff --git a/e2e/tests/oidc/04-error-display.ui.spec.ts b/e2e/tests/oidc/04-error-display.ui.spec.ts new file mode 100644 index 00000000..0fe5faef --- /dev/null +++ b/e2e/tests/oidc/04-error-display.ui.spec.ts @@ -0,0 +1,44 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; + +test.describe.serial('OIDC — error display', () => { + test('callback page shows error for missing code and redirects to login', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/oidc/callback`); + + await expect(page.locator('.oidc-callback__error')).toHaveText( + 'Invalid callback - missing authorization code', + ); + await expect(page.locator('.oidc-callback__redirect')).toHaveText('Redirecting to login...'); + await expect(page).toHaveURL(/\/auth\/login/, { timeout: 5_000 }); + }); + + test('callback page shows error for unauthorized', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=unauthorized`); + await expect(page.locator('.oidc-callback__error')).toHaveText( + 'Your account is not authorized for OIDC login', + ); + }); + + test('callback page shows error for provider_error', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=provider_error`); + await expect(page.locator('.oidc-callback__error')).toHaveText( + 'The identity provider returned an error', + ); + }); + + test('callback page shows error for exchange_failed', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=exchange_failed`); + await expect(page.locator('.oidc-callback__error')).toHaveText('Failed to complete sign in'); + }); + + test('callback page shows fallback error for unknown code', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/oidc/callback?oidc_error=xyz`); + await expect(page.locator('.oidc-callback__error')).toHaveText('An unknown error occurred'); + }); + + test('login page shows error from oidc_error query param', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login?oidc_error=unauthorized`); + await expect(page.locator('.error-message')).toHaveText( + 'Your account is not authorized for OIDC login', + ); + }); +}); diff --git a/e2e/tests/oidc/05-subject-mismatch.ui.spec.ts b/e2e/tests/oidc/05-subject-mismatch.ui.spec.ts new file mode 100644 index 00000000..131d3d34 --- /dev/null +++ b/e2e/tests/oidc/05-subject-mismatch.ui.spec.ts @@ -0,0 +1,32 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import { createKeycloakUser, deleteKeycloakUser } from '../helpers/keycloak'; + +const WRONG_USER = 'wronguser'; +const WRONG_PASS = 'wrongpass'; +const WRONG_EMAIL = 'wronguser@example.com'; + +test.describe.serial('OIDC — subject mismatch', () => { + test.beforeAll(async () => { + await createKeycloakUser(WRONG_USER, WRONG_PASS, WRONG_EMAIL); + }); + + test.afterAll(async () => { + await deleteKeycloakUser(WRONG_USER); + }); + + test('OIDC login with wrong Keycloak user shows unauthorized error', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + await page.getByRole('button', { name: /sign in with/i }).click(); + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + + await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); + await page.locator('#username').fill(WRONG_USER); + await page.locator('#password').fill(WRONG_PASS); + await page.locator('#kc-login').click(); + + await expect(page).toHaveURL(/oidc_error=unauthorized/, { timeout: 15_000 }); + await expect(page.locator('.error-message')).toHaveText( + 'Your account is not authorized for OIDC login', + ); + }); +}); diff --git a/e2e/tests/oidc-config-changes.spec.ts b/e2e/tests/oidc/06-config-changes.ui.spec.ts similarity index 54% rename from e2e/tests/oidc-config-changes.spec.ts rename to e2e/tests/oidc/06-config-changes.ui.spec.ts index fca98c44..a8f5bd70 100644 --- a/e2e/tests/oidc-config-changes.spec.ts +++ b/e2e/tests/oidc/06-config-changes.ui.spec.ts @@ -1,33 +1,19 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, - updateOidcConfig, -} from './helpers/app-api'; +import { test, expect, TEST_CONFIG, CleanuparrApi } from '../fixtures/base'; -test.describe.serial('OIDC Configuration Changes', () => { - let token: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async () => { - token = await loginAndGetToken(); - snapshot = await getOidcConfig(token); - await configureOidc(token); - await clearOidcLink(token); - }); - - test.afterAll(async () => { - await clearOidcLink(token); - await setOidcConfig(token, snapshot); - }); +async function adminApi(): Promise { + const api = new CleanuparrApi(); + const tokens = await api.auth.loginAndCaptureTokens( + TEST_CONFIG.adminUsername, + TEST_CONFIG.adminPassword, + ); + api.setToken(tokens.accessToken); + return api; +} +test.describe.serial('OIDC — configuration changes', () => { test('disabling OIDC hides the login button', async ({ page }) => { - await updateOidcConfig(token, { enabled: false }); + const api = await adminApi(); + await api.account.patchOidcConfig({ enabled: false }); await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); @@ -38,13 +24,9 @@ test.describe.serial('OIDC Configuration Changes', () => { await expect(divider).not.toBeVisible(); }); - test('changing provider name updates the login button text', async ({ - page, - }) => { - await updateOidcConfig(token, { - enabled: true, - providerName: 'MyCustomIdP', - }); + test('changing provider name updates the login button text', async ({ page }) => { + const api = await adminApi(); + await api.account.patchOidcConfig({ enabled: true, providerName: 'MyCustomIdP' }); await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); @@ -53,10 +35,9 @@ test.describe.serial('OIDC Configuration Changes', () => { await expect(oidcButton).toContainText('MyCustomIdP'); }); - test('re-enabling with original provider name restores the button', async ({ - page, - }) => { - await updateOidcConfig(token, { + test('re-enabling with original provider name restores the button', async ({ page }) => { + const api = await adminApi(); + await api.account.patchOidcConfig({ enabled: true, providerName: TEST_CONFIG.oidcProviderName, }); diff --git a/e2e/tests/oidc/07-settings-ui.ui.spec.ts b/e2e/tests/oidc/07-settings-ui.ui.spec.ts new file mode 100644 index 00000000..778cb245 --- /dev/null +++ b/e2e/tests/oidc/07-settings-ui.ui.spec.ts @@ -0,0 +1,49 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import { getSubjectForUser } from '../helpers/keycloak'; + +test.describe.serial('OIDC — settings UI', () => { + async function loginAsAdmin(page: import('@playwright/test').Page): Promise { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + await page.getByRole('textbox', { name: 'Username' }).fill(TEST_CONFIG.adminUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(TEST_CONFIG.adminPassword); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); + } + + test('settings page shows linked OIDC subject', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); + await page.getByText('OIDC / SSO').click(); + + const subjectEl = page.locator('.oidc-link-section__subject'); + await expect(subjectEl).toBeVisible({ timeout: 5_000 }); + + const expectedSubject = await getSubjectForUser(TEST_CONFIG.oidcUsername); + await expect(subjectEl).toHaveText(expectedSubject); + }); + + test('settings page shows Re-link button when account is linked', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); + await page.getByText('OIDC / SSO').click(); + + const relinkButton = page.getByRole('button', { name: 'Re-link' }); + await expect(relinkButton).toBeVisible({ timeout: 5_000 }); + }); + + test('oidc_link=success query param shows toast and expands accordion', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${TEST_CONFIG.appUrl}/settings/account?oidc_link=success`); + + await expect(page.getByText('OIDC account linked successfully')).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('.oidc-link-section__subject')).toBeVisible({ timeout: 5_000 }); + }); + + test('oidc_link_error query param shows toast and expands accordion', async ({ page }) => { + await loginAsAdmin(page); + await page.goto(`${TEST_CONFIG.appUrl}/settings/account?oidc_link_error=failed`); + + await expect(page.getByText('Failed to link OIDC account')).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('.oidc-link-section__subject')).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/e2e/tests/oidc/08-login-persists.ui.spec.ts b/e2e/tests/oidc/08-login-persists.ui.spec.ts new file mode 100644 index 00000000..b6624749 --- /dev/null +++ b/e2e/tests/oidc/08-login-persists.ui.spec.ts @@ -0,0 +1,22 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; + +test.describe.serial('OIDC — login persistence after config changes', () => { + test('OIDC login still works after configuration changes', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + + const oidcButton = page.getByRole('button', { name: /sign in with/i }); + await expect(oidcButton).toBeVisible({ timeout: 10_000 }); + await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); + + await oidcButton.click(); + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + + await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); + await page.locator('#username').fill(TEST_CONFIG.oidcUsername); + await page.locator('#password').fill(TEST_CONFIG.oidcPassword); + await page.locator('#kc-login').click(); + + await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); + await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000 }); + }); +}); diff --git a/e2e/tests/oidc/09-exclusive-mode.ui.spec.ts b/e2e/tests/oidc/09-exclusive-mode.ui.spec.ts new file mode 100644 index 00000000..ab8762e0 --- /dev/null +++ b/e2e/tests/oidc/09-exclusive-mode.ui.spec.ts @@ -0,0 +1,111 @@ +import { test, expect, TEST_CONFIG, CleanuparrApi } from '../fixtures/base'; + +// Token captured BEFORE exclusive mode is enabled — re-logging in afterwards +// is blocked (the whole point of exclusive mode), so all admin-API actions +// inside the describe reuse this access token. +let adminToken: string; + +function adminApi(): CleanuparrApi { + return new CleanuparrApi({ token: adminToken }); +} + +test.describe.serial('OIDC — exclusive mode', () => { + test.beforeAll(async () => { + const bootstrap = new CleanuparrApi(); + const tokens = await bootstrap.auth.loginAndCaptureTokens( + TEST_CONFIG.adminUsername, + TEST_CONFIG.adminPassword, + ); + adminToken = tokens.accessToken; + + await adminApi().account.patchOidcConfig({ exclusiveMode: true }); + }); + + test.afterAll(async () => { + try { + await adminApi().account.patchOidcConfig({ exclusiveMode: false }); + } catch { + // best-effort cleanup + } + }); + + test('login page shows only OIDC button when exclusive mode is active', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + + const oidcButton = page.locator('.oidc-login-btn'); + await expect(oidcButton).toBeVisible({ timeout: 10_000 }); + await expect(oidcButton).toContainText(TEST_CONFIG.oidcProviderName); + + await expect(page.locator('.login-form')).not.toBeVisible(); + await expect(page.locator('.divider')).not.toBeVisible(); + await expect(page.locator('.plex-login-btn')).not.toBeVisible(); + }); + + test('OIDC login still works in exclusive mode', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + await page.locator('.oidc-login-btn').click(); + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + + await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); + await page.locator('#username').fill(TEST_CONFIG.oidcUsername); + await page.locator('#password').fill(TEST_CONFIG.oidcPassword); + await page.locator('#kc-login').click(); + + await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); + await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000 }); + }); + + test('password login API returns 403 in exclusive mode', async ({ anonymousApi }) => { + const res = await anonymousApi.auth.login(TEST_CONFIG.adminUsername, TEST_CONFIG.adminPassword); + expect(res.status).toBe(403); + }); + + test('auth status API reflects exclusive mode', async ({ anonymousApi }) => { + const res = await anonymousApi.auth.status(); + expect(res.ok).toBe(true); + const data = await res.json(); + expect(data.oidcExclusiveMode).toBe(true); + }); + + test('settings page shows warning notices and disabled controls', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + await page.locator('.oidc-login-btn').click(); + await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); + await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); + await page.locator('#username').fill(TEST_CONFIG.oidcUsername); + await page.locator('#password').fill(TEST_CONFIG.oidcPassword); + await page.locator('#kc-login').click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); + + await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); + + await expect( + page.getByText('Password login is disabled while OIDC exclusive mode is active.'), + ).toBeVisible({ timeout: 5_000 }); + await expect( + page.getByText('Plex login is disabled while OIDC exclusive mode is active.'), + ).toBeVisible({ timeout: 5_000 }); + + await page.getByText('OIDC / SSO').click(); + await expect(page.getByText('Exclusive Mode', { exact: true })).toBeVisible({ timeout: 5_000 }); + }); + + test('disabling exclusive mode restores credential form on login page', async ({ page }) => { + await adminApi().account.patchOidcConfig({ exclusiveMode: false }); + + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + + await expect(page.locator('.login-form')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.oidc-login-btn')).toBeVisible(); + await expect(page.locator('.divider')).toBeVisible(); + }); + + test('password login works again after disabling exclusive mode', async ({ page }) => { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + + await page.getByRole('textbox', { name: 'Username' }).fill(TEST_CONFIG.adminUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(TEST_CONFIG.adminPassword); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); + await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); + }); +}); diff --git a/e2e/tests/oidc-unlink-allows-any-user.spec.ts b/e2e/tests/oidc/10-unlink-allows-any-user.ui.spec.ts similarity index 65% rename from e2e/tests/oidc-unlink-allows-any-user.spec.ts rename to e2e/tests/oidc/10-unlink-allows-any-user.ui.spec.ts index a172f61e..39bee8fa 100644 --- a/e2e/tests/oidc-unlink-allows-any-user.spec.ts +++ b/e2e/tests/oidc/10-unlink-allows-any-user.ui.spec.ts @@ -1,60 +1,24 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, -} from './helpers/app-api'; -import { linkOidcViaBrowser } from './helpers/oidc'; -import { - createKeycloakUser, - deleteKeycloakUser, -} from './helpers/keycloak'; +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import { createKeycloakUser, deleteKeycloakUser } from '../helpers/keycloak'; const ANOTHER_USER = 'anotheruser'; const ANOTHER_PASS = 'anotherpass'; const ANOTHER_EMAIL = 'anotheruser@example.com'; -test.describe.serial('OIDC Unlink Allows Any User', () => { - let adminToken: string; - let snapshot: OidcConfigSnapshot; - - test.beforeAll(async ({ browser }) => { - adminToken = await loginAndGetToken(); - snapshot = await getOidcConfig(adminToken); - await configureOidc(adminToken); - await clearOidcLink(adminToken); - - const setupPage = await browser.newPage(); - try { - await linkOidcViaBrowser(setupPage); - } finally { - await setupPage.close(); - } - +test.describe.serial('OIDC — unlink allows any user', () => { + test.beforeAll(async () => { await createKeycloakUser(ANOTHER_USER, ANOTHER_PASS, ANOTHER_EMAIL); }); test.afterAll(async () => { await deleteKeycloakUser(ANOTHER_USER); - await clearOidcLink(adminToken); - await setOidcConfig(adminToken, snapshot); }); test('unlinking OIDC subject via UI succeeds', async ({ page }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - await page - .getByRole('textbox', { name: 'Username' }) - .fill(TEST_CONFIG.adminUsername); - await page - .getByRole('textbox', { name: 'Password' }) - .fill(TEST_CONFIG.adminPassword); - await page - .getByRole('button', { name: 'Sign In', exact: true }) - .click(); + await page.getByRole('textbox', { name: 'Username' }).fill(TEST_CONFIG.adminUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(TEST_CONFIG.adminPassword); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); @@ -71,10 +35,7 @@ test.describe.serial('OIDC Unlink Allows Any User', () => { await expect(confirmButton).toBeVisible({ timeout: 5_000 }); await confirmButton.click(); - await expect(page.getByText('OIDC account unlinked')).toBeVisible({ - timeout: 5_000, - }); - + await expect(page.getByText('OIDC account unlinked')).toBeVisible({ timeout: 5_000 }); await expect(subjectEl).not.toBeVisible({ timeout: 5_000 }); const linkButton = page.getByRole('button', { name: 'Link Account' }); @@ -83,9 +44,7 @@ test.describe.serial('OIDC Unlink Allows Any User', () => { test('OIDC login still works after unlinking', async ({ page }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - await page.getByRole('button', { name: /sign in with/i }).click(); - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); @@ -96,13 +55,9 @@ test.describe.serial('OIDC Unlink Allows Any User', () => { await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); }); - test('a different Keycloak user can also log in after unlinking', async ({ - page, - }) => { + test('a different Keycloak user can also log in after unlinking', async ({ page }) => { await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); - await page.getByRole('button', { name: /sign in with/i }).click(); - await expect(page).toHaveURL(/localhost:8080/, { timeout: 10_000 }); await page.locator('#username').waitFor({ state: 'visible', timeout: 5_000 }); @@ -111,9 +66,6 @@ test.describe.serial('OIDC Unlink Allows Any User', () => { await page.locator('#kc-login').click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 }); - - await expect(page.locator('body')).not.toContainText('Sign In', { - timeout: 5_000, - }); + await expect(page.locator('body')).not.toContainText('Sign In', { timeout: 5_000 }); }); }); diff --git a/e2e/tests/oidc-save-without-link-warning.spec.ts b/e2e/tests/oidc/11-save-without-link-warning.ui.spec.ts similarity index 57% rename from e2e/tests/oidc-save-without-link-warning.spec.ts rename to e2e/tests/oidc/11-save-without-link-warning.ui.spec.ts index 3668e2c3..3d7ee7e5 100644 --- a/e2e/tests/oidc-save-without-link-warning.spec.ts +++ b/e2e/tests/oidc/11-save-without-link-warning.ui.spec.ts @@ -1,61 +1,53 @@ -import { test, expect, Page } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; -import { - clearOidcLink, - configureOidc, - getOidcConfig, - loginAndGetToken, - OidcConfigSnapshot, - setOidcConfig, - updateOidcConfig, -} from './helpers/app-api'; +import { test, expect, TEST_CONFIG, CleanuparrApi } from '../fixtures/base'; +import type { Page } from '@playwright/test'; -const API = TEST_CONFIG.appUrl; +async function adminApi(): Promise { + const api = new CleanuparrApi(); + const tokens = await api.auth.loginAndCaptureTokens( + TEST_CONFIG.adminUsername, + TEST_CONFIG.adminPassword, + ); + api.setToken(tokens.accessToken); + return api; +} -// UX hardening for the OIDC "no linked subject" trust mode -// The unlinked mode is intentional - -test.describe.serial('OIDC Save without Link Warning', () => { - let adminToken: string; - let snapshot: OidcConfigSnapshot; +// UX hardening for the OIDC "no linked subject" trust mode. +// The unlinked mode is intentional but should require a confirmation. +test.describe.serial('OIDC — save without link warning', () => { test.beforeAll(async () => { - adminToken = await loginAndGetToken(); - snapshot = await getOidcConfig(adminToken); - await configureOidc(adminToken); - await clearOidcLink(adminToken); + const api = await adminApi(); + await api.account.patchOidcConfig({ + enabled: true, + providerName: TEST_CONFIG.oidcProviderName, + issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, + clientId: TEST_CONFIG.clientId, + clientSecret: TEST_CONFIG.clientSecret, + scopes: 'openid profile email', + redirectUrl: '', + exclusiveMode: false, + }); + const clear = await api.account.unlinkOidc(); + if (!clear.ok) { + throw new Error(`Failed to clear OIDC link: ${clear.status} ${await clear.text()}`); + } }); - test.afterAll(async () => { - await clearOidcLink(adminToken); - await setOidcConfig(adminToken, snapshot); - }); - - async function loginUI(page: Page) { - await page.goto(`${API}/auth/login`); - await page - .getByRole('textbox', { name: 'Username' }) - .fill(TEST_CONFIG.adminUsername); - await page - .getByRole('textbox', { name: 'Password' }) - .fill(TEST_CONFIG.adminPassword); - await page - .getByRole('button', { name: 'Sign In', exact: true }) - .click(); + async function loginUI(page: Page): Promise { + await page.goto(`${TEST_CONFIG.appUrl}/auth/login`); + await page.getByRole('textbox', { name: 'Username' }).fill(TEST_CONFIG.adminUsername); + await page.getByRole('textbox', { name: 'Password' }).fill(TEST_CONFIG.adminPassword); + await page.getByRole('button', { name: 'Sign In', exact: true }).click(); await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 }); } - async function openOidcSettings(page: Page) { - await page.goto(`${API}/settings/account`); + async function openOidcSettings(page: Page): Promise { + await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); await page.getByText('OIDC / SSO').click(); - await expect(page.getByRole('button', { name: 'Save OIDC Settings' })).toBeVisible({ - timeout: 5_000, - }); + await expect(page.getByRole('button', { name: 'Save OIDC Settings' })).toBeVisible({ timeout: 5_000 }); } - test('Saving with Enabled=true and no linked subject shows the warning dialog', async ({ - page, - }) => { + test('Saving with Enabled=true and no linked subject shows the warning dialog', async ({ page }) => { await loginUI(page); await openOidcSettings(page); @@ -67,9 +59,7 @@ test.describe.serial('OIDC Save without Link Warning', () => { await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toContainText('Enable OIDC without a linked account'); await expect(dialog).toContainText('UNSAFE'); - await expect( - dialog.getByRole('button', { name: 'Enable anyway' }), - ).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Enable anyway' })).toBeVisible(); await dialog.getByRole('button', { name: 'Cancel' }).click(); await expect(dialog).not.toBeVisible({ timeout: 5_000 }); @@ -93,7 +83,6 @@ test.describe.serial('OIDC Save without Link Warning', () => { await expect(dialog).not.toBeVisible({ timeout: 5_000 }); expect(putRequested).toBe(false); - await expect(page.getByText('OIDC settings saved')).not.toBeVisible(); }); @@ -107,9 +96,7 @@ test.describe.serial('OIDC Save without Link Warning', () => { await dialog.getByRole('button', { name: 'Enable anyway' }).click(); await expect(dialog).not.toBeVisible({ timeout: 5_000 }); - await expect(page.getByText('OIDC settings saved')).toBeVisible({ - timeout: 5_000, - }); + await expect(page.getByText('OIDC settings saved')).toBeVisible({ timeout: 5_000 }); }); test('Saving with Enabled=false does not show the warning', async ({ page }) => { @@ -120,20 +107,28 @@ test.describe.serial('OIDC Save without Link Warning', () => { await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); - await expect(page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' })).not.toBeVisible({ timeout: 1_000 }); + await expect( + page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' }), + ).not.toBeVisible({ timeout: 1_000 }); - await expect(page.getByText('OIDC settings saved')).toBeVisible({ - timeout: 5_000, + await expect(page.getByText('OIDC settings saved')).toBeVisible({ timeout: 5_000 }); + + const api = await adminApi(); + await api.account.patchOidcConfig({ + enabled: true, + providerName: TEST_CONFIG.oidcProviderName, + issuerUrl: `${TEST_CONFIG.keycloakUrl}/realms/${TEST_CONFIG.realm}`, + clientId: TEST_CONFIG.clientId, + clientSecret: TEST_CONFIG.clientSecret, + scopes: 'openid profile email', + redirectUrl: '', + exclusiveMode: false, }); - - await updateOidcConfig(adminToken, { enabled: true }); }); - test('Saving with a linked subject does not show the warning', async ({ - page, - }) => { + test('Saving with a linked subject does not show the warning', async ({ page }) => { await loginUI(page); - await page.goto(`${API}/settings/account`); + await page.goto(`${TEST_CONFIG.appUrl}/settings/account`); await page.getByText('OIDC / SSO').click(); const linkButton = page.getByRole('button', { name: 'Link Account' }); @@ -147,14 +142,12 @@ test.describe.serial('OIDC Save without Link Warning', () => { await page.locator('#kc-login').click(); await expect(page).toHaveURL(/\/settings\/account/, { timeout: 15_000 }); - await expect(page.locator('.oidc-link-section__subject')).toBeVisible({ - timeout: 5_000, - }); + await expect(page.locator('.oidc-link-section__subject')).toBeVisible({ timeout: 5_000 }); await page.getByRole('button', { name: 'Save OIDC Settings' }).click(); - await expect(page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' })).not.toBeVisible({ timeout: 1_000 }); - await expect(page.getByText('OIDC settings saved')).toBeVisible({ - timeout: 5_000, - }); + await expect( + page.getByRole('alertdialog', { name: 'Enable OIDC without a linked account' }), + ).not.toBeVisible({ timeout: 1_000 }); + await expect(page.getByText('OIDC settings saved')).toBeVisible({ timeout: 5_000 }); }); }); diff --git a/e2e/tests/queue-cleaner/queue-cleaner-config.api.spec.ts b/e2e/tests/queue-cleaner/queue-cleaner-config.api.spec.ts new file mode 100644 index 00000000..54e6b7a2 --- /dev/null +++ b/e2e/tests/queue-cleaner/queue-cleaner-config.api.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('QueueCleaner — config', () => { + test('GET returns the config singleton', async ({ api }) => { + const res = await api.queueCleaner.getConfig(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('enabled'); + expect(body).toHaveProperty('cronExpression'); + }); + + test('PUT updates enabled flag and cron', async ({ api }) => { + const before = await (await api.queueCleaner.getConfig()).json(); + const res = await api.queueCleaner.updateConfig({ + ...before, + enabled: true, + cronExpression: '0 0/30 * * * ?', + }); + expect(res.ok).toBe(true); + + const after = await (await api.queueCleaner.getConfig()).json(); + expect(after.enabled).toBe(true); + expect(after.cronExpression).toBe('0 0/30 * * * ?'); + + await api.queueCleaner.updateConfig(before); + }); + + test('PUT rejects invalid cron expression', async ({ api }) => { + const before = await (await api.queueCleaner.getConfig()).json(); + const res = await api.queueCleaner.updateConfig({ + ...before, + cronExpression: 'not-a-cron', + }); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.queueCleaner.getConfig(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/queue-cleaner/queue-cleaner-job-execution.api.spec.ts b/e2e/tests/queue-cleaner/queue-cleaner-job-execution.api.spec.ts new file mode 100644 index 00000000..a8864468 --- /dev/null +++ b/e2e/tests/queue-cleaner/queue-cleaner-job-execution.api.spec.ts @@ -0,0 +1,74 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; +import { ArrStubs } from '../helpers/mocks'; + +test.describe('QueueCleaner — job execution end-to-end', () => { + test('manual trigger is accepted', async ({ api, mocks }) => { + await ArrStubs.applyArrDefaults(mocks.arr); + + await api.arr.createInstance('sonarr', { + name: 'sonarr-job', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'k', + version: 3, + enabled: true, + }); + + const trigger = await api.jobs.trigger('QueueCleaner'); + expect(trigger.status).toBeLessThan(300); + }); + + test('manual trigger with a stall rule configured is accepted', async ({ api, mocks }) => { + await ArrStubs.applyArrDefaults(mocks.arr); + await mocks.arr.stub( + ArrStubs.arrQueueStub([ + { + id: 1, + title: 'stalled.test.s01e01', + status: 'warning', + trackedDownloadStatus: 'warning', + trackedDownloadState: 'stalled', + errorMessage: 'No connections', + downloadId: 'HASH-STALLED', + protocol: 'torrent', + }, + ]), + ); + + const sonarr = await ( + await api.arr.createInstance('sonarr', { + name: 'sonarr-job-strike', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'k', + version: 3, + enabled: true, + }) + ).json(); + expect(sonarr.id).toBeTruthy(); + + const created = await ( + await api.queueCleaner.createRule('stall', { + name: 'stall-rule-job', + enabled: true, + maxStrikes: 3, + privacyType: 'Public', + minCompletionPercentage: 0, + maxCompletionPercentage: 100, + deletePrivateTorrentsFromClient: false, + changeCategory: false, + resetStrikesOnProgress: true, + minimumProgress: null, + }) + ).json(); + + const trigger = await api.jobs.trigger('QueueCleaner'); + expect(trigger.status).toBeLessThan(300); + + // Explicit cleanup of the created rule — autoReset wipes stall_rules, but + // EF Core's pooled connection sometimes retains a pre-DELETE snapshot for + // a moment, which makes the very next test see a phantom overlap. Calling + // the DELETE endpoint forces the backend itself to clear the row. + if (created?.id) { + await api.queueCleaner.deleteRule('stall', created.id); + } + }); +}); diff --git a/e2e/tests/queue-cleaner-ui.spec.ts b/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts similarity index 99% rename from e2e/tests/queue-cleaner-ui.spec.ts rename to e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts index e9fbf03b..1a031876 100644 --- a/e2e/tests/queue-cleaner-ui.spec.ts +++ b/e2e/tests/queue-cleaner/queue-cleaner-ui.spec.ts @@ -7,7 +7,7 @@ import { ensureAccordionExpanded, expectGuardOnLeave, expectNoGuardOnLeave, -} from './helpers/ui'; +} from '../helpers/ui'; // Behavior-parity spec for the Queue Cleaner settings form + stall-rule modal. test.describe('Queue Cleaner UI', () => { diff --git a/e2e/tests/queue-cleaner/queue-rules.api.spec.ts b/e2e/tests/queue-cleaner/queue-rules.api.spec.ts new file mode 100644 index 00000000..07075e8d --- /dev/null +++ b/e2e/tests/queue-cleaner/queue-rules.api.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from '../fixtures/base'; +import type { QueueRuleKind } from '../helpers/api/queue-cleaner'; + +const KINDS: QueueRuleKind[] = ['stall', 'slow']; + +function buildPayload(kind: QueueRuleKind, name: string, overrides: Record = {}) { + const base = { + name, + enabled: true, + maxStrikes: 3, + privacyType: 'Public', + minCompletionPercentage: 0, + maxCompletionPercentage: 100, + deletePrivateTorrentsFromClient: false, + changeCategory: false, + }; + if (kind === 'stall') { + return { ...base, resetStrikesOnProgress: true, minimumProgress: null, ...overrides }; + } + return { + ...base, + resetStrikesOnProgress: true, + minSpeed: '100KB', + maxTimeHours: 0, + ignoreAboveSize: null, + ...overrides, + }; +} + +test.describe('QueueCleaner — rules CRUD', () => { + // Belt-and-braces: SQLite-direct autoReset clears the rule tables, but EF + // Core's pooled connection can occasionally hold a pre-DELETE snapshot. + // Explicitly delete any lingering rules through the API as well so the + // backend's own view matches our reset. + test.beforeEach(async ({ api }) => { + for (const kind of KINDS) { + const list = await (await api.queueCleaner.listRules(kind)).json(); + if (Array.isArray(list)) { + for (const rule of list) { + if (rule?.id) { + await api.queueCleaner.deleteRule(kind, rule.id); + } + } + } + } + }); + + for (const kind of KINDS) { + test(`${kind}: create + list + update + delete`, async ({ api }) => { + const create = await api.queueCleaner.createRule(kind, buildPayload(kind, `${kind}-e2e`)); + if (!create.ok) { + console.error(`${kind} create failed:`, create.status, await create.text()); + } + expect(create.status).toBeLessThan(300); + const created = await create.json(); + expect(created.id).toBeTruthy(); + + const list = await (await api.queueCleaner.listRules(kind)).json(); + expect(list.some((r: { id: string }) => r.id === created.id)).toBe(true); + + const update = await api.queueCleaner.updateRule(kind, created.id, { + ...created, + name: `${kind}-renamed`, + maxStrikes: 5, + }); + expect(update.ok).toBe(true); + + const del = await api.queueCleaner.deleteRule(kind, created.id); + expect(del.status).toBe(204); + }); + + test(`${kind}: rejects maxStrikes below the minimum`, async ({ api }) => { + const res = await api.queueCleaner.createRule( + kind, + buildPayload(kind, `${kind}-bad`, { maxStrikes: 1 }), + ); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + } +}); diff --git a/e2e/tests/cors-wildcard-bug.spec.ts b/e2e/tests/regression/cors-wildcard.spec.ts similarity index 81% rename from e2e/tests/cors-wildcard-bug.spec.ts rename to e2e/tests/regression/cors-wildcard.spec.ts index ddb2fe3d..36cfecec 100644 --- a/e2e/tests/cors-wildcard-bug.spec.ts +++ b/e2e/tests/regression/cors-wildcard.spec.ts @@ -1,7 +1,7 @@ -import { test, expect } from '@playwright/test'; -import { TEST_CONFIG } from './helpers/test-config'; +import { test, expect, TEST_CONFIG } from '../fixtures/base'; -// Regression for GHSA-rwpc-36mg-fpvf +// Regression for GHSA-rwpc-36mg-fpvf — the API must not reflect arbitrary Origin +// headers in Access-Control-Allow-Origin (no wildcard CORS). test.describe.serial('GHSA-rwpc-36mg-fpvf regression', () => { const ATTACKER_ORIGIN = 'https://attacker.example'; @@ -11,7 +11,6 @@ test.describe.serial('GHSA-rwpc-36mg-fpvf regression', () => { headers: { Origin: ATTACKER_ORIGIN }, }); expect(res.status()).toBe(200); - const acao = res.headers()['access-control-allow-origin']; expect(acao).toBeUndefined(); }); @@ -24,7 +23,6 @@ test.describe.serial('GHSA-rwpc-36mg-fpvf regression', () => { 'Access-Control-Request-Method': 'GET', }, }); - const acao = res.headers()['access-control-allow-origin']; expect(acao).toBeUndefined(); }); diff --git a/e2e/tests/trusted-network-xff-bug.spec.ts b/e2e/tests/regression/trusted-network-xff.spec.ts similarity index 65% rename from e2e/tests/trusted-network-xff-bug.spec.ts rename to e2e/tests/regression/trusted-network-xff.spec.ts index cf0fb8f3..2e739246 100644 --- a/e2e/tests/trusted-network-xff-bug.spec.ts +++ b/e2e/tests/regression/trusted-network-xff.spec.ts @@ -1,17 +1,26 @@ -import { test, expect } from '@playwright/test'; -import { loginAndGetToken, setAuthBypass } from './helpers/app-api'; -import { TEST_CONFIG } from './helpers/test-config'; +import { test, expect, TEST_CONFIG, CleanuparrApi } from '../fixtures/base'; -// Regression for GHSA-8q44-v65j-jc3q +// Regression for GHSA-8q44-v65j-jc3q — spoofed X-Forwarded-For / X-Real-IP must not +// trigger trusted-network auth bypass when the request originates from a +// non-trusted source proxy (the nginx /attacker location simulates the spoof). + +async function withAdminApi(): Promise { + const api = new CleanuparrApi(); + const tokens = await api.auth.loginAndCaptureTokens( + TEST_CONFIG.adminUsername, + TEST_CONFIG.adminPassword, + ); + api.setToken(tokens.accessToken); + return api; +} test.describe.serial('GHSA-8q44-v65j-jc3q regression', () => { - let token: string; const PROXY = TEST_CONFIG.proxyUrl; const ATTACKER = `${PROXY}/attacker`; test.beforeAll(async () => { - token = await loginAndGetToken(); - await setAuthBypass(token, { + const api = await withAdminApi(); + await api.general.setAuthBypass({ disableAuthForLocalAddresses: true, trustForwardedHeaders: true, trustedNetworks: [], @@ -19,7 +28,8 @@ test.describe.serial('GHSA-8q44-v65j-jc3q regression', () => { }); test.afterAll(async () => { - await setAuthBypass(token, { + const api = await withAdminApi(); + await api.general.setAuthBypass({ disableAuthForLocalAddresses: false, trustForwardedHeaders: false, trustedNetworks: [], @@ -31,7 +41,6 @@ test.describe.serial('GHSA-8q44-v65j-jc3q regression', () => { headers: { 'X-Forwarded-For': '10.0.0.5' }, }); expect(res.status()).toBe(200); - const body = await res.json(); expect(body.authBypassActive).toBe(false); }); @@ -41,7 +50,6 @@ test.describe.serial('GHSA-8q44-v65j-jc3q regression', () => { headers: { 'X-Real-IP': '10.0.0.5' }, }); expect(res.status()).toBe(200); - const body = await res.json(); expect(body.authBypassActive).toBe(false); }); @@ -49,7 +57,6 @@ test.describe.serial('GHSA-8q44-v65j-jc3q regression', () => { test('legitimate localhost request still gets bypass via direct nginx', async ({ request }) => { const res = await request.get(`${PROXY}/api/auth/status`); expect(res.status()).toBe(200); - const body = await res.json(); expect(body.authBypassActive).toBe(true); }); diff --git a/e2e/tests/seeker-config-api.spec.ts b/e2e/tests/seeker-config-api.spec.ts deleted file mode 100644 index a07d5d2c..00000000 --- a/e2e/tests/seeker-config-api.spec.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { test, expect } from '@playwright/test'; -import { - loginAndGetToken, - getSeekerConfig, - updateSeekerConfig, - getSearchStatsSummary, - getSearchEvents, - getCfScores, - getCfScoreStats, - createArrInstance, - deleteArrInstance, -} from './helpers/app-api'; - -test.describe.serial('Seeker API', () => { - let token: string; - - test.beforeAll(async () => { - token = await loginAndGetToken(); - }); - - test('should return default seeker config', async () => { - const res = await getSeekerConfig(token); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('searchEnabled'); - expect(body).toHaveProperty('searchInterval'); - expect(body).toHaveProperty('proactiveSearchEnabled'); - expect(body).toHaveProperty('selectionStrategy'); - expect(body).toHaveProperty('useRoundRobin'); - expect(body).toHaveProperty('postReleaseGraceHours'); - expect(body).toHaveProperty('instances'); - expect(Array.isArray(body.instances)).toBe(true); - - // monitoredOnly, useCutoff, useCustomFormatScore are per-instance settings - expect(body).not.toHaveProperty('monitoredOnly'); - expect(body).not.toHaveProperty('useCutoff'); - expect(body).not.toHaveProperty('useCustomFormatScore'); - }); - - test('should update seeker config', async () => { - // Get current config first - const getRes = await getSeekerConfig(token); - const current = await getRes.json(); - - // Update with modified values - const updateRes = await updateSeekerConfig(token, { - ...current, - searchEnabled: false, - searchInterval: 5, - }); - expect(updateRes.status).toBe(200); - - // Verify the update persisted - const verifyRes = await getSeekerConfig(token); - const updated = await verifyRes.json(); - expect(updated.searchEnabled).toBe(false); - expect(updated.searchInterval).toBe(5); - - // Restore original values - await updateSeekerConfig(token, { - ...updated, - searchEnabled: current.searchEnabled, - searchInterval: current.searchInterval, - }); - }); - - test('should reject invalid search interval', async () => { - const getRes = await getSeekerConfig(token); - const current = await getRes.json(); - - const res = await updateSeekerConfig(token, { - ...current, - searchInterval: 7, // Not a valid divisor of 60 - }); - // Should fail validation (400 or 500) - expect(res.status).toBeGreaterThanOrEqual(400); - }); - - test('should return search stats summary with zero counts', async () => { - const res = await getSearchStatsSummary(token); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('totalSearchesAllTime'); - expect(body).toHaveProperty('searchesLast7Days'); - expect(body).toHaveProperty('searchesLast30Days'); - expect(body).toHaveProperty('uniqueItemsSearched'); - expect(body.totalSearchesAllTime).toBeGreaterThanOrEqual(0); - }); - - test('should return empty search events', async () => { - const res = await getSearchEvents(token); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('items'); - expect(Array.isArray(body.items)).toBe(true); - }); - - test('should return empty CF scores list', async () => { - const res = await getCfScores(token); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('items'); - expect(Array.isArray(body.items)).toBe(true); - expect(body).toHaveProperty('totalCount'); - }); - - test('should return CF score stats with zero values', async () => { - const res = await getCfScoreStats(token); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('totalTracked'); - expect(body).toHaveProperty('belowCutoff'); - expect(body.totalTracked).toBeGreaterThanOrEqual(0); - }); -}); - -test.describe.serial('Seeker Per-Instance Config', () => { - let token: string; - let radarrId: string; - let sonarrId: string; - - test.beforeAll(async () => { - token = await loginAndGetToken(); - - // Create Radarr and Sonarr instances for per-instance testing - const radarrRes = await createArrInstance(token, 'radarr', { - name: 'E2E Radarr', - url: 'http://radarr-fake:7878', - apiKey: 'e2e-test-key-radarr', - version: 5, - }); - expect(radarrRes.status).toBe(201); - const radarrBody = await radarrRes.json(); - radarrId = radarrBody.id; - - const sonarrRes = await createArrInstance(token, 'sonarr', { - name: 'E2E Sonarr', - url: 'http://sonarr-fake:8989', - apiKey: 'e2e-test-key-sonarr', - version: 4, - }); - expect(sonarrRes.status).toBe(201); - const sonarrBody = await sonarrRes.json(); - sonarrId = sonarrBody.id; - }); - - test.afterAll(async () => { - // Clean up created instances - if (radarrId) await deleteArrInstance(token, 'radarr', radarrId); - if (sonarrId) await deleteArrInstance(token, 'sonarr', sonarrId); - }); - - test('should include arr instances with per-instance settings', async () => { - const res = await getSeekerConfig(token); - expect(res.status).toBe(200); - - const body = await res.json(); - expect(body.instances.length).toBeGreaterThanOrEqual(2); - - const radarr = body.instances.find((i: Record) => i.arrInstanceId === radarrId); - const sonarr = body.instances.find((i: Record) => i.arrInstanceId === sonarrId); - - expect(radarr).toBeDefined(); - expect(sonarr).toBeDefined(); - - // Verify per-instance properties exist with defaults - for (const instance of [radarr, sonarr]) { - expect(instance).toHaveProperty('enabled'); - expect(instance).toHaveProperty('skipTags'); - expect(instance).toHaveProperty('activeDownloadLimit'); - expect(instance).toHaveProperty('minCycleTimeDays'); - expect(instance).toHaveProperty('monitoredOnly'); - expect(instance).toHaveProperty('useCutoff'); - expect(instance).toHaveProperty('useCustomFormatScore'); - expect(instance).toHaveProperty('instanceName'); - expect(instance).toHaveProperty('instanceType'); - expect(instance).toHaveProperty('arrInstanceEnabled'); - } - - // Defaults: monitoredOnly=true, useCutoff=false, useCustomFormatScore=false - expect(radarr.monitoredOnly).toBe(true); - expect(radarr.useCutoff).toBe(false); - expect(radarr.useCustomFormatScore).toBe(false); - }); - - test('should update per-instance settings independently', async () => { - const getRes = await getSeekerConfig(token); - const current = await getRes.json(); - - // Set different settings per instance: - // Radarr: useCutoff + useCustomFormatScore, monitoredOnly off - // Sonarr: useCutoff only, monitoredOnly on - const instances = current.instances.map((i: Record) => { - if (i.arrInstanceId === radarrId) { - return { ...i, enabled: true, monitoredOnly: false, useCutoff: true, useCustomFormatScore: true }; - } - if (i.arrInstanceId === sonarrId) { - return { ...i, enabled: true, monitoredOnly: true, useCutoff: true, useCustomFormatScore: false }; - } - return i; - }); - - const updateRes = await updateSeekerConfig(token, { - ...current, - instances, - }); - expect(updateRes.status).toBe(200); - - // Verify settings persisted with correct per-instance values - const verifyRes = await getSeekerConfig(token); - const updated = await verifyRes.json(); - - const radarr = updated.instances.find((i: Record) => i.arrInstanceId === radarrId); - const sonarr = updated.instances.find((i: Record) => i.arrInstanceId === sonarrId); - - expect(radarr.monitoredOnly).toBe(false); - expect(radarr.useCutoff).toBe(true); - expect(radarr.useCustomFormatScore).toBe(true); - - expect(sonarr.monitoredOnly).toBe(true); - expect(sonarr.useCutoff).toBe(true); - expect(sonarr.useCustomFormatScore).toBe(false); - }); - - test('should persist per-instance settings across updates', async () => { - // Update only a global setting without changing instances - const getRes = await getSeekerConfig(token); - const current = await getRes.json(); - - const updateRes = await updateSeekerConfig(token, { - ...current, - postReleaseGraceHours: 12, - }); - expect(updateRes.status).toBe(200); - - // Per-instance settings should remain unchanged - const verifyRes = await getSeekerConfig(token); - const updated = await verifyRes.json(); - - const radarr = updated.instances.find((i: Record) => i.arrInstanceId === radarrId); - expect(radarr.useCutoff).toBe(true); - expect(radarr.useCustomFormatScore).toBe(true); - expect(updated.postReleaseGraceHours).toBe(12); - - // Restore - await updateSeekerConfig(token, { - ...updated, - postReleaseGraceHours: current.postReleaseGraceHours, - }); - }); -}); diff --git a/e2e/tests/seeker/custom-format-scores.api.spec.ts b/e2e/tests/seeker/custom-format-scores.api.spec.ts new file mode 100644 index 00000000..41e2c2f0 --- /dev/null +++ b/e2e/tests/seeker/custom-format-scores.api.spec.ts @@ -0,0 +1,46 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Seeker — custom format scores', () => { + test('GET /api/seeker/cf-scores returns paginated payload', async ({ api }) => { + const res = await api.seeker.listCustomFormatScores(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body) || Array.isArray(body.items)).toBe(true); + }); + + test('GET /upgrades returns array or paginated payload', async ({ api }) => { + const res = await api.seeker.listCustomFormatScoreUpgrades(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body) || Array.isArray(body.items)).toBe(true); + }); + + test('GET /instances returns instances array wrapper', async ({ api }) => { + const res = await api.seeker.listCustomFormatScoreInstances(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.instances)).toBe(true); + }); + + test('GET /stats returns aggregate object', async ({ api }) => { + const res = await api.seeker.getCustomFormatScoreStats(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + }); + + test('history returns empty entries for unknown instance + item', async ({ api }) => { + const res = await api.seeker.getCustomFormatScoreHistory( + '00000000-0000-0000-0000-000000000000', + '99999', + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.entries).toEqual([]); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.seeker.listCustomFormatScores(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/seeker/search-stats.api.spec.ts b/e2e/tests/seeker/search-stats.api.spec.ts new file mode 100644 index 00000000..1bd1ad3a --- /dev/null +++ b/e2e/tests/seeker/search-stats.api.spec.ts @@ -0,0 +1,27 @@ +import { test, expect } from '../fixtures/base'; + +test.describe('Seeker — search stats', () => { + test('GET /summary returns object with counters', async ({ api }) => { + const res = await api.seeker.getSearchStatsSummary(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + }); + + test('GET /events returns paginated array', async ({ api }) => { + const res = await api.seeker.getSearchEvents({ page: '1', pageSize: '10' }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body) || Array.isArray(body.items) || Array.isArray(body.records)).toBe(true); + }); + + test('GET /events accepts severity filter', async ({ api }) => { + const res = await api.seeker.getSearchEvents({ severity: 'info' }); + expect(res.status).toBe(200); + }); + + test('GET requires auth', async ({ anonymousApi }) => { + const res = await anonymousApi.seeker.getSearchStatsSummary(); + expect(res.status).toBe(401); + }); +}); diff --git a/e2e/tests/seeker/seeker-config.api.spec.ts b/e2e/tests/seeker/seeker-config.api.spec.ts new file mode 100644 index 00000000..0a332a55 --- /dev/null +++ b/e2e/tests/seeker/seeker-config.api.spec.ts @@ -0,0 +1,76 @@ +import { test, expect } from '../fixtures/base'; + +test.describe.serial('Seeker — config', () => { + test('returns default seeker config', async ({ api }) => { + const res = await api.seeker.getConfig(); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('searchEnabled'); + expect(body).toHaveProperty('searchInterval'); + expect(body).toHaveProperty('proactiveSearchEnabled'); + expect(body).toHaveProperty('selectionStrategy'); + expect(body).toHaveProperty('useRoundRobin'); + expect(body).toHaveProperty('postReleaseGraceHours'); + expect(body).toHaveProperty('instances'); + expect(Array.isArray(body.instances)).toBe(true); + + expect(body).not.toHaveProperty('monitoredOnly'); + expect(body).not.toHaveProperty('useCutoff'); + expect(body).not.toHaveProperty('useCustomFormatScore'); + }); + + test('updates seeker config', async ({ api }) => { + const current = await (await api.seeker.getConfig()).json(); + + const updateRes = await api.seeker.updateConfig({ + ...current, + searchEnabled: false, + searchInterval: 5, + }); + expect(updateRes.status).toBe(200); + + const updated = await (await api.seeker.getConfig()).json(); + expect(updated.searchEnabled).toBe(false); + expect(updated.searchInterval).toBe(5); + + await api.seeker.updateConfig({ + ...updated, + searchEnabled: current.searchEnabled, + searchInterval: current.searchInterval, + }); + }); + + test('rejects invalid search interval (not a divisor of 60)', async ({ api }) => { + const current = await (await api.seeker.getConfig()).json(); + const res = await api.seeker.updateConfig({ ...current, searchInterval: 7 }); + expect(res.status).toBeGreaterThanOrEqual(400); + }); + + test('returns search stats summary with zero counts initially', async ({ api }) => { + const res = await api.seeker.getSearchStatsSummary(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('totalSearchesAllTime'); + expect(body).toHaveProperty('searchesLast7Days'); + expect(body).toHaveProperty('searchesLast30Days'); + expect(body).toHaveProperty('uniqueItemsSearched'); + expect(body.totalSearchesAllTime).toBeGreaterThanOrEqual(0); + }); + + test('returns empty CF scores list initially', async ({ api }) => { + const res = await api.seeker.listCustomFormatScores(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(Array.isArray(body) || Array.isArray(body.items)).toBe(true); + }); + + test('returns CF score stats with zero values initially', async ({ api }) => { + const res = await api.seeker.getCustomFormatScoreStats(); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('totalTracked'); + expect(body).toHaveProperty('belowCutoff'); + expect(body.totalTracked).toBeGreaterThanOrEqual(0); + }); +}); diff --git a/e2e/tests/seeker/seeker-instance-config.api.spec.ts b/e2e/tests/seeker/seeker-instance-config.api.spec.ts new file mode 100644 index 00000000..20288b95 --- /dev/null +++ b/e2e/tests/seeker/seeker-instance-config.api.spec.ts @@ -0,0 +1,110 @@ +import { test, expect, TEST_CONFIG } from '../fixtures/base'; + +// These tests create arr instances inline rather than in beforeAll because the +// auto-reset fixture wipes them between tests. + +async function createTwoArrInstances( + api: import('../helpers/api').CleanuparrApi, +): Promise<{ radarrId: string; sonarrId: string }> { + const radarr = await ( + await api.arr.createInstance('radarr', { + name: 'E2E Radarr', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'e2e-test-key-radarr', + version: 5, + }) + ).json(); + + const sonarr = await ( + await api.arr.createInstance('sonarr', { + name: 'E2E Sonarr', + url: TEST_CONFIG.mocks.arrUrl, + apiKey: 'e2e-test-key-sonarr', + version: 4, + }) + ).json(); + + return { radarrId: radarr.id, sonarrId: sonarr.id }; +} + +test.describe('Seeker — per-instance config', () => { + test('config includes arr instances with per-instance settings', async ({ api }) => { + const { radarrId, sonarrId } = await createTwoArrInstances(api); + + const body = await (await api.seeker.getConfig()).json(); + expect(body.instances.length).toBeGreaterThanOrEqual(2); + + const radarr = body.instances.find((i: { arrInstanceId: string }) => i.arrInstanceId === radarrId); + const sonarr = body.instances.find((i: { arrInstanceId: string }) => i.arrInstanceId === sonarrId); + expect(radarr).toBeDefined(); + expect(sonarr).toBeDefined(); + + for (const instance of [radarr, sonarr]) { + expect(instance).toHaveProperty('enabled'); + expect(instance).toHaveProperty('skipTags'); + expect(instance).toHaveProperty('activeDownloadLimit'); + expect(instance).toHaveProperty('minCycleTimeDays'); + expect(instance).toHaveProperty('monitoredOnly'); + expect(instance).toHaveProperty('useCutoff'); + expect(instance).toHaveProperty('useCustomFormatScore'); + expect(instance).toHaveProperty('instanceName'); + expect(instance).toHaveProperty('instanceType'); + expect(instance).toHaveProperty('arrInstanceEnabled'); + } + + expect(radarr.monitoredOnly).toBe(true); + expect(radarr.useCutoff).toBe(false); + expect(radarr.useCustomFormatScore).toBe(false); + }); + + test('updates per-instance settings independently', async ({ api }) => { + const { radarrId, sonarrId } = await createTwoArrInstances(api); + const current = await (await api.seeker.getConfig()).json(); + + const instances = current.instances.map((i: { arrInstanceId: string }) => { + if (i.arrInstanceId === radarrId) { + return { ...i, enabled: true, monitoredOnly: false, useCutoff: true, useCustomFormatScore: true }; + } + if (i.arrInstanceId === sonarrId) { + return { ...i, enabled: true, monitoredOnly: true, useCutoff: true, useCustomFormatScore: false }; + } + return i; + }); + + const update = await api.seeker.updateConfig({ ...current, instances }); + expect(update.status).toBe(200); + + const updated = await (await api.seeker.getConfig()).json(); + const radarr = updated.instances.find((i: { arrInstanceId: string }) => i.arrInstanceId === radarrId); + const sonarr = updated.instances.find((i: { arrInstanceId: string }) => i.arrInstanceId === sonarrId); + + expect(radarr.monitoredOnly).toBe(false); + expect(radarr.useCutoff).toBe(true); + expect(radarr.useCustomFormatScore).toBe(true); + expect(sonarr.monitoredOnly).toBe(true); + expect(sonarr.useCutoff).toBe(true); + expect(sonarr.useCustomFormatScore).toBe(false); + }); + + test('persists per-instance settings across global updates', async ({ api }) => { + const { radarrId } = await createTwoArrInstances(api); + let current = await (await api.seeker.getConfig()).json(); + + const seeded = current.instances.map((i: { arrInstanceId: string }) => + i.arrInstanceId === radarrId + ? { ...i, enabled: true, useCutoff: true, useCustomFormatScore: true } + : i, + ); + await api.seeker.updateConfig({ ...current, instances: seeded }); + + current = await (await api.seeker.getConfig()).json(); + const update = await api.seeker.updateConfig({ ...current, postReleaseGraceHours: 12 }); + expect(update.status).toBe(200); + + const updated = await (await api.seeker.getConfig()).json(); + const radarr = updated.instances.find((i: { arrInstanceId: string }) => i.arrInstanceId === radarrId); + expect(radarr.useCutoff).toBe(true); + expect(radarr.useCustomFormatScore).toBe(true); + expect(updated.postReleaseGraceHours).toBe(12); + }); +}); diff --git a/e2e/tests/seeker-ui.spec.ts b/e2e/tests/seeker/seeker-ui.spec.ts similarity index 99% rename from e2e/tests/seeker-ui.spec.ts rename to e2e/tests/seeker/seeker-ui.spec.ts index dca2739a..a3093f04 100644 --- a/e2e/tests/seeker-ui.spec.ts +++ b/e2e/tests/seeker/seeker-ui.spec.ts @@ -5,7 +5,7 @@ import { ensureToggle, expectGuardOnLeave, expectNoGuardOnLeave, -} from './helpers/ui'; +} from '../helpers/ui'; // Behavior-parity spec for the Seeker settings form. test.describe('Seeker UI', () => { diff --git a/e2e/tests/signalr/app-hub.api.spec.ts b/e2e/tests/signalr/app-hub.api.spec.ts new file mode 100644 index 00000000..3830d9bf --- /dev/null +++ b/e2e/tests/signalr/app-hub.api.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from '../fixtures/base'; +import { buildHubConnection, waitForEvent } from '../helpers/api/signalr'; +import { adminTokens } from '../helpers/test-lifecycle'; + +test.describe('SignalR — app hub', () => { + test('client can connect with bearer token and receive initial state', async () => { + const tokens = adminTokens(); + const connection = buildHubConnection({ + accessToken: tokens.accessToken, + hubUrl: '/api/hubs/app', + }); + await connection.start(); + expect(connection.state).toBe('Connected'); + + try { + const eventsPromise = waitForEvent(connection, 'EventsReceived'); + await connection.invoke('GetRecentEvents', 5); + const events = await eventsPromise; + expect(Array.isArray(events)).toBe(true); + + const strikesPromise = waitForEvent(connection, 'StrikesReceived'); + await connection.invoke('GetRecentStrikes', 5); + const strikes = await strikesPromise; + expect(Array.isArray(strikes)).toBe(true); + } finally { + await connection.stop(); + } + }); + + test('connection without token is rejected', async () => { + const connection = buildHubConnection({ + accessToken: '', + hubUrl: '/api/hubs/app', + }); + await expect(connection.start()).rejects.toThrow(); + }); +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 45c4d19b..71f53dac 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -5,6 +5,7 @@ "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, - "skipLibCheck": true + "skipLibCheck": true, + "types": ["node"] } }