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:
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user