feat: Implement admin-only specialties management interface
Playwright Tests / test (push) Canceled after 0s
Playwright Tests / test (push) Canceled after 0s
- Added MailerAttachmentPolicy for validating email attachments against server rules. - Introduced fis.specialties.js for managing hidden maintenance actions via an admin interface. - Created fis.specialties.scss for styling the specialties management UI. - Added German localization for specialties management in fis.specialties_txt_de.js. - Developed CSS for specialties management in fis.specialties.css. - Implemented server-side stored procedure fds__admin_backupAndClearInvoiceFile for backing up and clearing invoice files. - Created fds__invoice_file_backups table to store backup records of invoice files.
This commit is contained in:
@@ -172,4 +172,147 @@ public class AzureBlobStorageServiceTests
|
||||
Assert.Null(result);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.uploads.failed counter should have been incremented on a failed upload.");
|
||||
}
|
||||
|
||||
// ── CopyInvoicePdfToBackupAsync ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_NoClient_ReturnsNullWithoutThrowing()
|
||||
{
|
||||
var svc = new AzureBlobStorageService(client: null, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_NoExistingBlob_ReturnsNullWithoutCopying()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(false, Mock.Of<Response>()));
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
blob.Verify(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
blob.Verify(b => b.DeleteIfExistsAsync(
|
||||
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source blob exists; the backup blob accepts a server-side copy whose operation completes
|
||||
/// immediately. Returns the mocks needed for verification.
|
||||
/// </summary>
|
||||
private static (Mock<BlobServiceClient> service, Mock<BlobClient> sourceBlob, Mock<BlobClient> backupBlob, Mock<CopyFromUriOperation> copy)
|
||||
CreateCopyChain(Uri sourceUri, Uri backupUri)
|
||||
{
|
||||
var (service, container, sourceBlob) = CreateMockedClientChain();
|
||||
sourceBlob.Setup(b => b.Uri).Returns(sourceUri);
|
||||
sourceBlob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(true, Mock.Of<Response>()));
|
||||
|
||||
var copy = new Mock<CopyFromUriOperation>();
|
||||
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(3L, Mock.Of<Response>()));
|
||||
|
||||
var backupBlob = new Mock<BlobClient>();
|
||||
backupBlob.Setup(b => b.Uri).Returns(backupUri);
|
||||
backupBlob.Setup(b => b.StartCopyFromUriAsync(It.IsAny<Uri>(), It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(copy.Object);
|
||||
container.Setup(c => c.GetBlobClient(It.Is<string>(n => n.StartsWith("INV1/backup_"))))
|
||||
.Returns(backupBlob.Object);
|
||||
|
||||
return (service, sourceBlob, backupBlob, copy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_ExistingBlob_CopiesServerSideUnderBackupNameAndKeepsOriginal()
|
||||
{
|
||||
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_20260917_120000_Rechnung_INV1.pdf");
|
||||
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
|
||||
|
||||
long succeeded = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref succeeded, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Equal(backupUri, result);
|
||||
// The bytes must never pass through the process: server-side copy from the source URI only.
|
||||
backupBlob.Verify(b => b.StartCopyFromUriAsync(sourceUri, It.IsAny<BlobCopyFromUriOptions>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
copy.Verify(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
sourceBlob.Verify(b => b.DownloadContentAsync(It.IsAny<CancellationToken>()), Times.Never);
|
||||
backupBlob.Verify(b => b.UploadAsync(It.IsAny<Stream>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
// The original stays: it remains the current archive until the next regeneration overwrites it.
|
||||
sourceBlob.Verify(b => b.DeleteIfExistsAsync(
|
||||
It.IsAny<DeleteSnapshotsOption>(), It.IsAny<BlobRequestConditions>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
Assert.True(succeeded >= 1, "fuchs.blobstorage.backupcopies counter should have been incremented.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_CopyFails_ReturnsNullAndIncrementsFailedCounter()
|
||||
{
|
||||
var sourceUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/Rechnung_INV1.pdf");
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/test-invoices/INV1/backup_x_Rechnung_INV1.pdf");
|
||||
var (service, sourceBlob, backupBlob, copy) = CreateCopyChain(sourceUri, backupUri);
|
||||
copy.Setup(c => c.WaitForCompletionAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException("copy aborted"));
|
||||
|
||||
long failed = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref failed, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV1", "Rechnung_INV1.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.True(failed >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopyInvoicePdfToBackupAsync_ClientThrows_ReturnsNullAndIncrementsFailedCounter()
|
||||
{
|
||||
var (service, container, blob) = CreateMockedClientChain();
|
||||
blob.Setup(b => b.ExistsAsync(It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException("simulated storage failure"));
|
||||
|
||||
long delta = 0;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.blobstorage.backupcopies.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = new AzureBlobStorageService(service.Object, CreateSettings(), NullLogger<AzureBlobStorageService>.Instance);
|
||||
Uri? result = await svc.CopyInvoicePdfToBackupAsync("INV2", "Rechnung_INV2.pdf");
|
||||
|
||||
Assert.Null(result);
|
||||
Assert.True(delta >= 1, "fuchs.blobstorage.backupcopies.failed counter should have been incremented.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using OCORE.web.pwmailer;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the Fuchs override of <see cref="PWMailerClientService.ValidateMessage"/>: the Mailer
|
||||
/// contract's attachment limits (count, per-file and per-message size, forbidden extensions) are
|
||||
/// enforced before any HTTP request, on top of the base rules (required fields, empty file,
|
||||
/// missing extension), and a compliant message still goes out. The transport is a scripted
|
||||
/// handler that fails the test if it is reached when it must not be.
|
||||
/// </summary>
|
||||
public class FuchsMailerClientTests
|
||||
{
|
||||
private sealed class ScriptedHandler : HttpMessageHandler
|
||||
{
|
||||
public int Calls;
|
||||
public string Body = "{\"comStackID\":\"11111111-2222-3333-4444-555555555555\",\"attachmentsReceived\":1,\"attachmentsProcessed\":1}";
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(Body) });
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Factory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public Factory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
private static (FuchsMailerClient client, ScriptedHandler handler) Make(MailerAttachmentLimits? limits = null)
|
||||
{
|
||||
var handler = new ScriptedHandler();
|
||||
var options = Options.Create(new PWMailerClientOptions
|
||||
{
|
||||
BaseUrl = "https://mailer.test/api/mailer", Account = "acct", Token = "tok",
|
||||
ServerId = "11111111-2222-3333-4444-555555555555", RetryDelayMilliseconds = 0
|
||||
});
|
||||
var settings = Options.Create(new ProcessWebComSettings { Attachments = limits ?? new MailerAttachmentLimits() });
|
||||
return (new FuchsMailerClient(options, settings, new Factory(handler), NullLogger<PWMailerClientService>.Instance), handler);
|
||||
}
|
||||
|
||||
private static PWMailerMessage Message(params PWMailerAttachment[] attachments)
|
||||
{
|
||||
var m = new PWMailerMessage { To = "a@example.com", Subject = "Hi", Body = "<p>x</p>", Reference = "R1" };
|
||||
m.Attachments.AddRange(attachments);
|
||||
return m;
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> LimitViolations()
|
||||
{
|
||||
yield return [new PWMailerAttachment[] { new("run.EXE", [1]) }, "forbidden extension (.exe)"];
|
||||
yield return [new PWMailerAttachment[] { new("script.js", [1]) }, "forbidden extension (.js)"];
|
||||
yield return [Enumerable.Range(1, 11).Select(i => new PWMailerAttachment($"f{i}.pdf", [1])).ToArray(), "Too many attachments: 11 (max 10)"];
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(LimitViolations))]
|
||||
public async Task PushAsync_AttachmentViolatesContractLimits_RefusedBeforeSending(PWMailerAttachment[] attachments, string expected)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(attachments));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(0, result.StatusCode);
|
||||
Assert.Equal(0, result.Attempts);
|
||||
Assert.Contains(expected, result.Error);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PushAsync_PerFileAndPerMessageSizeLimits_AreEnforcedFromSettings()
|
||||
{
|
||||
var (client, handler) = Make(new MailerAttachmentLimits { MaxFileBytes = 100, MaxMessageBytes = 150, MaxCount = 10 });
|
||||
|
||||
var tooBig = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[101])));
|
||||
Assert.Contains("'a.pdf' is 101 bytes (max 100)", tooBig.Error);
|
||||
|
||||
var total = await client.PushAsync(Message(new PWMailerAttachment("a.pdf", new byte[100]), new PWMailerAttachment("b.pdf", new byte[51])));
|
||||
Assert.Contains("total 151 bytes (max 150", total.Error);
|
||||
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("leer.pdf", new byte[0], "is empty")]
|
||||
[InlineData("noext", new byte[] { 1 }, "no file extension")]
|
||||
public async Task PushAsync_BaseContractRules_StillAppliedThroughOverride(string name, byte[] bytes, string expected)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(new PWMailerAttachment(name, bytes)));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains(expected, result.Error);
|
||||
Assert.Equal(0, handler.Calls);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Rechnung.pdf")]
|
||||
[InlineData("export.CSV")]
|
||||
[InlineData("archive.7z")]
|
||||
public async Task PushAsync_AllowedAttachment_IsSent(string name)
|
||||
{
|
||||
var (client, handler) = Make();
|
||||
|
||||
var result = await client.PushAsync(Message(new PWMailerAttachment(name, [1, 2])));
|
||||
|
||||
Assert.True(result.Success, result.Error);
|
||||
Assert.Equal(1, handler.Calls);
|
||||
Assert.Equal(1, result.Value!.AttachmentsProcessed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MailerAttachmentPolicy_NoAttachments_NoViolation()
|
||||
{
|
||||
Assert.Empty(MailerAttachmentPolicy.Validate(Array.Empty<PWMailerAttachment>(), new MailerAttachmentLimits()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using OCORE.security;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers <see cref="InvoiceFileBackupService.BackupAndClearAsync"/>: the invoice-not-found
|
||||
/// short-circuit, that a blob backup-rename failure never blocks the DB-side backup/clear (best
|
||||
/// effort per Docs/Decisions/0027), and that the DB call failing is reported without throwing.
|
||||
/// No SQL Server: the DB-clear path uses an unconfigured connection so the write fails fast inside
|
||||
/// the OCORE helper, mirroring InvoiceServiceStoreFileTests.
|
||||
/// </summary>
|
||||
public class InvoiceFileBackupServiceTests
|
||||
{
|
||||
private sealed class RecordingLogger : ILogger<InvoiceFileBackupService>
|
||||
{
|
||||
public sealed record Entry(LogLevel Level, Exception? Exception, string Message);
|
||||
public readonly List<Entry> Entries = new();
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add(new Entry(logLevel, exception, formatter(state, exception)));
|
||||
}
|
||||
|
||||
// A refused local port: the write fails inside the OCORE helper's own try/catch (returning an
|
||||
// Exception string) instead of hanging on a connect timeout.
|
||||
private const string UnreachableConnectionString =
|
||||
"Server=127.0.0.1,1;Database=fuchs;User Id=u;Password=p;Connect Timeout=1;TrustServerCertificate=True";
|
||||
|
||||
private static FdsInvoiceData Invoice(string id = "W7VAL", bool isFinal = true, bool isExternal = false, bool hasFile = true) => new()
|
||||
{
|
||||
InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = id,
|
||||
["InvoiceId"] = "R2026-0258",
|
||||
["DocumentName"] = $"Rechnung_{id}.pdf",
|
||||
["IsFinal"] = isFinal,
|
||||
["IsExternal"] = isExternal,
|
||||
["hasFile"] = hasFile,
|
||||
}),
|
||||
IsDraft = !isFinal,
|
||||
};
|
||||
|
||||
private static (InvoiceFileBackupService svc, RecordingLogger log, Mock<IInvoiceService> invoices, Mock<IBlobStorageService> blob)
|
||||
MakeService(FdsInvoiceData? invoice, string? connectionString = null)
|
||||
{
|
||||
var log = new RecordingLogger();
|
||||
var invoices = new Mock<IInvoiceService>();
|
||||
invoices.Setup(i => i.LoadInvoiceAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<DatabaseSecurity>()))
|
||||
.ReturnsAsync(invoice ?? new FdsInvoiceData());
|
||||
var blob = new Mock<IBlobStorageService>();
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:fuchs_fds_ConnectionString"] = connectionString
|
||||
}).Build();
|
||||
var svc = new InvoiceFileBackupService(new Fuchs_intranet(config), invoices.Object, blob.Object, log);
|
||||
return (svc, log, invoices, blob);
|
||||
}
|
||||
|
||||
// LoadInvoiceAsync never yields a null registration for an unknown id — it yields an empty
|
||||
// dictionary (FirstRow.toObjectDictionary()), i.e. an invoice whose Id is "". Both shapes must
|
||||
// be recognised as "not found" and never fall through to the IsDraft check as "not_final".
|
||||
public static IEnumerable<object[]> NotFoundShapes() =>
|
||||
[
|
||||
[ new FdsInvoiceData() ],
|
||||
[ new FdsInvoiceData { InvoiceRegistration = new OCORE.OCORE_dictionaries.GenericObjectDictionary(new Dictionary<string, object?>()) } ],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(NotFoundShapes))]
|
||||
public async Task BackupAndClearAsync_InvoiceNotFound_ReturnsFailureWithoutTouchingBlobOrDb(FdsInvoiceData loaded)
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(loaded);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("MISSING", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("invoice_not_found", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
var entry = Assert.Single(log.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains("not found", entry.Message);
|
||||
}
|
||||
|
||||
// ADR 0025's regeneration precondition is load-bearing: clearing a draft's file would let the
|
||||
// next read persist a draft render that then blocks the real final PDF forever; an external
|
||||
// (MFR) document can't be regenerated at all. Both must be refused before anything is touched.
|
||||
[Theory]
|
||||
[InlineData(false, false, "not_final")]
|
||||
[InlineData(true, true, "external")]
|
||||
[InlineData(false, true, "not_final")]
|
||||
public async Task BackupAndClearAsync_DraftOrExternalInvoice_RefusesWithoutTouchingBlobOrDb(
|
||||
bool isFinal, bool isExternal, string expectedReason)
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(isFinal: isFinal, isExternal: isExternal), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal(expectedReason, result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
var entry = Assert.Single(log.Entries);
|
||||
Assert.Equal(LogLevel.Warning, entry.Level);
|
||||
Assert.Contains("refused", entry.Message);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_NoStoredFile_ReturnsNoFileWithoutCopyingBlobOrTouchingDb()
|
||||
{
|
||||
// A repeated click on an already-cleared invoice must not leave another backup_* blob
|
||||
// behind: fds__getInvoice's hasFile settles it before the copy and before the proc.
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(hasFile: false), UnreachableConnectionString);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("no_file", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
Assert.DoesNotContain(log.Entries, e => e.Level == LogLevel.Error); // the proc was never called
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("no file stored"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_BlobCopySucceeds_DbFails_ReportsDbErrorWithBackupUri()
|
||||
{
|
||||
// The backup copy is taken BEFORE the DB clear (so no regeneration can overwrite the
|
||||
// original first) and the original blob is kept; a DB failure (here: no real SQL Server)
|
||||
// therefore leaves the archive intact plus a spare copy, which the result still reports.
|
||||
var backupUri = new Uri("https://test.blob.core.windows.net/invoices/W7VAL/backup_x.pdf");
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
|
||||
blob.Setup(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(backupUri);
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("db_error", result.Reason);
|
||||
Assert.Equal(backupUri, result.BlobBackupUri);
|
||||
blob.Verify(b => b.CopyInvoicePdfToBackupAsync("W7VAL", "Rechnung_W7VAL.pdf", It.IsAny<CancellationToken>()), Times.Once);
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupAndClearAsync_BlobCopyThrows_StillAttemptsDbClearAndReturnsDbOutcome()
|
||||
{
|
||||
var (svc, log, invoices, blob) = MakeService(Invoice(), UnreachableConnectionString);
|
||||
blob.Setup(b => b.CopyInvoicePdfToBackupAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("blob boom"));
|
||||
|
||||
var result = await svc.BackupAndClearAsync("W7VAL", "user1", new DatabaseSecurity("user1", null));
|
||||
|
||||
// The blob exception is swallowed and logged; the DB step still runs (and fails here for
|
||||
// lack of a SQL Server) rather than the exception escaping BackupAndClearAsync.
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("db_error", result.Reason);
|
||||
Assert.Null(result.BlobBackupUri);
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("blob backup-copy threw"));
|
||||
Assert.Contains(log.Entries, e => e.Level == LogLevel.Error && e.Message.Contains("fds__admin_backupAndClearInvoiceFile failed"));
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,73 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using OCORE.web.pwmailer;
|
||||
using Xunit;
|
||||
|
||||
namespace Fuchs.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the outbound communication service. Cover both intentionally
|
||||
/// succeeding (API 200) and intentionally failing (invalid email, disabled,
|
||||
/// API 500) paths, plus the attachment payload contract and metric emission.
|
||||
/// Tests for the outbound communication service as an adapter over the OCORE_web
|
||||
/// <see cref="IPWMailerClientService"/> (ADR 0029). Cover both intentionally succeeding and
|
||||
/// intentionally failing paths (invalid email, disabled, API error, duplicate, refused by
|
||||
/// pre-validation, attachment count not confirmed), the <see cref="PWMailerMessage"/> handed to
|
||||
/// the client, the audit log written on every path, and metric emission. The HTTP contract itself
|
||||
/// (JSON vs multipart, unnamed envelope field, attachmentCount) is the client's responsibility and
|
||||
/// is tested in OCORE_webTests/comm/PWMailerClientServiceTests.cs.
|
||||
///
|
||||
/// Fuchs_intranet is passed as null: it is only touched by the audit-log write,
|
||||
/// which is wrapped in try/catch and therefore harmless in a unit test.
|
||||
/// </summary>
|
||||
public class ProcessWebComServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
|
||||
// ── Test doubles ────────────────────────────────────────────────────────
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public string? LastRequestBody { get; private set; }
|
||||
public int CallCount { get; private set; }
|
||||
private static PWMailerResult<PWMailerPushReceipt> Accepted(int received = 0, int processed = 0, string comStackId = "cs-1") =>
|
||||
new() { StatusCode = 200, Value = new PWMailerPushReceipt(comStackId, received, processed), Attempts = 1, Operation = "push_com" };
|
||||
|
||||
public StubHandler(HttpStatusCode status, string body = "OK")
|
||||
private static PWMailerResult<PWMailerPushReceipt> Failed(int status, string error) =>
|
||||
new() { StatusCode = status, Error = error, Attempts = 1, Operation = "push_com" };
|
||||
|
||||
/// <summary>The client refused the call before sending (validation, options, token): status 0, no attempt.</summary>
|
||||
private static PWMailerResult<PWMailerPushReceipt> Refused(string error) =>
|
||||
new() { StatusCode = 0, Error = error, Attempts = 0, Operation = "push_com", Exception = new ArgumentException(error) };
|
||||
|
||||
private sealed class MailerStub
|
||||
{
|
||||
public readonly Mock<IPWMailerClientService> Mock = new(MockBehavior.Loose);
|
||||
public readonly List<PWMailerMessage> Pushed = new();
|
||||
public int CallCount => Pushed.Count;
|
||||
|
||||
public MailerStub(PWMailerResult<PWMailerPushReceipt>? push = null)
|
||||
{
|
||||
_status = status;
|
||||
_body = body;
|
||||
var result = push ?? Accepted();
|
||||
Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<PWMailerMessage, CancellationToken>((m, _) => Pushed.Add(m))
|
||||
.ReturnsAsync(result);
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
/// <summary>Answers like a Mailer that processed every file it received.</summary>
|
||||
public static MailerStub Echoing()
|
||||
{
|
||||
CallCount++;
|
||||
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
|
||||
return new HttpResponseMessage(_status) { Content = new StringContent(_body) };
|
||||
var stub = new MailerStub();
|
||||
stub.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<PWMailerMessage, CancellationToken>((m, _) => stub.Pushed.Add(m))
|
||||
.ReturnsAsync((PWMailerMessage m, CancellationToken _) => Accepted(m.Attachments.Count, m.Attachments.Count));
|
||||
return stub;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ThrowingHandler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> throw new HttpRequestException("simulated transport failure");
|
||||
}
|
||||
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly HttpMessageHandler _handler;
|
||||
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
|
||||
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures what would be written to <c>fds__logEmail</c> without hitting a database, by
|
||||
/// overriding the audit-log write. Lets tests assert that every send path is logged and that
|
||||
@@ -71,146 +77,128 @@ public class ProcessWebComServiceTests
|
||||
{
|
||||
public readonly List<(bool success, List<string> log)> Entries = new();
|
||||
public readonly List<string> Configs = new();
|
||||
public readonly List<string> Guids = new();
|
||||
|
||||
public CapturingComService(
|
||||
IOptions<ProcessWebComSettings> settings,
|
||||
IOptions<FuchsEmailSettings> emailSettings,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, httpClientFactory)
|
||||
{
|
||||
}
|
||||
public CapturingComService(IOptions<ProcessWebComSettings> settings, IOptions<FuchsEmailSettings> emailSettings,
|
||||
IPWMailerClientService mailer)
|
||||
: base(NullLogger<ProcessWebComService>.Instance, intranet: null!, settings, emailSettings, mailer) { }
|
||||
|
||||
protected override Task WriteAuditLogAsync(string reference, string guid, string config,
|
||||
DateTime sent, bool success, IEnumerable<string> errors)
|
||||
{
|
||||
Entries.Add((success, errors.ToList()));
|
||||
Configs.Add(config);
|
||||
Guids.Add(guid);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private static CapturingComService CreateCapturing(HttpMessageHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
private static IOptions<ProcessWebComSettings> Settings(bool enabled = true, string accountId = "acct", string token = "tok", string serverId = "") =>
|
||||
Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok"
|
||||
Enabled = enabled, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token, ServerId = serverId
|
||||
});
|
||||
var emailSettings = Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient });
|
||||
return new CapturingComService(settings, emailSettings, new StubHttpClientFactory(handler));
|
||||
}
|
||||
|
||||
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true, string? overrideRecipient = null)
|
||||
{
|
||||
var settings = Options.Create(new ProcessWebComSettings
|
||||
{
|
||||
Enabled = enabled,
|
||||
BaseUrl = "https://mailer.test",
|
||||
AccountId = "acct",
|
||||
Token = "tok"
|
||||
});
|
||||
var emailSettings = Options.Create(new FuchsEmailSettings
|
||||
{
|
||||
OverrideRecipient = overrideRecipient
|
||||
});
|
||||
return new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance,
|
||||
intranet: null!,
|
||||
settings,
|
||||
emailSettings,
|
||||
new StubHttpClientFactory(handler));
|
||||
}
|
||||
private static CapturingComService CreateCapturing(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
|
||||
new(Settings(enabled), Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
|
||||
|
||||
private static ProcessWebComService CreateService(MailerStub mailer, bool enabled = true, string? overrideRecipient = null) =>
|
||||
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(enabled),
|
||||
Options.Create(new FuchsEmailSettings { OverrideRecipient = overrideRecipient }), mailer.Mock.Object);
|
||||
|
||||
// ── Success path ─────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce()
|
||||
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPushesOnce()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(1, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Failure paths ──────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_ReturnsFalse()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub(Failed(500, "boom"));
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
Assert.Equal(1, mailer.CallCount);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-email")]
|
||||
[InlineData("")]
|
||||
[InlineData("missing@")]
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail)
|
||||
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingMailer(string badEmail)
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_3", "Subject", "<p>hi</p>", badEmail, "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount); // never reached the API
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi()
|
||||
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingMailer()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, enabled: false);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, enabled: false);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_4", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Audit logging: every attempt is logged; failures carry the service response ─
|
||||
// ── Audit logging: every attempt is logged; failures carry the client's error ─
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Success_LogsSuccessfulAttempt()
|
||||
public async Task SendEmailAsync_Success_LogsSuccessfulAttemptWithComStackId()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateCapturing(new MailerStub(Accepted(comStackId: "cs-42")));
|
||||
|
||||
await svc.SendEmailAsync("inv_log_ok", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
Assert.Equal("cs-42", Assert.Single(svc.Guids)); // what getComStatus/getCom accept
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingServiceResponse()
|
||||
public async Task SendEmailAsync_ApiError_LogsFailureIncludingStatusAndError()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.InternalServerError, "mailer rejected: quota exceeded"));
|
||||
var svc = CreateCapturing(new MailerStub(Failed(500, "mailer rejected: quota exceeded")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_api", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded")); // raw service response
|
||||
Assert.Contains(entry.log, l => l.Contains("500")); // HTTP status
|
||||
Assert.Contains(entry.log, l => l.Contains("mailer rejected: quota exceeded") && l.Contains("500"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_TransportException_LogsFailureIncludingExceptionDetail()
|
||||
public async Task SendEmailAsync_ClientThrows_LogsFailureIncludingExceptionDetail()
|
||||
{
|
||||
var svc = CreateCapturing(new ThrowingHandler());
|
||||
var mailer = new MailerStub();
|
||||
mailer.Mock.Setup(m => m.PushAsync(It.IsAny<PWMailerMessage>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("simulated client failure"));
|
||||
var svc = CreateCapturing(mailer);
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_ex", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending"));
|
||||
Assert.Contains(entry.log, l => l.Contains("simulated transport failure"));
|
||||
Assert.Contains(entry.log, l => l.Contains("Exception while sending") && l.Contains("simulated client failure"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -218,7 +206,7 @@ public class ProcessWebComServiceTests
|
||||
[InlineData("")]
|
||||
public async Task SendEmailAsync_InvalidEmail_StillLogsFailedAttempt(string badEmail)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateCapturing(new MailerStub());
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_log_bad", "S", "<p>x</p>", badEmail, "Kunde");
|
||||
|
||||
@@ -231,7 +219,7 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Disabled_LogsFailedAttempt()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), enabled: false);
|
||||
var svc = CreateCapturing(new MailerStub(), enabled: false);
|
||||
|
||||
await svc.SendEmailAsync("inv_log_dis", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
@@ -317,18 +305,19 @@ public class ProcessWebComServiceTests
|
||||
|
||||
public static IEnumerable<object[]> AllAuditPaths() =>
|
||||
[
|
||||
[ "success", HttpStatusCode.OK, true, "kunde@example.de" ],
|
||||
[ "api-error", HttpStatusCode.InternalServerError, true, "kunde@example.de" ],
|
||||
[ "disabled", HttpStatusCode.OK, false, "kunde@example.de" ],
|
||||
[ "invalid-email", HttpStatusCode.OK, true, "not-an-email" ],
|
||||
[ "success", 200, true, "kunde@example.de" ],
|
||||
[ "api-error", 500, true, "kunde@example.de" ],
|
||||
[ "disabled", 200, false, "kunde@example.de" ],
|
||||
[ "invalid-email", 200, true, "not-an-email" ],
|
||||
];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllAuditPaths))]
|
||||
public async Task SendEmailAsync_EveryAuditPath_LogsParsableConfigWithRecipientSubjectAndAttachments(
|
||||
string path, HttpStatusCode status, bool enabled, string email)
|
||||
string path, int status, bool enabled, string email)
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(status), enabled: enabled);
|
||||
var mailer = status == 200 ? MailerStub.Echoing() : new MailerStub(Failed(status, "err"));
|
||||
var svc = CreateCapturing(mailer, enabled: enabled);
|
||||
var att = new Dictionary<string, byte[]> { ["Mahnung.pdf"] = [9, 9] };
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_" + path, "Subj " + path, "<p>x</p>", email, "Kunde", att);
|
||||
@@ -343,7 +332,7 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_ConfigRecordsActualRecipientAndPrefixedSubject()
|
||||
{
|
||||
var svc = CreateCapturing(new StubHandler(HttpStatusCode.OK), overrideRecipient: "dev-inbox@example.test");
|
||||
var svc = CreateCapturing(new MailerStub(), overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_cfg_ovr", "Subject", "<p>x</p>", "kunde@example.de", "Kunde", null, cc: "cc@example.de");
|
||||
|
||||
@@ -357,27 +346,24 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_RedirectsToOverrideAddress()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_ov1", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_SubjectRetainsOriginalRecipientForTraceability()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_ov2", "Rechnung 123", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string subject = json["communication"]!["subject"]!.ToString();
|
||||
string subject = Assert.Single(mailer.Pushed).Subject;
|
||||
Assert.Contains("realcustomer@example.de", subject);
|
||||
Assert.Contains("Rechnung 123", subject);
|
||||
}
|
||||
@@ -385,84 +371,69 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_InvalidOriginalAddressIsStillRedirected()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_ov3", "Subject", "<p>hi</p>", "not-an-email", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("dev-inbox@example.test", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_NoOverrideConfigured_SendsToOriginalRecipient()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_ov4", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("realcustomer@example.de", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal("realcustomer@example.de", Assert.Single(mailer.Pushed).To);
|
||||
}
|
||||
|
||||
// ── Cc recipient ("Zusatzempfänger") ─────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_CcProvided_IsIncludedInPayload()
|
||||
public async Task SendEmailAsync_CcProvided_IsIncludedInMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde",
|
||||
cc: "zusatz@example.de");
|
||||
await svc.SendEmailAsync("inv_cc1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", cc: "zusatz@example.de");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("zusatz@example.de", json["communication"]!["cc"]!.ToString());
|
||||
Assert.Equal("zusatz@example.de", Assert.Single(mailer.Pushed).Cc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_CcOmitted_IsNullInPayload()
|
||||
public async Task SendEmailAsync_CcOmitted_IsNullInMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
await svc.SendEmailAsync("inv_cc2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||
Assert.Null(Assert.Single(mailer.Pushed).Cc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_OverrideRecipientSet_DropsCcEntirely()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: "dev-inbox@example.test");
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: "dev-inbox@example.test");
|
||||
|
||||
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde",
|
||||
cc: "zusatz@example.de");
|
||||
await svc.SendEmailAsync("inv_cc3", "Subject", "<p>hi</p>", "realcustomer@example.de", "Kunde", cc: "zusatz@example.de");
|
||||
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
Assert.Equal("dev-inbox@example.test", json["communication"]!["to"]!.ToString());
|
||||
Assert.Equal(JTokenType.Null, json["communication"]!["cc"]!.Type);
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("dev-inbox@example.test", m.To);
|
||||
Assert.Null(m.Cc);
|
||||
}
|
||||
|
||||
// ── Override enforcement across every real appsettings*.json environment ───
|
||||
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core
|
||||
// layers them (base file + optional environment-specific overlay). These files
|
||||
// are copied into this test assembly's output directory via the Fuchs project
|
||||
// reference (Microsoft.NET.Sdk.Web auto-includes appsettings*.json as Content
|
||||
// with CopyToOutputDirectory). This proves the safety net holds for whichever
|
||||
// environment/appsettings file ends up "active" at runtime - not just a
|
||||
// hand-typed literal - so a future edit that silently breaks the override key
|
||||
// or its value in any appsettings*.json file would fail this test.
|
||||
//
|
||||
// Note: this API has a single "communication.to" field - there is no distinct
|
||||
// to/cc/bcc concept anywhere in the codebase (see ProcessWebComService.
|
||||
// SendEmailAsync / communication.to). "to/cc/bcc cleared" is therefore fully
|
||||
// satisfied by asserting that field no longer carries the original recipient
|
||||
// once an override is configured.
|
||||
// Loads the ACTUAL Fuchs/appsettings*.json files the same way ASP.NET Core layers them (base
|
||||
// file + optional environment-specific overlay). These files are copied into this test
|
||||
// assembly's output directory via the Fuchs project reference. This proves the safety net holds
|
||||
// for whichever environment/appsettings file ends up "active" at runtime — not just a hand-typed
|
||||
// literal — so a future edit that silently breaks the override key or its value in any
|
||||
// appsettings*.json file would fail this test.
|
||||
public static IEnumerable<object[]> AppSettingsEnvironments()
|
||||
{
|
||||
yield return new object[] { "" }; // base appsettings.json only ("Production"-like default)
|
||||
@@ -489,8 +460,8 @@ public class ProcessWebComServiceTests
|
||||
public async Task SendEmailAsync_OverrideRecipientFromRealAppsettings_ClearsRecipientWheneverConfigured(string environmentName)
|
||||
{
|
||||
string overrideRecipient = ResolveOverrideRecipientForEnvironment(environmentName);
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler, overrideRecipient: overrideRecipient);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer, overrideRecipient: overrideRecipient);
|
||||
|
||||
const string originalRecipient = "realcustomer@tenant-owner.example";
|
||||
bool result = await svc.SendEmailAsync(
|
||||
@@ -498,134 +469,226 @@ public class ProcessWebComServiceTests
|
||||
"Subject", "<p>hi</p>", originalRecipient, "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
string sentRecipient = json["communication"]!["to"]!.ToString();
|
||||
|
||||
string sentRecipient = Assert.Single(mailer.Pushed).To;
|
||||
if (!string.IsNullOrWhiteSpace(overrideRecipient))
|
||||
{
|
||||
// Override configured for this environment: the real recipient (to/cc/bcc)
|
||||
// must be fully cleared and replaced by the override - never leaked.
|
||||
Assert.Equal(overrideRecipient, sentRecipient);
|
||||
Assert.NotEqual(originalRecipient, sentRecipient);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No override configured for this environment: real recipient is used normally.
|
||||
Assert.Equal(originalRecipient, sentRecipient);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Attachment payload contract ────────────────────────────────────────────
|
||||
// ── The message handed to the PWMailer client ─────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
|
||||
public async Task SendEmailAsync_MapsEnvelopeFieldsAndAttachmentsOntoPWMailerMessage()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
byte[] pdf = { 1, 2, 3, 4 };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = pdf };
|
||||
var mailer = MailerStub.Echoing();
|
||||
var svc = CreateService(mailer);
|
||||
byte[] pdf = { 0x25, 0x50, 0x44, 0x46 };
|
||||
byte[] xml = { 0x3C, 0x3F };
|
||||
var attachments = new Dictionary<string, byte[]> { ["Rechnung R2026-0119.pdf"] = pdf, ["zugferd.xml"] = xml };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments);
|
||||
bool result = await svc.SendEmailAsync("inv_map", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments, cc: "cc@example.de");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.NotNull(handler.LastRequestBody);
|
||||
var json = JObject.Parse(handler.LastRequestBody!);
|
||||
var att = (JArray)json["communication"]!["attachments"]!;
|
||||
Assert.Single(att);
|
||||
Assert.Equal("Rechnung.pdf", att[0]!["filename"]!.ToString());
|
||||
Assert.Equal("application/pdf", att[0]!["mimeType"]!.ToString());
|
||||
Assert.Equal(Convert.ToBase64String(pdf), att[0]!["contentBase64"]!.ToString());
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("e", m.ComType);
|
||||
Assert.Equal("inv_map", m.Reference);
|
||||
Assert.Equal("kunde@example.de", m.To);
|
||||
Assert.Equal("cc@example.de", m.Cc);
|
||||
Assert.Equal("Subject", m.Subject);
|
||||
Assert.StartsWith("<p>hi</p>", m.Body); // signature is appended after the body
|
||||
Assert.Null(m.AttachmentCount); // null ⇒ client sends the real count
|
||||
Assert.Equal(2, m.Attachments.Count);
|
||||
Assert.Equal(pdf, m.Attachments.Single(a => a.FileName == "Rechnung R2026-0119.pdf").Bytes);
|
||||
Assert.Equal(xml, m.Attachments.Single(a => a.FileName == "zugferd.xml").Bytes);
|
||||
}
|
||||
|
||||
// ── Response checks ────────────────────────────────────────────────────────
|
||||
[Theory]
|
||||
[InlineData(1, 0)]
|
||||
[InlineData(0, 0)]
|
||||
public async Task SendEmailAsync_AttachmentsNotConfirmedProcessed_ReturnsFalseAndAuditsCounts(int received, int processed)
|
||||
{
|
||||
// A 200 is not enough when files were sent: the Mailer must report every one as processed.
|
||||
var svc = CreateCapturing(new MailerStub(Accepted(received, processed, "cs-9")));
|
||||
var att = new Dictionary<string, byte[]> { ["Rechnung.pdf"] = [1, 2, 3] };
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_mismatch", "S", "<p>x</p>", "kunde@example.de", "Kunde", att);
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("not all 1 attachment(s)")
|
||||
&& l.Contains($"attachmentsReceived={received}") && l.Contains($"attachmentsProcessed={processed}"));
|
||||
Assert.Equal("cs-9", Assert.Single(svc.Guids)); // still traceable at the Mailer
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_AttachmentsMismatch_IncrementsFailedCounterWithReason()
|
||||
{
|
||||
string? reason = null;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
|
||||
{
|
||||
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new MailerStub(Accepted(1, 0)));
|
||||
await svc.SendEmailAsync("inv_mm_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde",
|
||||
new Dictionary<string, byte[]> { ["a.pdf"] = [1] });
|
||||
|
||||
Assert.Equal("attachments-mismatch", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_Conflict409_IsSuccessBecauseThePendingDuplicateWillBeSent()
|
||||
{
|
||||
// A resend of an identical message while the first is still pending: the Mailer keeps the
|
||||
// first entry and delivers it — the caller (e.g. req/resend, or a double click) must see a
|
||||
// successful send, with the deduplication recorded in the audit log.
|
||||
var svc = CreateCapturing(new MailerStub(Failed(409, "duplicate of pending entry")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_dup", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.True(entry.success);
|
||||
Assert.Contains(entry.log, l => l.Contains("still-pending") && l.Contains("409") && l.Contains("duplicate of pending entry"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_RefusedByClientPreValidation_ReturnsFalseAndAuditsEachRule()
|
||||
{
|
||||
// The client refuses before sending (status 0, no attempt) with "; "-joined rule messages —
|
||||
// e.g. Fuchs' attachment limits (FuchsMailerClient) or the contract's required fields.
|
||||
var svc = CreateCapturing(new MailerStub(Refused("Attachment 'big.pdf' is 11,000,000 bytes (max 10,485,760); attachment 'x' has no file extension")));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_refused", "S", "<p>x</p>", "kunde@example.de", "Kunde",
|
||||
new Dictionary<string, byte[]> { ["big.pdf"] = [1] });
|
||||
|
||||
Assert.False(result);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Equal(2, entry.log.Count);
|
||||
Assert.Contains(entry.log, l => l.StartsWith("Attachment 'big.pdf'"));
|
||||
Assert.Contains(entry.log, l => l.Contains("no file extension"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendEmailAsync_RefusedBecauseClientNotUsable_ReturnsFalseWithClientConfigReason()
|
||||
{
|
||||
// Same shape as a validation refusal (status 0, no attempt) but no ArgumentException: the
|
||||
// client could not resolve its token / options. Must not be reported as caller validation.
|
||||
string? reason = null;
|
||||
using var listener = new MeterListener
|
||||
{
|
||||
InstrumentPublished = (inst, l) =>
|
||||
{
|
||||
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.failed")
|
||||
l.EnableMeasurementEvents(inst);
|
||||
}
|
||||
};
|
||||
listener.SetMeasurementEventCallback<long>((_, _, tags, _) =>
|
||||
{
|
||||
foreach (var t in tags) if (t.Key == "reason") reason = t.Value?.ToString();
|
||||
});
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateCapturing(new MailerStub(new PWMailerResult<PWMailerPushReceipt>
|
||||
{
|
||||
StatusCode = 0, Attempts = 0, Operation = "push_com",
|
||||
Error = "PWMailer token could not be loaded from Key Vault secret 'Fuchs--Mailer--Token': timeout"
|
||||
}));
|
||||
|
||||
bool result = await svc.SendEmailAsync("inv_cfg", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal("client-config", reason);
|
||||
var entry = Assert.Single(svc.Entries);
|
||||
Assert.False(entry.success);
|
||||
Assert.Contains(entry.log, l => l.StartsWith("Mailer client not usable") && l.Contains("Key Vault"));
|
||||
}
|
||||
|
||||
// ── SMS ────────────────────────────────────────────────────────────────────
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue()
|
||||
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrueAndPushesComTypeS()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
|
||||
|
||||
Assert.True(result);
|
||||
Assert.Equal(1, handler.CallCount);
|
||||
var m = Assert.Single(mailer.Pushed);
|
||||
Assert.Equal("s", m.ComType);
|
||||
Assert.Equal("01700000000", m.To);
|
||||
Assert.Equal("Code 1234", m.Body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi()
|
||||
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingMailer()
|
||||
{
|
||||
var handler = new StubHandler(HttpStatusCode.OK);
|
||||
var svc = CreateService(handler);
|
||||
var mailer = new MailerStub();
|
||||
var svc = CreateService(mailer);
|
||||
|
||||
bool result = await svc.SendSmsAsync("", "Code 1234");
|
||||
|
||||
Assert.False(result);
|
||||
Assert.Equal(0, handler.CallCount);
|
||||
Assert.Equal(0, mailer.CallCount);
|
||||
}
|
||||
|
||||
// ── Credential verification (GET /mailer/fc?fn=verify) ─────────────────────
|
||||
private sealed class CapturingRequestHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly HttpStatusCode _status;
|
||||
private readonly string _body;
|
||||
public HttpRequestMessage? LastRequest { get; private set; }
|
||||
// ── Credential verification (client VerifyAsync) ────────────────────────────
|
||||
private static PWMailerResult<PWMailerVerifyResult> VerifyResult(bool ok, string account = "acc-123", string? serverId = null) =>
|
||||
new() { StatusCode = 200, Value = new PWMailerVerifyResult(ok, account, serverId), Attempts = 1, Operation = "verify" };
|
||||
|
||||
public CapturingRequestHandler(HttpStatusCode status, string body) { _status = status; _body = body; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
LastRequest = request;
|
||||
return Task.FromResult(new HttpResponseMessage(_status) { Content = new StringContent(_body) });
|
||||
}
|
||||
}
|
||||
private static ProcessWebComService VerifyService(Mock<IPWMailerClientService> mailer, string accountId = "acct", string token = "tok", string serverId = "") =>
|
||||
new(NullLogger<ProcessWebComService>.Instance, intranet: null!, Settings(true, accountId, token, serverId),
|
||||
Options.Create(new FuchsEmailSettings()), mailer.Object);
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_MailerConfirms_ReturnsOkWithAccount()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\"}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("acc-123", result.Account);
|
||||
Assert.NotNull(handler.LastRequest);
|
||||
Assert.Equal(HttpMethod.Get, handler.LastRequest!.Method);
|
||||
Assert.Equal("https://mailer.test/mailer/fc?fn=verify", handler.LastRequest.RequestUri!.ToString());
|
||||
Assert.Equal("Basic", handler.LastRequest.Headers.Authorization!.Scheme);
|
||||
mailer.Verify(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_ServerIdConfigured_IsSentAndEchoedOnSuccess()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true,\"account\":\"acc-123\",\"serverid\":\"srv-1\"}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-1" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync("srv-1", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(true, serverId: "srv-1"));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, serverId: "srv-1").VerifyCredentialsAsync();
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Equal("srv-1", result.ServerId);
|
||||
Assert.Equal("https://mailer.test/mailer/fc?fn=verify&serverid=srv-1", handler.LastRequest!.RequestUri!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_ServerIdNotOwnedByAccount_ReturnsNotOkWithServerIdDetail()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false,\"account\":\"acc-123\",\"serverid\":\"srv-wrong\"}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok", ServerId = "srv-wrong" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync("srv-wrong", It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false, serverId: "srv-wrong"));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, serverId: "srv-wrong").VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("srv-wrong", result.Detail);
|
||||
@@ -634,44 +697,37 @@ public class ProcessWebComServiceTests
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_MailerReturnsOkFalse_ReturnsNotOk()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":false}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>())).ReturnsAsync(VerifyResult(false));
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("ungültige Zugangsdaten", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_Http401_ReturnsNotOkWithDetail()
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.Unauthorized, "unauthorized");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "wrong" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 401, Error = "not authenticated", Attempts = 1 });
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, token: "wrong").VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("401", result.Detail);
|
||||
Assert.Contains("not authenticated", result.Detail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VerifyCredentialsAsync_TransportException_ReturnsNotOkWithExceptionDetail()
|
||||
public async Task VerifyCredentialsAsync_TransportError_ReturnsNotOkWithClientError()
|
||||
{
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = "acct", Token = "tok" }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(new ThrowingHandler()));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
mailer.Setup(m => m.VerifyAsync(null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new PWMailerResult<PWMailerVerifyResult> { StatusCode = 0, Error = "simulated transport failure", Attempts = 3, TimedOut = false });
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Contains("simulated transport failure", result.Detail);
|
||||
@@ -680,19 +736,14 @@ public class ProcessWebComServiceTests
|
||||
[Theory]
|
||||
[InlineData("", "tok")]
|
||||
[InlineData("acct", "")]
|
||||
public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingApi(string accountId, string token)
|
||||
public async Task VerifyCredentialsAsync_CredentialsNotConfigured_ReturnsNotOkWithoutCallingClient(string accountId, string token)
|
||||
{
|
||||
var handler = new CapturingRequestHandler(HttpStatusCode.OK, "{\"ok\":true}");
|
||||
var svc = new ProcessWebComService(
|
||||
NullLogger<ProcessWebComService>.Instance, intranet: null!,
|
||||
Options.Create(new ProcessWebComSettings { Enabled = true, BaseUrl = "https://mailer.test", AccountId = accountId, Token = token }),
|
||||
Options.Create(new FuchsEmailSettings()),
|
||||
new StubHttpClientFactory(handler));
|
||||
var mailer = new Mock<IPWMailerClientService>();
|
||||
|
||||
var result = await svc.VerifyCredentialsAsync();
|
||||
var result = await VerifyService(mailer, accountId, token).VerifyCredentialsAsync();
|
||||
|
||||
Assert.False(result.Ok);
|
||||
Assert.Null(handler.LastRequest);
|
||||
mailer.Verify(m => m.VerifyAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Metric emission (performance indicator) ────────────────────────────────
|
||||
@@ -711,11 +762,9 @@ public class ProcessWebComServiceTests
|
||||
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
|
||||
listener.Start();
|
||||
|
||||
var svc = CreateService(new StubHandler(HttpStatusCode.OK));
|
||||
var svc = CreateService(new MailerStub());
|
||||
await svc.SendEmailAsync("inv_metric", "S", "<p>x</p>", "kunde@example.de", "Kunde");
|
||||
|
||||
Assert.True(delta >= 1, "fuchs.emails.sent counter should have been incremented on a successful send.");
|
||||
}
|
||||
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -24,6 +25,7 @@ namespace Fuchs.Tests;
|
||||
public class SystemStatusServiceTests
|
||||
{
|
||||
private const string FuchsMeterName = "Fuchs.Intranet";
|
||||
private static readonly byte[] FakePdfBytes = { 0x25, 0x50, 0x44, 0x46, 0x2D }; // "%PDF-"
|
||||
|
||||
private static IConfiguration Config(Dictionary<string, string?>? overrides = null)
|
||||
{
|
||||
@@ -51,6 +53,7 @@ public class SystemStatusServiceTests
|
||||
Mock<IBlobStorageService>? blob = null,
|
||||
Mock<IMfrClientFactory>? mfr = null,
|
||||
Mock<IComService>? com = null,
|
||||
Mock<IPdfService>? pdf = null,
|
||||
ProcessWebComSettings? mailer = null,
|
||||
FuchsEmailSettings? email = null,
|
||||
AzureBlobStorageSettings? blobSettings = null,
|
||||
@@ -67,6 +70,16 @@ public class SystemStatusServiceTests
|
||||
.ReturnsAsync(new MailerCredentialsCheckResult { Ok = true, Account = "acc" });
|
||||
}
|
||||
|
||||
// Default PDF stub: renders any letter to a fixed, recognisable byte payload.
|
||||
var pdfMock = pdf ?? new Mock<IPdfService>();
|
||||
if (pdf is null)
|
||||
{
|
||||
pdfMock.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
|
||||
pdfMock.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
|
||||
.Returns(FakePdfBytes);
|
||||
}
|
||||
|
||||
return new SystemStatusService(
|
||||
config ?? Config(),
|
||||
env.Object,
|
||||
@@ -74,6 +87,7 @@ public class SystemStatusServiceTests
|
||||
(blob ?? new Mock<IBlobStorageService>()).Object,
|
||||
(mfr ?? new Mock<IMfrClientFactory>()).Object,
|
||||
comMock.Object,
|
||||
pdfMock.Object,
|
||||
Options.Create(mailer ?? new ProcessWebComSettings { Enabled = true, Token = "tok", BaseUrl = "https://api.example", AccountId = "acc", ServerId = "srv" }),
|
||||
Options.Create(email ?? new FuchsEmailSettings()),
|
||||
Options.Create(blobSettings ?? new AzureBlobStorageSettings { Enabled = true }),
|
||||
@@ -495,6 +509,140 @@ public class SystemStatusServiceTests
|
||||
Assert.Null(r.OverrideRecipient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_AttachPdfDefault_AttachesRenderedPdfUnderFixedName()
|
||||
{
|
||||
Dictionary<string, byte[]>? capturedAttachments = null;
|
||||
FuchsPdf.FdsTextBlocks? capturedBlocks = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), false))
|
||||
.Callback<FuchsPdf.FdsTextBlocks, bool>((tb, _) => capturedBlocks = tb)
|
||||
.ReturnsAsync(new MigraDoc.DocumentObjectModel.Document());
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>())).Returns(FakePdfBytes);
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1\r\nZeile2");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
|
||||
Assert.Contains(SystemStatusService.TestPdfAttachmentName, r.Message);
|
||||
Assert.NotNull(capturedAttachments);
|
||||
var (name, bytes) = Assert.Single(capturedAttachments!);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, name);
|
||||
Assert.Equal(FakePdfBytes, bytes);
|
||||
// The letter carries the mail's own subject/body and addresses the requested recipient.
|
||||
Assert.NotNull(capturedBlocks);
|
||||
Assert.Equal("Betreff", capturedBlocks!.Subject);
|
||||
Assert.Equal("Zeile1\nZeile2", capturedBlocks.Body);
|
||||
Assert.Contains("dest@example.test", capturedBlocks.Address);
|
||||
pdf.Verify(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_AttachPdfFalse_SendsWithoutAttachmentAndSkipsRendering()
|
||||
{
|
||||
Dictionary<string, byte[]>? capturedAttachments = new();
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>(MockBehavior.Strict); // any render call would throw
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body", attachPdf: false);
|
||||
|
||||
Assert.True(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
Assert.Null(capturedAttachments);
|
||||
Assert.DoesNotContain("PDF", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_PdfRenderingFails_DoesNotSendAndReportsError()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.ThrowsAsync(new InvalidOperationException("font resolver missing"));
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
Assert.Contains("PDF-Anhang", r.Message);
|
||||
Assert.Contains("font resolver missing", r.Message);
|
||||
Assert.Equal("dest@example.test", r.RequestedRecipient);
|
||||
com.Verify(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MailerRejectsWithAttachment_ReportsAttachmentNotConfirmed()
|
||||
{
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(false); // ADR 0028: also the outcome when attachmentsProcessed ≠ sent
|
||||
var svc = Build(com: com);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Equal(SystemStatusService.TestPdfAttachmentName, r.AttachmentName);
|
||||
Assert.Contains("Anhang", r.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_RealLetterRenderer_ProducesValidOnePagePdfAttachment()
|
||||
{
|
||||
// Real MigraDoc/PdfSharp rendering of the letter the service builds (raw, without the
|
||||
// Spire PDF/A pass, so the test does not depend on a Spire license) — proves the text
|
||||
// blocks the service assembles are renderable, not just that a stub was called.
|
||||
Dictionary<string, byte[]>? capturedAttachments = null;
|
||||
var com = new Mock<IComService>();
|
||||
com.Setup(c => c.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, byte[]>?>(), It.IsAny<string?>()))
|
||||
.Callback<string, string, string, string, string, Dictionary<string, byte[]>?, string?>(
|
||||
(_, _, _, _, _, att, _) => capturedAttachments = att)
|
||||
.ReturnsAsync(true);
|
||||
var pdf = new Mock<IPdfService>();
|
||||
pdf.Setup(p => p.WriteLetterAsync(It.IsAny<FuchsPdf.FdsTextBlocks>(), It.IsAny<bool>()))
|
||||
.Returns<FuchsPdf.FdsTextBlocks, bool>((tb, draft) => FuchsPdf.WriteLetter(tb, draft, FuchsPdf.DeCulture));
|
||||
pdf.Setup(p => p.DocToPdfBytes(It.IsAny<MigraDoc.DocumentObjectModel.Document>()))
|
||||
.Returns<MigraDoc.DocumentObjectModel.Document>(FuchsPdf.DocToPdfBytesRaw);
|
||||
var svc = Build(com: com, pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("dest@example.test", "Fuchs Intranet – Test-E-Mail", "Dies ist eine Test-E-Mail.\nZweite Zeile.");
|
||||
|
||||
Assert.True(r.Sent);
|
||||
byte[] bytes = Assert.Single(capturedAttachments!).Value;
|
||||
Assert.True(bytes.Length > 1000, "attachment should be a real rendered PDF");
|
||||
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(bytes, 0, 4));
|
||||
using var opened = PdfSharp.Pdf.IO.PdfReader.Open(new System.IO.MemoryStream(bytes), PdfSharp.Pdf.IO.PdfDocumentOpenMode.Import);
|
||||
Assert.Equal(1, opened.PageCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotRenderPdf()
|
||||
{
|
||||
var pdf = new Mock<IPdfService>(MockBehavior.Strict);
|
||||
var svc = Build(pdf: pdf);
|
||||
|
||||
var r = await svc.SendTestEmailAsync("", "Betreff", "Body");
|
||||
|
||||
Assert.False(r.Sent);
|
||||
Assert.Null(r.AttachmentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendTestEmailAsync_OverrideActive_ReportsOverrideRecipient()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user