From 8a0ebeeb1efbb25ccc98fe89c2e14ad5a31dcd3d Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 16 Jul 2026 14:59:14 +0200 Subject: [PATCH] Add German localization and styling for administration module - Introduced new JavaScript file `fis.admin_txt_de.js` for German translations of administration-related terms and messages. - Created `fis.admin.css` for styling the administration interface, including layout, cards, and buttons. - Added `fis.admin.de.js` for the main functionality of the administration module, implementing features such as system status checks and email testing. - Minified version of the German JavaScript file created as `fis.admin.de.min.js`. - Minified CSS file created as `fis.admin.min.css` for optimized loading. --- .github/copilot-instructions.md | 4 +- CLAUDE.md | 2 +- CODEX.md | 2 +- Fuchs.Tests/StartupSelfTestServiceTests.cs | 53 +- Fuchs.Tests/SystemStatusServiceTests.cs | 465 ++++++++++++++++++ Fuchs/Controllers/IntranetController.Admin.cs | 105 ++++ Fuchs/Controllers/IntranetController.cs | 6 +- Fuchs/Docs/ARCHITECTURE.md | 3 +- Fuchs/Docs/Concepts/admin-system-status.md | 97 ++++ .../0011-admin-module-system-status.md | 67 +++ Fuchs/Observability/FuchsTelemetry.cs | 3 + Fuchs/Program.cs | 7 + Fuchs/Services/AzureBlobStorageService.cs | 81 +++ Fuchs/Services/IBlobStorageService.cs | 45 ++ Fuchs/Services/ISystemStatusService.cs | 28 ++ Fuchs/Services/StartupCheckReport.cs | 46 ++ Fuchs/Services/StartupSelfTestService.cs | 21 +- Fuchs/Services/SystemStatusModels.cs | 132 +++++ Fuchs/Services/SystemStatusService.cs | 415 ++++++++++++++++ Fuchs/appsettings.Development.json | 4 +- Fuchs/bdlconfig.json | 19 + Fuchs/js/intranet/fis_main_go.js | 1 + Fuchs/js/intranet/fis_main_menu.js | 19 +- Fuchs/js/intranet/fis_texts_gui_de.js | 1 + Fuchs/js/intranet/modules/fis.admin.js | 250 ++++++++++ Fuchs/js/intranet/modules/fis.admin.scss | 200 ++++++++ Fuchs/js/intranet/modules/fis.admin_txt_de.js | 84 ++++ Fuchs/wwwroot/web/fis.admin.css | 256 ++++++++++ Fuchs/wwwroot/web/fis.admin.de.js | 335 +++++++++++++ Fuchs/wwwroot/web/fis.admin.de.min.js | 1 + Fuchs/wwwroot/web/fis.admin.min.css | 1 + Fuchs/wwwroot/web/fis.js | 19 + Fuchs/wwwroot/web/fis.min.js | 4 +- Fuchs/wwwroot/web/tools.js | 5 +- 34 files changed, 2765 insertions(+), 16 deletions(-) create mode 100644 Fuchs.Tests/SystemStatusServiceTests.cs create mode 100644 Fuchs/Controllers/IntranetController.Admin.cs create mode 100644 Fuchs/Docs/Concepts/admin-system-status.md create mode 100644 Fuchs/Docs/Decisions/0011-admin-module-system-status.md create mode 100644 Fuchs/Services/ISystemStatusService.cs create mode 100644 Fuchs/Services/StartupCheckReport.cs create mode 100644 Fuchs/Services/SystemStatusModels.cs create mode 100644 Fuchs/Services/SystemStatusService.cs create mode 100644 Fuchs/js/intranet/modules/fis.admin.js create mode 100644 Fuchs/js/intranet/modules/fis.admin.scss create mode 100644 Fuchs/js/intranet/modules/fis.admin_txt_de.js create mode 100644 Fuchs/wwwroot/web/fis.admin.css create mode 100644 Fuchs/wwwroot/web/fis.admin.de.js create mode 100644 Fuchs/wwwroot/web/fis.admin.de.min.js create mode 100644 Fuchs/wwwroot/web/fis.admin.min.css diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bfc1512..bd94f65 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -47,8 +47,8 @@ ## Services & Dependency Injection - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces; inject them into `IntranetController` (constructor injection). Do **not** reintroduce static God-classes or pass the whole controller into helpers. - - `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`. - - Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`) are scoped. Register in `Program.cs`. + - `IComService` (email/SMS via ProcessWeb Mailer API, attachments sent inline as base64; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService` (MigraDoc render), `IInvoiceService`, `IReminderService`, `IReportService` (SQL report engine via `FuchsVisualization`), `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). + - Lifetimes: stateless services (`IPdfService`, `IBankingService`, `IMfrClientFactory`) are singletons; request-scoped DB services (`IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IComService`, `ISystemStatusService`) are scoped. Register in `Program.cs`. - `FdsInvoiceData` / `FdsReminderData` are **pure data holders** (parse + properties). Loading, persistence and PDF generation belong in the services — never `Task.Run(...).Wait()` sync-over-async. - Data access stays SQL-first via OCORE helpers (`getSQLDataSet_async`, `setSQLValue_async`) + stored procedures; no EF Core. diff --git a/CLAUDE.md b/CLAUDE.md index c6b1216..9fb8c9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ ## Services & Dependency Injection - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. -- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). +- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). diff --git a/CODEX.md b/CODEX.md index 9058e9c..5d6495d 100644 --- a/CODEX.md +++ b/CODEX.md @@ -37,7 +37,7 @@ ## Services & Dependency Injection - Business logic lives in **DI-registered services** under `Fuchs/Services/` behind interfaces, injected into `IntranetController`. Do not reintroduce static God-classes or pass the controller into helpers. -- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`. Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). +- Services: `IComService` (ProcessWeb Mailer API; honors the `Fuchs:Email:OverrideRecipient` dev safety net — see Configuration), `IPdfService`, `IInvoiceService`, `IReminderService`, `IReportService`, `IWidgetService`, `IBankingService`, `IMfrClientFactory`, `ISystemStatusService` (Admin module diagnostics: config snapshot + DB/Key Vault/blob/MFR connectivity probes + test-email; restricted to `fds_sys` > 4). Stateless ones are singletons; DB/request-scoped ones are scoped (see `Program.cs`). - `FdsInvoiceData` / `FdsReminderData` are **pure data holders**; load/persist/render belongs in services. No `Task.Run(...).Wait()` sync-over-async. - Data access is SQL-first via OCORE helpers + stored procedures (no EF Core). diff --git a/Fuchs.Tests/StartupSelfTestServiceTests.cs b/Fuchs.Tests/StartupSelfTestServiceTests.cs index ff0199a..b12c587 100644 --- a/Fuchs.Tests/StartupSelfTestServiceTests.cs +++ b/Fuchs.Tests/StartupSelfTestServiceTests.cs @@ -205,6 +205,54 @@ public class StartupSelfTestServiceTests Assert.Equal(1, service.MailerCalls); } + [Fact] + public async Task RunOnce_Disabled_PublishesNotRunReport() + { + var reporter = new StartupCheckReporter(); + using var service = new TestableStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + CreateConfiguration(), + Options.Create(CreateSettings(enabled: false)), + NullLogger.Instance, + reporter); + + await service.RunForTestAsync(CancellationToken.None); + + Assert.NotNull(reporter.Latest); + Assert.False(reporter.Latest!.Ran); + } + + [Fact] + public async Task RunOnce_Enabled_PublishesReportWithPerCheckResults() + { + var reporter = new StartupCheckReporter(); + using var service = new TestableStartupSelfTestService( + new ServiceCollection().BuildServiceProvider(), + CreateConfiguration(), + Options.Create(CreateSettings(enabled: true, checkKeyVault: true, checkDatabase: true, checkMfr: false, sendStartupEmail: true, startupRecipient: "ops@example.test", checkPdfLicense: false)), + NullLogger.Instance, + reporter) + { + KeyVaultResult = true, + DatabaseResult = false, + MailerResult = true, + }; + + await service.RunForTestAsync(CancellationToken.None); + + var report = reporter.Latest; + Assert.NotNull(report); + Assert.True(report!.Ran); + Assert.NotNull(report.CompletedUtc); + var kv = Assert.Single(report.Items, i => i.Name == "KeyVault"); + Assert.True(kv.Enabled && kv.Ok); + var db = Assert.Single(report.Items, i => i.Name == "Database"); + Assert.True(db.Enabled); + Assert.False(db.Ok); // intentionally-failing path + var mfr = Assert.Single(report.Items, i => i.Name == "MFR"); + Assert.False(mfr.Enabled); // disabled check reported as not-enabled + } + [Fact] public async Task ExecuteAsync_ProbeThrows_ServiceDoesNotThrowAndContinuesRemainingChecks() { @@ -250,8 +298,9 @@ public class StartupSelfTestServiceTests IServiceProvider serviceProvider, IConfiguration configuration, IOptions settings, - Microsoft.Extensions.Logging.ILogger logger) - : base(serviceProvider, configuration, settings, logger) + Microsoft.Extensions.Logging.ILogger logger, + StartupCheckReporter? reporter = null) + : base(serviceProvider, configuration, settings, logger, reporter) { } diff --git a/Fuchs.Tests/SystemStatusServiceTests.cs b/Fuchs.Tests/SystemStatusServiceTests.cs new file mode 100644 index 0000000..b02b954 --- /dev/null +++ b/Fuchs.Tests/SystemStatusServiceTests.cs @@ -0,0 +1,465 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using System.Threading; +using System.Threading.Tasks; +using Fuchs.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Tests for the Admin module's read-only diagnostics service. Covers the passive config +/// snapshot (SQL server parsing, email override reflection, presence flags), each probe's +/// disabled/unconfigured/ok/error outcomes where deterministically reachable without live +/// infrastructure, the test-email pipeline (success/failure/validation + HTML-encoding + +/// override reporting), and the emitted probe telemetry. +/// +public class SystemStatusServiceTests +{ + private const string FuchsMeterName = "Fuchs.Intranet"; + + private static IConfiguration Config(Dictionary? overrides = null) + { + var dict = new Dictionary + { + ["ConnectionStrings:fuchs_fds_ConnectionString"] = + "Data Source=SQLHOST,1433;Initial Catalog=site_fuchs_test;User ID=fuchs_app;password='secret';", + ["ConnectionStrings:AzureBlobStorage_ConnectionString"] = "DefaultEndpointsProtocol=https;AccountName=acct;AccountKey=key==;", + ["Fuchs:IsTestDeployment"] = "true", + ["Fds:MFR_host"] = "portal.mobilefieldreport.com", + ["Fds:MFR_UserName"] = "mfruser", + ["Fds:SyncEnabled"] = "true", + ["SecretManagement:VaultUri"] = "https://vault.example/", + ["SecretManagement:AppName"] = "fuchs", + ["SecretManagement:ManagedSecretKeys:0"] = "Fuchs--Mailer--Token", + ["SecretManagement:ManagedSecretKeys:1"] = "ConnectionStrings--fuchs-fds-password", + }; + if (overrides != null) + foreach (var kv in overrides) dict[kv.Key] = kv.Value; + return new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + } + + private static SystemStatusService Build( + IConfiguration? config = null, + Mock? blob = null, + Mock? mfr = null, + Mock? com = null, + ProcessWebComSettings? mailer = null, + FuchsEmailSettings? email = null, + AzureBlobStorageSettings? blobSettings = null, + IServiceProvider? provider = null, + StartupCheckReporter? startupChecks = null) + { + var env = new Mock(); + env.SetupGet(e => e.EnvironmentName).Returns("Development"); + + return new SystemStatusService( + config ?? Config(), + env.Object, + provider ?? new ServiceCollection().BuildServiceProvider(), + (blob ?? new Mock()).Object, + (mfr ?? new Mock()).Object, + (com ?? new Mock()).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 }), + startupChecks ?? new StartupCheckReporter(), + NullLogger.Instance); + } + + // ── GetInfo (passive snapshot) ───────────────────────────────────────────── + [Fact] + public void GetInfo_ParsesConnectionStringAndReflectsConfiguration() + { + var svc = Build(); + + SystemInfoSnapshot info = svc.GetInfo(); + + Assert.Equal("Development", info.Environment); + Assert.True(info.IsTestDeployment); + Assert.True(info.Database.Configured); + Assert.Equal("SQLHOST,1433", info.Database.Server); + Assert.Equal("site_fuchs_test", info.Database.Catalog); + Assert.Equal("fuchs_app", info.Database.UserId); // login only, never the password + Assert.True(info.Mfr.CredentialsConfigured); + Assert.Equal("portal.mobilefieldreport.com", info.Mfr.Host); + Assert.True(info.Mfr.SyncEnabled); + Assert.Equal("fuchs", info.KeyVault.AppName); + Assert.Equal(2, info.KeyVault.ManagedSecretCount); + Assert.False(info.KeyVault.ClientRegistered); // empty provider + } + + [Fact] + public void GetInfo_NeverLeaksDatabasePassword() + { + var svc = Build(); + + SystemInfoSnapshot info = svc.GetInfo(); + + Assert.DoesNotContain("secret", info.Database.UserId ?? ""); + Assert.DoesNotContain("secret", info.Database.Server ?? ""); + Assert.DoesNotContain("secret", info.Database.Catalog ?? ""); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void GetInfo_EmailOverride_ReflectedOnlyWhenConfigured(bool overrideSet) + { + var email = new FuchsEmailSettings { OverrideRecipient = overrideSet ? "safety@example.test" : "" }; + var svc = Build(email: email); + + SystemInfoSnapshot info = svc.GetInfo(); + + Assert.Equal(overrideSet, info.Email.OverrideActive); + Assert.Equal(overrideSet ? "safety@example.test" : null, info.Email.OverrideRecipient); + Assert.True(info.Email.MailerEnabled); // Build() default enables the mailer + Assert.True(info.Email.TokenConfigured); + } + + [Fact] + public void GetInfo_StartupChecks_NullWhenServiceHasNotRun() + { + var svc = Build(startupChecks: new StartupCheckReporter()); // nothing set yet + + Assert.Null(svc.GetInfo().StartupChecks); + } + + [Fact] + public void GetInfo_StartupChecks_ReflectsReporterContents() + { + var reporter = new StartupCheckReporter(); + reporter.Set(new StartupCheckReport + { + Ran = true, + CompletedUtc = DateTimeOffset.UtcNow, + MachineName = "BUILD-HOST", + Items = + [ + new StartupCheckItem { Name = "Database", Enabled = true, Ok = true }, + new StartupCheckItem { Name = "Mailer", Enabled = false, Ok = false }, + ], + }); + var svc = Build(startupChecks: reporter); + + var report = svc.GetInfo().StartupChecks; + + Assert.NotNull(report); + Assert.True(report!.Ran); + Assert.Equal("BUILD-HOST", report.MachineName); + Assert.Equal(2, report.Items.Count); + Assert.Contains(report.Items, i => i.Name == "Database" && i.Enabled && i.Ok); + } + + [Fact] + public void GetInfo_DatabaseUnconfigured_WhenConnectionStringMissing() + { + var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" }); + var svc = Build(config); + + Assert.False(svc.GetInfo().Database.Configured); + } + + // ── Blob probe ───────────────────────────────────────────────────────────── + [Fact] + public async Task ProbeBlob_Disabled_ReturnsDisabled() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity { Enabled = false }); + var svc = Build(blob: blob); + + var r = await svc.ProbeAsync("blob"); + + Assert.Equal("blob", r.Component); + Assert.Equal("disabled", r.Status); + Assert.False(r.Ok); + } + + [Fact] + public async Task ProbeBlob_EnabledButUnconfigured_ReturnsUnconfigured() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = false, Detail = "keine Verbindungszeichenfolge" }); + var svc = Build(blob: blob); + + var r = await svc.ProbeAsync("blob"); + + Assert.Equal("unconfigured", r.Status); + Assert.False(r.Ok); + } + + [Fact] + public async Task ProbeBlob_Reachable_ReturnsOkWithAccountDetail() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = true, AccountName = "acct", Detail = "StorageV2 / Standard_LRS" }); + var svc = Build(blob: blob); + + var r = await svc.ProbeAsync("blob"); + + Assert.Equal("ok", r.Status); + Assert.True(r.Ok); + Assert.Contains("acct", r.Detail); + } + + [Fact] + public async Task ProbeBlob_Reachable_ReportsFileCountsPerContainerAsMetrics() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity + { + Enabled = true, Configured = true, Reachable = true, AccountName = "acct", + Detail = "StorageV2 / Standard_LRS", + Containers = new List + { + new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = 42 }, + new() { Name = "dev-fuchs-reminders", Exists = false, FileCount = 0 }, + }, + }); + var svc = Build(blob: blob); + + var r = await svc.ProbeAsync("blob"); + + Assert.Equal("ok", r.Status); + Assert.NotNull(r.Metrics); + Assert.Equal(2, r.Metrics!.Count); + var inv = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-invoices"); + Assert.Equal("42 Dateien", inv.Value); + var rem = Assert.Single(r.Metrics, m => m.Label == "dev-fuchs-reminders"); + Assert.Equal("nicht vorhanden", rem.Value); + Assert.Contains("42 Dateien gesamt", r.Message); // total across containers + } + + [Fact] + public async Task ProbeBlob_FileCountUnavailable_ReportsMetricWithoutFailing() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity + { + Enabled = true, Configured = true, Reachable = true, AccountName = "acct", + Containers = new List + { + new() { Name = "dev-fuchs-invoices", Exists = true, FileCount = -1 }, + }, + }); + var svc = Build(blob: blob); + + var r = await svc.ProbeAsync("blob"); + + Assert.Equal("ok", r.Status); + var inv = Assert.Single(r.Metrics!); + Assert.Equal("Anzahl nicht ermittelbar", inv.Value); + } + + [Fact] + public async Task ProbeBlob_ConfiguredButUnreachable_ReturnsError() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity { Enabled = true, Configured = true, Reachable = false, AccountName = "acct", Detail = "403 Forbidden" }); + var svc = Build(blob: blob); + + var r = await svc.ProbeAsync("blob"); + + Assert.Equal("error", r.Status); + Assert.False(r.Ok); + } + + // ── Key Vault probe ──────────────────────────────────────────────────────── + [Fact] + public async Task ProbeKeyVault_ClientNotRegistered_ReturnsUnconfigured() + { + var svc = Build(); // empty service provider → no SecretClient + + var r = await svc.ProbeAsync("keyvault"); + + Assert.Equal("keyvault", r.Component); + Assert.Equal("unconfigured", r.Status); + Assert.False(r.Ok); + } + + [Fact] + public async Task ProbeKeyVault_NoManagedKeys_ReturnsUnconfigured() + { + var config = Config(new() + { + ["SecretManagement:ManagedSecretKeys:0"] = null, + ["SecretManagement:ManagedSecretKeys:1"] = null, + }); + var svc = Build(config); + + var r = await svc.ProbeAsync("keyvault"); + + Assert.Equal("unconfigured", r.Status); + } + + // ── MFR probe ────────────────────────────────────────────────────────────── + [Fact] + public async Task ProbeMfr_FactoryThrows_ReturnsError() + { + var mfr = new Mock(); + mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("no credentials")); + var svc = Build(mfr: mfr); + + var r = await svc.ProbeAsync("mfr"); + + Assert.Equal("mfr", r.Component); + Assert.Equal("error", r.Status); + Assert.False(r.Ok); + Assert.Equal("no credentials", r.Detail); + } + + // ── Database probe (unconfigured is deterministic without a live server) ──── + [Fact] + public async Task ProbeDatabase_NoConnectionString_ReturnsUnconfigured() + { + var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" }); + var svc = Build(config); + + var r = await svc.ProbeAsync("database"); + + Assert.Equal("database", r.Component); + Assert.Equal("unconfigured", r.Status); + Assert.False(r.Ok); + } + + // ── Unknown component ────────────────────────────────────────────────────── + [Fact] + public async Task ProbeAsync_UnknownComponent_ReturnsError() + { + var svc = Build(); + + var r = await svc.ProbeAsync("does-not-exist"); + + Assert.Equal("error", r.Status); + Assert.False(r.Ok); + } + + [Fact] + public async Task ProbeAllAsync_ReturnsOneResultPerComponent() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity { Enabled = false }); + var mfr = new Mock(); + mfr.Setup(f => f.Create()).Throws(new InvalidOperationException("x")); + var config = Config(new() { ["ConnectionStrings:fuchs_fds_ConnectionString"] = "" }); + var svc = Build(config, blob: blob, mfr: mfr); + + var results = await svc.ProbeAllAsync(); + + Assert.Equal(svc.ProbeComponents.Count, results.Count); + foreach (var comp in svc.ProbeComponents) + Assert.Contains(results, r => r.Component == comp); + Assert.All(results, r => Assert.True(r.DurationMs >= 0)); + } + + [Fact] + public async Task ProbeAsync_EmitsProbeTelemetry() + { + var blob = new Mock(); + blob.Setup(b => b.CheckConnectivityAsync(It.IsAny())) + .ReturnsAsync(new BlobConnectivity { Enabled = false }); + var svc = Build(blob: blob); + + long delta = 0; + using var listener = new MeterListener + { + InstrumentPublished = (inst, l) => + { + if (inst.Meter.Name == FuchsMeterName && inst.Name == "fuchs.systemstatus.probes") + l.EnableMeasurementEvents(inst); + } + }; + listener.SetMeasurementEventCallback((_, value, _, _) => Interlocked.Add(ref delta, value)); + listener.Start(); + + await svc.ProbeAsync("blob"); + + Assert.True(delta >= 1, "fuchs.systemstatus.probes counter should increment per probe."); + } + + // ── Test email ───────────────────────────────────────────────────────────── + [Theory] + [InlineData("", "subj", "body")] + [InlineData("to@example.test", "", "body")] + public async Task SendTestEmailAsync_MissingRequiredFields_DoesNotSend(string to, string subject, string body) + { + var com = new Mock(); + var svc = Build(com: com); + + var r = await svc.SendTestEmailAsync(to, subject, body); + + Assert.False(r.Sent); + com.Verify(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny>()), Times.Never); + } + + [Fact] + public async Task SendTestEmailAsync_Success_SendsHtmlEncodedBodyToRecipient() + { + string? capturedHtml = null, capturedTo = null, capturedSubject = null; + var com = new Mock(); + com.Setup(c => c.SendEmailAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny?>())) + .Callback?>( + (_, subj, html, to, _, _) => { capturedSubject = subj; capturedHtml = html; capturedTo = to; }) + .ReturnsAsync(true); + var svc = Build(com: com); + + var r = await svc.SendTestEmailAsync("dest@example.test", "Betreff", "Zeile1