feat: Implement admin-only specialties management interface
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:
Stefan
2026-09-17 21:17:23 +02:00
parent 86f6a60860
commit 249a945f5e
59 changed files with 2720 additions and 457 deletions
+143
View File
@@ -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.");
}
}
+132
View File
@@ -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"));
}
}
+306 -257
View File
@@ -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";
}
+148
View File
@@ -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()
{