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);
+ }
+}