diff --git a/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueCleanerResponseContractTests.cs b/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueCleanerResponseContractTests.cs new file mode 100644 index 00000000..99032f2c --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/Features/QueueCleaner/QueueCleanerResponseContractTests.cs @@ -0,0 +1,192 @@ +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.Extensions.Logging; +using NSubstitute; +using Shouldly; +using Xunit; + +namespace Cleanuparr.Api.Tests.Features.QueueCleaner; + +/// +/// Records today's wire shape, leaks included. +/// +public class QueueCleanerResponseContractTests : IDisposable +{ + private readonly DataContext _dataContext; + private readonly QueueCleanerConfigController _configController; + private readonly QueueRulesController _rulesController; + + public QueueCleanerResponseContractTests() + { + _dataContext = ConfigControllerTestDataFactory.CreateDataContext(); + + _configController = new QueueCleanerConfigController( + Substitute.For>(), + _dataContext, + Substitute.For()); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_configController); + + IRuleIntervalValidator validator = Substitute.For(); + validator.ValidateStallRuleIntervals(Arg.Any(), Arg.Any>()) + .Returns(ValidationResult.Success()); + validator.ValidateSlowRuleIntervals(Arg.Any(), Arg.Any>()) + .Returns(ValidationResult.Success()); + + _rulesController = new QueueRulesController( + Substitute.For>(), + _dataContext, + validator); + ConfigControllerTestDataFactory.ConfigureProblemDetails(_rulesController); + } + + public void Dispose() + { + _dataContext.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task GetQueueCleanerConfig_ReturnsTheDocumentedKeys() + { + IActionResult result = await _configController.GetQueueCleanerConfig(); + + ResponseContract.Keys(result).ShouldBe( + [ + "cronExpression", + "downloadingMetadataMaxStrikes", + "enabled", + "failedImport", + "id", + "ignoredDownloads", + "processNoContentId", + "slowRules", + "stallRules", + "useAdvancedScheduling", + ]); + } + + [Fact] + public async Task GetStallRules_ReturnsTheDocumentedKeys() + { + await _rulesController.CreateStallRule(NewStallDto("contract")); + + IActionResult result = await _rulesController.GetStallRules(); + + ResponseContract.FirstItemKeys(result).ShouldBe(StallRuleKeys); + } + + [Fact] + public async Task CreateStallRule_ReturnsTheDocumentedKeys() + { + IActionResult result = await _rulesController.CreateStallRule(NewStallDto("contract")); + + ResponseContract.Keys(result).ShouldBe(StallRuleKeys); + } + + [Fact] + public async Task UpdateStallRule_ReturnsTheDocumentedKeys() + { + IActionResult created = await _rulesController.CreateStallRule(NewStallDto("contract")); + Guid id = created.ShouldBeOfType().Value.ShouldBeOfType().Id; + + IActionResult result = await _rulesController.UpdateStallRule(id, NewStallDto("renamed")); + + ResponseContract.Keys(result).ShouldBe(StallRuleKeys); + } + + [Fact] + public async Task GetSlowRules_ReturnsTheDocumentedKeys() + { + await _rulesController.CreateSlowRule(NewSlowDto("contract")); + + IActionResult result = await _rulesController.GetSlowRules(); + + ResponseContract.FirstItemKeys(result).ShouldBe(SlowRuleKeys); + } + + [Fact] + public async Task CreateSlowRule_ReturnsTheDocumentedKeys() + { + IActionResult result = await _rulesController.CreateSlowRule(NewSlowDto("contract")); + + ResponseContract.Keys(result).ShouldBe(SlowRuleKeys); + } + + [Fact] + public async Task UpdateSlowRule_ReturnsTheDocumentedKeys() + { + IActionResult created = await _rulesController.CreateSlowRule(NewSlowDto("contract")); + Guid id = created.ShouldBeOfType().Value.ShouldBeOfType().Id; + + IActionResult result = await _rulesController.UpdateSlowRule(id, NewSlowDto("renamed")); + + ResponseContract.Keys(result).ShouldBe(SlowRuleKeys); + } + + private static readonly string[] StallRuleKeys = + [ + "changeCategory", + "deletePrivateTorrentsFromClient", + "enabled", + "id", + "maxCompletionPercentage", + "maxStrikes", + "minCompletionPercentage", + "minimumProgress", + "name", + "privacyType", + "queueCleanerConfig", + "queueCleanerConfigId", + "resetStrikesOnProgress", + ]; + + private static readonly string[] SlowRuleKeys = + [ + "changeCategory", + "deletePrivateTorrentsFromClient", + "enabled", + "id", + "ignoreAboveSize", + "ignoreWhileAltSpeedActive", + "maxCompletionPercentage", + "maxStrikes", + "maxTimeHours", + "minCompletionPercentage", + "minSpeed", + "name", + "privacyType", + "queueCleanerConfig", + "queueCleanerConfigId", + "resetStrikesOnProgress", + ]; + + 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/ResponseContract.cs b/code/backend/Cleanuparr.Api.Tests/TestHelpers/ResponseContract.cs new file mode 100644 index 00000000..52060b75 --- /dev/null +++ b/code/backend/Cleanuparr.Api.Tests/TestHelpers/ResponseContract.cs @@ -0,0 +1,61 @@ +using System.Text.Json; +using Cleanuparr.Api.Json; +using Microsoft.AspNetCore.Mvc; + +namespace Cleanuparr.Api.Tests.TestHelpers; + +/// +/// Serializes a controller result through the API's own JSON options. +/// +public static class ResponseContract +{ + private static readonly JsonSerializerOptions Options = BuildOptions(); + + private static JsonSerializerOptions BuildOptions() + { + JsonSerializerOptions options = new(JsonSerializerDefaults.Web); + CleanuparrJsonConfiguration.ConfigureApiInbound(options); + return options; + } + + /// + /// Serializes the value carried by an and parses it back. + /// + public static JsonElement Body(IActionResult result) + { + if (result is not ObjectResult objectResult) + { + throw new InvalidOperationException($"Expected an ObjectResult, got {result.GetType().Name}."); + } + + if (objectResult.Value is null) + { + throw new InvalidOperationException("The result carries no value."); + } + + string json = JsonSerializer.Serialize(objectResult.Value, objectResult.Value.GetType(), Options); + return JsonDocument.Parse(json).RootElement.Clone(); + } + + /// + /// Top-level property names, sorted so assertions read in a stable order. + /// + public static IReadOnlyList Keys(IActionResult result) => Keys(Body(result)); + + /// + public static IReadOnlyList Keys(JsonElement element) => + element.EnumerateObject() + .Select(property => property.Name) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + /// + /// Property names of the first array element, empty when the array is empty. + /// + public static IReadOnlyList FirstItemKeys(IActionResult result) + { + JsonElement body = Body(result); + JsonElement? first = body.EnumerateArray().Cast().FirstOrDefault(); + return first is null ? [] : Keys(first.Value); + } +}