Add OpenTelemetry, performance metrics, and broaden logging + tests

Observability:
- New FuchsTelemetry (ActivitySource + Meter) defining business counters
  (invoices/reminders/reports rendered, emails/sms sent+failed, MT940 rows,
  MFR calls) and duration histograms (PDF render, report render, email send).
- Program.cs wires OpenTelemetry tracing (ASP.NET Core, HttpClient, SqlClient,
  app source) and metrics (ASP.NET Core, HttpClient, runtime, app meter).
  OTLP export is enabled only when Fuchs:Telemetry:OtlpEndpoint is set, so a
  missing collector never affects the app; disable via Fuchs:Telemetry:Enabled.

Instrumentation + logging:
- Services (Pdf, Invoice, Reminder, Report, Com, Banking, Widget, MfrFactory)
  now emit spans, record metrics, and log entry/result/timing/errors.
- Added dispatch + key-action logging to the previously silent handlers
  (Banking, Reminder, Reports, Requests).

Tests (137 total, +10):
- ProcessWebComServiceTests with a stub HttpMessageHandler cover success
  (API 200), failure (API 500, invalid email, empty mobile), disabled mode,
  the base64 attachment payload contract, and metric emission via MeterListener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 14:02:13 +02:00
parent 8dee630abb
commit e04d590c3a
17 changed files with 473 additions and 16 deletions
+194
View File
@@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.Metrics;
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 Newtonsoft.Json.Linq;
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.
///
/// 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
{
// ── 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; }
public StubHandler(HttpStatusCode status, string body = "OK")
{
_status = status;
_body = body;
}
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
CallCount++;
LastRequestBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken);
return new HttpResponseMessage(_status) { Content = new StringContent(_body) };
}
}
private sealed class StubHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler _handler;
public StubHttpClientFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
}
private static ProcessWebComService CreateService(StubHandler handler, bool enabled = true)
{
var settings = Options.Create(new ProcessWebComSettings
{
Enabled = enabled,
BaseUrl = "https://mailer.test",
AccountId = "acct",
Token = "tok"
});
return new ProcessWebComService(
NullLogger<ProcessWebComService>.Instance,
intranet: null!,
settings,
new StubHttpClientFactory(handler));
}
// ── Success path ─────────────────────────────────────────────────────────
[Fact]
public async Task SendEmailAsync_ValidAndApiOk_ReturnsTrueAndPostsOnce()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler);
bool result = await svc.SendEmailAsync("inv_1", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.True(result);
Assert.Equal(1, handler.CallCount);
}
// ── Failure paths ──────────────────────────────────────────────────────────
[Fact]
public async Task SendEmailAsync_ApiError_ReturnsFalse()
{
var handler = new StubHandler(HttpStatusCode.InternalServerError, "boom");
var svc = CreateService(handler);
bool result = await svc.SendEmailAsync("inv_2", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde");
Assert.False(result);
Assert.Equal(1, handler.CallCount);
}
[Theory]
[InlineData("not-an-email")]
[InlineData("")]
[InlineData("missing@")]
public async Task SendEmailAsync_InvalidEmail_ReturnsFalseWithoutCallingApi(string badEmail)
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler);
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
}
[Fact]
public async Task SendEmailAsync_Disabled_ReturnsFalseWithoutCallingApi()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler, 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);
}
// ── Attachment payload contract ────────────────────────────────────────────
[Fact]
public async Task SendEmailAsync_WithAttachment_EmbedsBase64InPayload()
{
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 };
bool result = await svc.SendEmailAsync("inv_5", "Subject", "<p>hi</p>", "kunde@example.de", "Kunde", attachments);
Assert.True(result);
Assert.NotNull(handler.LastRequestBody);
var json = JObject.Parse(handler.LastRequestBody!);
var att = (JArray)json["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());
}
// ── SMS ────────────────────────────────────────────────────────────────────
[Fact]
public async Task SendSmsAsync_ValidAndApiOk_ReturnsTrue()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler);
bool result = await svc.SendSmsAsync("01700000000", "Code 1234");
Assert.True(result);
Assert.Equal(1, handler.CallCount);
}
[Fact]
public async Task SendSmsAsync_EmptyMobile_ReturnsFalseWithoutCallingApi()
{
var handler = new StubHandler(HttpStatusCode.OK);
var svc = CreateService(handler);
bool result = await svc.SendSmsAsync("", "Code 1234");
Assert.False(result);
Assert.Equal(0, handler.CallCount);
}
// ── Metric emission (performance indicator) ────────────────────────────────
[Fact]
public async Task SendEmailAsync_Success_IncrementsEmailsSentCounter()
{
long delta = 0;
using var listener = new MeterListener
{
InstrumentPublished = (inst, l) =>
{
if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.emails.sent")
l.EnableMeasurementEvents(inst);
}
};
listener.SetMeasurementEventCallback<long>((_, value, _, _) => Interlocked.Add(ref delta, value));
listener.Start();
var svc = CreateService(new StubHandler(HttpStatusCode.OK));
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";
}