From d707fce8a327b3df7e89fe61a5a26e2cfe861a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20G=C3=B6pel?= Date: Sun, 26 Jul 2026 11:12:16 +0800 Subject: [PATCH] feat: use Result in email settings save path instead of throwing --- .../packages.lock.json | 3 +- ...dreGoepel.AppFoundation.MailService.csproj | 4 ++ .../IEmailSettingsStore.cs | 7 +++- .../MartenEmailSettingsStore.cs | 7 +++- .../packages.lock.json | 3 ++ .../Pages/EmailSettingsPage.razor | 36 +++++++++-------- .../packages.lock.json | 1 + .../MartenEmailSettingsStore.Tests.cs | 39 +++++++++++-------- .../packages.lock.json | 4 ++ .../Administration/EmailSettingsPage.Tests.cs | 31 ++++++++++++++- .../packages.lock.json | 5 ++- 11 files changed, 99 insertions(+), 41 deletions(-) diff --git a/src/AndreGoepel.AppFoundation.Hosting/packages.lock.json b/src/AndreGoepel.AppFoundation.Hosting/packages.lock.json index 697ea8b..9e449e9 100644 --- a/src/AndreGoepel.AppFoundation.Hosting/packages.lock.json +++ b/src/AndreGoepel.AppFoundation.Hosting/packages.lock.json @@ -588,7 +588,7 @@ "andregoepel.appfoundation": { "type": "Project", "dependencies": { - "AndreGoepel.AppFoundation.Core": "[1.8.0, )", + "AndreGoepel.AppFoundation.Core": "[1.9.0, )", "AndreGoepel.AppFoundation.MailService": "[1.8.0, )", "AndreGoepel.Design.Blazor": "[1.4.2, )", "AndreGoepel.Marten.Identity.Blazor": "[1.8.0, )", @@ -603,6 +603,7 @@ "andregoepel.appfoundation.mailservice": { "type": "Project", "dependencies": { + "AndreGoepel.AppFoundation.Core": "[1.9.0, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "MailKit": "[4.17.0, )", "Marten": "[9.19.0, )", diff --git a/src/AndreGoepel.AppFoundation.MailService/AndreGoepel.AppFoundation.MailService.csproj b/src/AndreGoepel.AppFoundation.MailService/AndreGoepel.AppFoundation.MailService.csproj index 917177f..67c18bd 100644 --- a/src/AndreGoepel.AppFoundation.MailService/AndreGoepel.AppFoundation.MailService.csproj +++ b/src/AndreGoepel.AppFoundation.MailService/AndreGoepel.AppFoundation.MailService.csproj @@ -26,6 +26,10 @@ + + + + diff --git a/src/AndreGoepel.AppFoundation.MailService/IEmailSettingsStore.cs b/src/AndreGoepel.AppFoundation.MailService/IEmailSettingsStore.cs index ef80687..52ef676 100644 --- a/src/AndreGoepel.AppFoundation.MailService/IEmailSettingsStore.cs +++ b/src/AndreGoepel.AppFoundation.MailService/IEmailSettingsStore.cs @@ -1,3 +1,5 @@ +using AndreGoepel.AppFoundation.Core; + namespace AndreGoepel.AppFoundation.MailService; /// @@ -15,9 +17,10 @@ public interface IEmailSettingsStore /// /// Persists the settings. replaces the stored - /// SMTP password; pass null or empty to keep the current one. + /// SMTP password; pass null or empty to keep the current one. Fails when + /// no password is stored yet and none is supplied. /// - Task SaveAsync( + Task SaveAsync( EmailSettings settings, string? newPassword, CancellationToken cancellationToken = default diff --git a/src/AndreGoepel.AppFoundation.MailService/MartenEmailSettingsStore.cs b/src/AndreGoepel.AppFoundation.MailService/MartenEmailSettingsStore.cs index e5512f1..334a54e 100644 --- a/src/AndreGoepel.AppFoundation.MailService/MartenEmailSettingsStore.cs +++ b/src/AndreGoepel.AppFoundation.MailService/MartenEmailSettingsStore.cs @@ -1,3 +1,4 @@ +using AndreGoepel.AppFoundation.Core; using AndreGoepel.Marten.Configuration; using Microsoft.AspNetCore.DataProtection; @@ -28,7 +29,7 @@ public async Task LoadAsync(CancellationToken cancellationToken = : new EmailSettings(); } - public async Task SaveAsync( + public async Task SaveAsync( EmailSettings settings, string? newPassword, CancellationToken cancellationToken = default @@ -48,7 +49,7 @@ public async Task SaveAsync( } else { - throw new InvalidOperationException("An SMTP password is required for the first save."); + return Result.Fail("An SMTP password is required for the first save."); } await store.SaveAsync( @@ -65,5 +66,7 @@ await store.SaveAsync( }, cancellationToken ); + + return Result.Ok(); } } diff --git a/src/AndreGoepel.AppFoundation.MailService/packages.lock.json b/src/AndreGoepel.AppFoundation.MailService/packages.lock.json index 4e5e955..6b6ce3a 100644 --- a/src/AndreGoepel.AppFoundation.MailService/packages.lock.json +++ b/src/AndreGoepel.AppFoundation.MailService/packages.lock.json @@ -225,6 +225,9 @@ "WolverineFx": "6.22.0" } }, + "andregoepel.appfoundation.core": { + "type": "Project" + }, "Npgsql": { "type": "CentralTransitive", "requested": "[9.0.4, )", diff --git a/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor b/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor index 67bb141..4cd9874 100644 --- a/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor +++ b/src/AndreGoepel.AppFoundation/Components/Administration/Pages/EmailSettingsPage.razor @@ -3,6 +3,7 @@ @attribute [Authorize(Roles = "Administrator")] @inherits AndreGoepel.AppFoundation.Components.LocalizedComponentBase +@using AndreGoepel.AppFoundation.Core @using AndreGoepel.AppFoundation.MailService @inject IEmailSettingsStore SettingsStore @@ -145,7 +146,7 @@ isSaving = true; try { - await SettingsStore.SaveAsync( + var result = await SettingsStore.SaveAsync( new EmailSettings { SenderName = model.SenderName, @@ -159,21 +160,24 @@ string.IsNullOrEmpty(model.Password) ? null : model.Password ); - hasPassword = true; - Input.Password = ""; - NotificationService.Notify( - NotificationSeverity.Success, - T("EmailSettings.SavedNotificationTitle"), - T("EmailSettings.SavedNotificationMessage") - ); - } - catch (Exception ex) - { - NotificationService.Notify( - NotificationSeverity.Error, - T("EmailSettings.ErrorSavingSettingsTitle"), - ex.Message - ); + if (result.IsSuccess) + { + hasPassword = true; + Input.Password = ""; + NotificationService.Notify( + NotificationSeverity.Success, + T("EmailSettings.SavedNotificationTitle"), + T("EmailSettings.SavedNotificationMessage") + ); + } + else + { + NotificationService.Notify( + NotificationSeverity.Error, + T("EmailSettings.ErrorSavingSettingsTitle"), + result.Error + ); + } } finally { diff --git a/src/AndreGoepel.AppFoundation/packages.lock.json b/src/AndreGoepel.AppFoundation/packages.lock.json index b974720..a301498 100644 --- a/src/AndreGoepel.AppFoundation/packages.lock.json +++ b/src/AndreGoepel.AppFoundation/packages.lock.json @@ -273,6 +273,7 @@ "andregoepel.appfoundation.mailservice": { "type": "Project", "dependencies": { + "AndreGoepel.AppFoundation.Core": "[1.9.0, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "MailKit": "[4.17.0, )", "Marten": "[9.19.0, )", diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs b/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs index 28a180f..adbaaa6 100644 --- a/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs +++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/MartenEmailSettingsStore.Tests.cs @@ -1,3 +1,4 @@ +using AndreGoepel.AppFoundation.Core; using AndreGoepel.Marten.Configuration; using Microsoft.AspNetCore.DataProtection; using NSubstitute; @@ -63,7 +64,7 @@ public async Task SaveAsync_WithNewPassword_StoresProtectedPassword() ); // Act - await emailStore.SaveAsync( + var result = await emailStore.SaveAsync( new EmailSettings { SenderName = "S", @@ -75,6 +76,7 @@ await emailStore.SaveAsync( ); // Assert + Assert.True(result.IsSuccess); Assert.NotNull(stored); Assert.NotEqual("new-secret", stored.ProtectedPassword); Assert.Equal( @@ -101,7 +103,7 @@ public async Task SaveAsync_WithoutPassword_KeepsExistingProtectedPassword() ); // Act - await BuildStore() + var result = await BuildStore() .SaveAsync( new EmailSettings { @@ -114,26 +116,29 @@ await BuildStore() ); // Assert + Assert.True(result.IsSuccess); Assert.NotNull(stored); Assert.Equal("protected", stored.ProtectedPassword); } [Fact] - public async Task SaveAsync_FirstSaveWithoutAnyPassword_Throws() + public async Task SaveAsync_FirstSaveWithoutAnyPassword_ReturnsFailure() { - // Act + Assert - await Assert.ThrowsAsync(() => - BuildStore() - .SaveAsync( - new EmailSettings - { - SenderName = "S", - SenderEmail = "s@example.com", - Server = "smtp", - Username = "u", - }, - newPassword: null - ) - ); + // Act + var result = await BuildStore() + .SaveAsync( + new EmailSettings + { + SenderName = "S", + SenderEmail = "s@example.com", + Server = "smtp", + Username = "u", + }, + newPassword: null + ); + + // Assert + Assert.True(result.IsFailure); + Assert.Equal("An SMTP password is required for the first save.", result.Error); } } diff --git a/tests/AndreGoepel.AppFoundation.MailService.Tests/packages.lock.json b/tests/AndreGoepel.AppFoundation.MailService.Tests/packages.lock.json index 91c8fbf..200909a 100644 --- a/tests/AndreGoepel.AppFoundation.MailService.Tests/packages.lock.json +++ b/tests/AndreGoepel.AppFoundation.MailService.Tests/packages.lock.json @@ -359,9 +359,13 @@ "xunit.v3.runner.common": "[3.2.2]" } }, + "andregoepel.appfoundation.core": { + "type": "Project" + }, "andregoepel.appfoundation.mailservice": { "type": "Project", "dependencies": { + "AndreGoepel.AppFoundation.Core": "[1.9.0, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "MailKit": "[4.17.0, )", "Marten": "[9.19.0, )", diff --git a/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs b/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs index 8bec6a2..4fc6b11 100644 --- a/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs +++ b/tests/AndreGoepel.AppFoundation.Tests/Components/Administration/EmailSettingsPage.Tests.cs @@ -1,4 +1,5 @@ using AndreGoepel.AppFoundation.Components.Administration.Pages; +using AndreGoepel.AppFoundation.Core; using AndreGoepel.AppFoundation.MailService; using Bunit; using Microsoft.AspNetCore.Authorization; @@ -13,13 +14,14 @@ public sealed class EmailSettingsPageTests : BunitContext { private readonly IEmailSettingsStore store = Substitute.For(); private readonly IEmailSender emailSender = Substitute.For(); + private readonly NotificationService notificationService = new(); public EmailSettingsPageTests() { JSInterop.Mode = JSRuntimeMode.Loose; Services.AddSingleton(store); Services.AddSingleton(emailSender); - Services.AddSingleton(new NotificationService()); + Services.AddSingleton(notificationService); } private static EmailSettings Settings() => @@ -55,6 +57,9 @@ public void Submit_WithValidInput_SavesWithoutPasswordChange() { // Arrange store.LoadAsync(Arg.Any()).Returns(Settings()); + store + .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Ok()); var cut = Render(); // Act @@ -74,6 +79,30 @@ public void Submit_WithValidInput_SavesWithoutPasswordChange() ); } + [Fact] + public void Submit_StoreReturnsFailure_ShowsErrorNotificationWithResultMessage() + { + // Arrange + store.LoadAsync(Arg.Any()).Returns(Settings()); + store + .SaveAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Fail("An SMTP password is required for the first save.")); + var cut = Render(); + + // Act + cut.Find("form").Submit(); + + // Assert + cut.WaitForAssertion(() => + Assert.Contains( + notificationService.Messages, + message => + message.Severity == NotificationSeverity.Error + && message.Detail == "An SMTP password is required for the first save." + ) + ); + } + [Fact] public void Route_IsAdministrationEmailSettings_AndRequiresAdministratorRole() { diff --git a/tests/AndreGoepel.AppFoundation.Tests/packages.lock.json b/tests/AndreGoepel.AppFoundation.Tests/packages.lock.json index c0a866a..b549563 100644 --- a/tests/AndreGoepel.AppFoundation.Tests/packages.lock.json +++ b/tests/AndreGoepel.AppFoundation.Tests/packages.lock.json @@ -728,7 +728,7 @@ "andregoepel.appfoundation": { "type": "Project", "dependencies": { - "AndreGoepel.AppFoundation.Core": "[1.8.0, )", + "AndreGoepel.AppFoundation.Core": "[1.9.0, )", "AndreGoepel.AppFoundation.MailService": "[1.8.0, )", "AndreGoepel.Design.Blazor": "[1.4.2, )", "AndreGoepel.Marten.Identity.Blazor": "[1.8.0, )", @@ -744,7 +744,7 @@ "type": "Project", "dependencies": { "AndreGoepel.AppFoundation": "[1.8.0, )", - "AndreGoepel.AppFoundation.Core": "[1.8.0, )", + "AndreGoepel.AppFoundation.Core": "[1.9.0, )", "AndreGoepel.AppFoundation.MailService": "[1.8.0, )", "AndreGoepel.AppFoundation.ServiceDefaults": "[1.8.0, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", @@ -762,6 +762,7 @@ "andregoepel.appfoundation.mailservice": { "type": "Project", "dependencies": { + "AndreGoepel.AppFoundation.Core": "[1.9.0, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "MailKit": "[4.17.0, )", "Marten": "[9.19.0, )",