Files
ssh-access-tui/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs
T
redlineoandClaude Sonnet 5 804b9610ca Generalize mail templates from a fixed pair to an operator-managed list
ArchiveKind was a hardcoded two-value enum (ssh_access/config_update),
wired through FilenameParser's regex, MailTemplateSet's two fixed
slots, and SettingsForm's two fixed tabs. Operators wanted arbitrary
custom templates (own file-name suffix, subject, body) without a code
change per new archive kind ssh-access-tui might start producing.

Replaced with MailTemplateDefinition (DisplayName/FileSuffix/Subject/
BodyHtml) in an open-ended list: FilenameParser now accepts any
"{login}_{suffix}.7z" and ArchiveScanner matches the suffix against
the configured list; SendJob.Kind/SendLogEntry.Kind became plain
strings; SettingsForm gained a template list (+/- buttons, editor
panel) replacing the two static tabs. ssh_access/config_update are now
just the two default entries in that list, not special-cased.

Handles the two backward-compat edges that matter for a tool already
in real use: old config.json's object-shaped "templates" migrates to
the new list shape transparently on first load (DataContractJsonSerializer
turned out to silently produce an empty list rather than throw on the
old shape, so detection is structural, not exception-based), and old
send-log.csv rows with PascalCase Kind values (SshAccess/ConfigUpdate)
still dedupe correctly against the new snake_case suffixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 11:26:31 +03:00

154 lines
7.0 KiB
C#

using System.Linq;
using System.Reflection;
using BulkArchiveMailer.Core;
using Xunit;
namespace BulkArchiveMailer.Core.Tests
{
/// <summary>
/// Covers the one AppSettingsData behavior that's easy to silently
/// break when adding a field: loading a config.json written before
/// FromAccountSmtpAddress existed must not throw, and a non-empty
/// value must round-trip once the operator picks a "from" account in
/// SettingsForm.
///
/// Note the old-JSON case asserts null, not "": DataContractJsonSerializer
/// builds the target via FormatterServices.GetUninitializedObject,
/// which skips this property's "" initializer entirely for any member
/// absent from the source JSON, leaving the CLR default (null) - see
/// the long comment on AppSettingsData.FromAccountSmtpAddress. Every
/// call site (MailSender, SettingsForm) already treats null the same
/// as "" for this reason.
///
/// Goes through AppSettings' private Serialize/Deserialize via
/// reflection rather than Load()/Save(), so this test never touches
/// the real %APPDATA%\BulkArchiveMailer\config.json on the machine
/// running it.
/// </summary>
public class AppSettingsTests
{
[Fact]
public void Deserialize_OldJsonWithoutFromAccountField_DoesNotThrowAndTreatsAsUnset()
{
const string oldJson = @"{
""archiveFolder"": ""C:\\archives"",
""emailDomain"": ""redlab.lo"",
""testModeDefault"": true,
""signatureHtmlPath"": """"
}";
var settings = InvokeDeserialize(oldJson);
Assert.True(string.IsNullOrEmpty(settings.FromAccountSmtpAddress));
Assert.Equal("redlab.lo", settings.EmailDomain);
}
[Fact]
public void SerializeThenDeserialize_RoundTripsFromAccountSmtpAddress()
{
var data = new AppSettingsData
{
ArchiveFolder = "C:\\archives",
EmailDomain = "redlab.lo",
FromAccountSmtpAddress = "svc-mailer@redlab.lo",
};
var json = InvokeSerialize(data);
var roundTripped = InvokeDeserialize(json);
Assert.Equal("svc-mailer@redlab.lo", roundTripped.FromAccountSmtpAddress);
}
/// <summary>
/// The riskiest part of the templates-list migration: a real
/// config.json saved by v1.0.0-v1.0.4 has "templates" as a JSON
/// OBJECT with "ssh_access"/"config_update" keys, not the array
/// AppSettingsData.Templates (List&lt;MailTemplateData&gt;) expects
/// now. Deserialize must transparently fall back to parsing that
/// old shape and convert it into two list entries with the right
/// FileSuffix - never throw, and never silently drop the
/// operator's saved Subject/BodyHtml text.
/// </summary>
[Fact]
public void Deserialize_LegacyObjectShapedTemplates_MigratesToTemplateList()
{
const string legacyJson = @"{
""archiveFolder"": ""C:\\archives"",
""emailDomain"": ""redlab.lo"",
""testModeDefault"": true,
""signatureHtmlPath"": """",
""fromAccountSmtpAddress"": """",
""templates"": {
""ssh_access"": {
""subject"": ""Legacy SSH subject {Login}"",
""bodyHtml"": ""<p>legacy ssh body</p>""
},
""config_update"": {
""subject"": ""Legacy config subject {Login}"",
""bodyHtml"": ""<p>legacy config body</p>""
}
}
}";
var migrated = InvokeDeserialize(legacyJson);
Assert.Equal("C:\\archives", migrated.ArchiveFolder);
Assert.Equal("redlab.lo", migrated.EmailDomain);
Assert.NotNull(migrated.Templates);
Assert.Equal(2, migrated.Templates.Count);
var ssh = migrated.Templates.Single(t => t.FileSuffix == "ssh_access");
Assert.Equal("Legacy SSH subject {Login}", ssh.Subject);
Assert.Equal("<p>legacy ssh body</p>", ssh.BodyHtml);
Assert.False(string.IsNullOrEmpty(ssh.DisplayName));
var cfg = migrated.Templates.Single(t => t.FileSuffix == "config_update");
Assert.Equal("Legacy config subject {Login}", cfg.Subject);
Assert.Equal("<p>legacy config body</p>", cfg.BodyHtml);
Assert.False(string.IsNullOrEmpty(cfg.DisplayName));
// And the converted result must itself be usable to build the
// Core template list AddIn code consumes (ArchiveScanner.Scan's
// "templates" parameter).
var templateList = migrated.ToTemplateList();
Assert.Contains(templateList, t => t.FileSuffix == "ssh_access" && t.Subject == "Legacy SSH subject {Login}");
Assert.Contains(templateList, t => t.FileSuffix == "config_update" && t.Subject == "Legacy config subject {Login}");
}
[Fact]
public void Deserialize_GenuinelyMalformedJson_ThrowsRatherThanSilentlyMigrating()
{
// Syntactically broken JSON (unterminated object) - not just a
// structural/shape mismatch (DataContractJsonSerializer turns
// out to be lenient about those, see the long comment on
// AppSettings.Deserialize) but text that isn't valid JSON at
// all. The legacy-shape detection must not treat this as
// legacy (IsLegacyTemplatesShape swallows the parse failure and
// returns false), and the normal parse attempt that runs next
// must still throw rather than silently producing a
// half-initialized settings object.
const string trulyMalformedJson = "{ \"archiveFolder\": \"C:\\\\archives\", \"templates\": [ { ";
// InvokeDeserialize goes through MethodInfo.Invoke (private
// static Deserialize, same as every other test in this file),
// which wraps any exception thrown by the invoked method in a
// TargetInvocationException - unwrap it to assert on the real
// exception type Deserialize itself throws.
var thrown = Assert.Throws<TargetInvocationException>(() => InvokeDeserialize(trulyMalformedJson));
Assert.NotNull(thrown.InnerException);
}
private static AppSettingsData InvokeDeserialize(string json)
{
var method = typeof(AppSettings).GetMethod("Deserialize", BindingFlags.NonPublic | BindingFlags.Static);
return (AppSettingsData)method.Invoke(null, new object[] { json });
}
private static string InvokeSerialize(AppSettingsData data)
{
var method = typeof(AppSettings).GetMethod("Serialize", BindingFlags.NonPublic | BindingFlags.Static);
return (string)method.Invoke(null, new object[] { data });
}
}
}