From 533c234b6ef23ca0b3208e2e6ede5b984347b026 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 17 Jun 2026 10:11:42 +0000 Subject: [PATCH] fix: preserve CHANGE_ME qBit placeholder sections on config save Unfinished [qBit-*] sections with Host = CHANGE_ME were dropped from config.toml whenever POST /web/config or POST /api/config saved unrelated changes. ApplyDottedConfigChanges excluded them from the merge snapshot and SaveConfig omitted them from TOML output. Include all qBit instances in the Host config round-trip and write CHANGE_ME placeholders to disk so multi-instance setup stubs survive. Regression tests: - ConfigurationLoaderTests.SaveConfig_PreservesChangeMeQBitPlaceholderSection - ConfigPlaceholderPreservationTests.PostConfig_UnrelatedChange_PreservesChangeMeQBitSeedboxStubOnDisk Co-authored-by: Feramance --- .../Configuration/ConfigurationLoader.cs | 5 ++- src/Torrentarr.Host/Program.cs | 2 +- .../Configuration/ConfigurationLoaderTests.cs | 27 ++++++++++++ .../Api/ConfigPlaceholderPreservationTests.cs | 44 +++++++++++++++++++ .../Api/TorrentarrWebApplicationFactory.cs | 38 ++++++++++++++++ 5 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 tests/Torrentarr.Host.Tests/Api/ConfigPlaceholderPreservationTests.cs diff --git a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs index 2629612a..618637f6 100644 --- a/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs +++ b/src/Torrentarr.Core/Configuration/ConfigurationLoader.cs @@ -1755,9 +1755,10 @@ private string GenerateTomlContent(TorrentarrConfig config) } sb.AppendLine(); - // All qBit instances — "qBit" written first (primary), then additional [qBit-XXX] + // All qBit instances — "qBit" written first (primary), then additional [qBit-XXX]. + // Include CHANGE_ME placeholders so multi-instance stubs survive WebUI round-trips. var orderedQbit = config.QBitInstances - .Where(kv => !string.IsNullOrEmpty(kv.Value.Host) && kv.Value.Host != "CHANGE_ME") + .Where(kv => !string.IsNullOrEmpty(kv.Value.Host)) .OrderBy(kv => kv.Key == "qBit" ? 0 : 1).ThenBy(kv => kv.Key); foreach (var (name, qbit) in orderedQbit) diff --git a/src/Torrentarr.Host/Program.cs b/src/Torrentarr.Host/Program.cs index 73447faf..eef5a454 100644 --- a/src/Torrentarr.Host/Program.cs +++ b/src/Torrentarr.Host/Program.cs @@ -2626,7 +2626,7 @@ static async Task HandleTestConnection(TestConnectionRequest req, Torre var currentObj = new Newtonsoft.Json.Linq.JObject(); currentObj["Settings"] = Newtonsoft.Json.Linq.JObject.FromObject(cfg.Settings, serializer); currentObj["WebUI"] = Newtonsoft.Json.Linq.JObject.FromObject(cfg.WebUI, serializer); - foreach (var (key, qbit) in cfg.QBitInstances.Where(kv => kv.Value.Host != "CHANGE_ME")) + foreach (var (key, qbit) in cfg.QBitInstances) currentObj[key] = Newtonsoft.Json.Linq.JObject.FromObject(qbit, serializer); foreach (var (key, arr) in cfg.ArrInstances) currentObj[key] = Newtonsoft.Json.Linq.JObject.FromObject(arr, serializer); diff --git a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs index 02fefd64..32f5d823 100644 --- a/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs +++ b/tests/Torrentarr.Core.Tests/Configuration/ConfigurationLoaderTests.cs @@ -671,6 +671,33 @@ public void SaveConfig_PreservesQBitTrackers() reloaded.Settings.LoopSleepTimer.Should().Be(10); } + [Fact] + public void SaveConfig_PreservesChangeMeQBitPlaceholderSection() + { + WriteToml(""" + [Settings] + LoopSleepTimer = 5 + + [qBit] + Host = "localhost" + Port = 8080 + + [qBit-seedbox] + Host = "CHANGE_ME" + Port = 8080 + """); + + var loader = new ConfigurationLoader(_tempFilePath); + var config = loader.Load(); + config.Settings.LoopSleepTimer = 10; + loader.SaveConfig(config); + + var reloaded = new ConfigurationLoader(_tempFilePath).Load(); + reloaded.QBitInstances.Should().ContainKey("qBit-seedbox"); + reloaded.QBitInstances["qBit-seedbox"].Host.Should().Be("CHANGE_ME"); + reloaded.Settings.LoopSleepTimer.Should().Be(10); + } + [Fact] public void Save_WebUI_WritesAuthBooleans() { diff --git a/tests/Torrentarr.Host.Tests/Api/ConfigPlaceholderPreservationTests.cs b/tests/Torrentarr.Host.Tests/Api/ConfigPlaceholderPreservationTests.cs new file mode 100644 index 00000000..badf65ce --- /dev/null +++ b/tests/Torrentarr.Host.Tests/Api/ConfigPlaceholderPreservationTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using System.Net; +using System.Text; +using System.Text.Json; +using Xunit; + +namespace Torrentarr.Host.Tests.Api; + +/// +/// Regression: CHANGE_ME qBit placeholder sections must survive unrelated POST /web/config saves. +/// +[Collection("HostWeb")] +public class ConfigPlaceholderPreservationTests : IClassFixture +{ + private readonly MultiQBitPlaceholderWebApplicationFactory _factory; + + public ConfigPlaceholderPreservationTests(MultiQBitPlaceholderWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task PostConfig_UnrelatedChange_PreservesChangeMeQBitSeedboxStubOnDisk() + { + _factory.SetConfigEnv(); + var client = _factory.CreateClientWithApiToken(); + + var payload = new + { + changes = new Dictionary + { + ["settings.loopSleepTimer"] = 12 + } + }; + var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); + + var response = await client.PostAsync("/web/config", content); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var savedToml = await File.ReadAllTextAsync(_factory.TempConfigPath); + savedToml.Should().Contain("[qBit-seedbox]"); + savedToml.Should().Contain("Host = \"CHANGE_ME\""); + } +} diff --git a/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs b/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs index 4ae325be..d1facbbc 100644 --- a/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs +++ b/tests/Torrentarr.Host.Tests/Api/TorrentarrWebApplicationFactory.cs @@ -365,6 +365,44 @@ protected override void Dispose(bool disposing) } } +/// Factory with configured qBit plus a CHANGE_ME placeholder qBit-seedbox stub. +public class MultiQBitPlaceholderWebApplicationFactory : TorrentarrWebApplicationFactory +{ + public MultiQBitPlaceholderWebApplicationFactory() => RewriteConfigFile(); + + protected override string GetTestConfigToml() => """ + [Settings] + ConfigVersion = "6.12.2" + LoopSleepTimer = 5 + FailedCategory = "failed" + RecheckCategory = "recheck" + PingURLS = ["one.one.one.one"] + + [WebUI] + Host = "0.0.0.0" + Port = 6969 + Token = "test-api-token" + AuthDisabled = true + LocalAuthEnabled = false + OIDCEnabled = false + LiveArr = false + + [qBit] + Host = "localhost" + Port = 8080 + UserName = "admin" + Password = "adminpass" + ManagedCategories = ["radarr"] + + [qBit-seedbox] + Host = "CHANGE_ME" + Port = 8080 + UserName = "CHANGE_ME" + Password = "CHANGE_ME" + ManagedCategories = [] + """; +} + /// Factory with Radarr, Sonarr, Lidarr, and qBit sections for catalog/validation endpoint tests. public class ArrCatalogWebApplicationFactory : TorrentarrWebApplicationFactory {