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