using System.Linq;
using System.Reflection;
using BulkArchiveMailer.Core;
using Xunit;
namespace BulkArchiveMailer.Core.Tests
{
///
/// 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.
///
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);
}
///
/// 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<MailTemplateData>) 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.
///
[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"": ""
legacy ssh body
""
},
""config_update"": {
""subject"": ""Legacy config subject {Login}"",
""bodyHtml"": ""legacy config body
""
}
}
}";
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("legacy ssh body
", 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("legacy config body
", 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(() => 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 });
}
}
}