Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions.Tests/SubmitOffboardingFeedbackFunctionTests.cs
T
Oliver Kieselbach d763a885e2 Ops alert channels, ops-events server-side filters, and generated MCP tool vocabularies (#140)
* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad

Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer
tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad.
Ein neuer Provider haette beide anfassen muessen.

Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider —
WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST.
SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es
keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession,
ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification).
SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService
bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern.

Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token
gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb
ist das Gate eine Server-Regel, keine versteckte Dropdown-Option:
ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE
Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der
Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter
Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann.
Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und
wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft.

Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList /
RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin,
statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson +
GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload,
NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0).

Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von
Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im
Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus
SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar,
damit ein Speichern ihn nicht still verwirft.

Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den
SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate
invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests
decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub,
der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots

OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in
AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel
wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden
hiess also: das Sales-Team bekommt auch jede Security-Regel.

Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe
NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds
bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und
vereinigt ihre Ziele.

Rueckwaertskompatibel ohne Migrationsjob:
- Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot
  einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und
  uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet.
- GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor
  zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert.
  Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher
  wegdriftet.
- Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel
  traegt, also verhaelt sich am Deploy-Tag nichts anders.
- Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel,
  deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles.

Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr
geschrieben — aufraeumen erst, wenn produktiv Channels stehen.

Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von
RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen
Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes
Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die
auch die Tenant-Liste passiert.

Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog
registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und
wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen.

Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext
mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen
Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der
gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt
ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine
Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf
positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine
Webhook-URL in den falschen Provider-Slot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(backend): TenantTrialStarted + Event-Payload optional pro Regel

Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der
GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den
Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event.

RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden
per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist
fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad
haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still,
ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null)
ist ein Downgrade und bleibt bei TenantPlanDowngraded.

Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein
Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst:
ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen,
und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das
Informationsniveau aller bestehenden Regeln bleibt damit unveraendert.

ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch
eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und
ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln.

Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte
<= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext
dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer
gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein
stabiler Schluessel zum Verzweigen.

Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die
Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung,
schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben
ungetestet — Suite-Konvention ist, die reinen Kerne zu testen.

Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md +
docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events
Query-Surface), und ein Teil-Commit darin haette es mitgenommen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies

The ops-events read could only narrow by category and date window, so answering
"did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and
filtering it client-side — and for the MCP tool that client is a language model,
so the question cost a category-sized response in model context.

Ops-events filter surface (mirrors the audit-log filter pattern one-for-one):
- OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters +
  pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters ->
  OData clauses). BOTH filter builders call the appender — the single-category /
  unpaged one and the per-partition fan-out builder that adds the RowKey bound —
  so "no category named" cannot silently return unfiltered rows.
- minSeverity expands to an OR-set, not a range: Table Storage compares strings
  lexicographically and the severity names sort Critical < Error < Info <
  Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half.
  The ladder now lives in OpsEventSeverity.Rank, shared with
  OpsAlertDispatchService, so "Warning and above" in a read means what it meant
  when the alert rules fired.
- severity is normalised onto the canonical vocabulary and an unknown value is a
  400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an
  empty page that reads exactly like "no such events happened". eventType stays
  verbatim — stored rows outlive the vocabulary, so a retired type must remain
  searchable in the historical window.
- Every filter value is folded into the continuation fingerprint and echoed on
  nextLink; extras are appended AFTER the pre-existing category/tenantId
  discriminators and skipped when empty, so tokens minted before the filters
  existed still fingerprint identically. The tenantId drill deliberately stays
  in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing).
- MCP get_ops_events gains eventType / severity / minSeverity plus a `days`
  shorthand resolved client-side to a concrete dateFrom (a backend-side "last N
  days" would re-resolve "now" on every page and blow the token fingerprint);
  an explicit bound always wins over the shorthand.

Fixed alongside: the paged cross-category fan-out skipped Platform (Azure
Monitor alerts) because its category list had drifted from OpsEventCategory —
written from day one, invisible to every paged reader, still visible on the
unpaged full-table path. The list is now OpsEventCategory.All plus a reflection
coverage test.

That drift had a structural cause, so the same round closes it for the MCP as a
whole: wire TYPES reached the MCP from day one, but vocabularies did not — types
are erased at runtime and z.enum() needs values, so every tool schema hand-typed
its own copy with nothing to compare against. The copies had drifted:
search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete
— both real stored statuses), both event readers offered 4 of 6 severities (no
Debug, no Trace), get_ops_events named 6 of 7 ops categories.

- The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES:
  session statuses, event severities, ops categories/severities/types,
  annotation lanes/verdicts) from the manifest sections listed in
  MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops
  vocabularies. Freshness is pinned twice — byte-equality against a fresh
  codegen run in the MCP suite, and the shared-manifests-in-sync CI job.
- Tool schemas derive their z.enum() from it and descriptions interpolate the
  list instead of spelling it.
- vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..])
  must sit on a baseline naming why it has no backend owner (MCP-local knobs,
  plus rule type / connection type / CVE risk, which have no C# constants class
  yet). A new hand-typed list fails, and so does a stale baseline entry.
- OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the
  OpsEventService write sites use the constants (a raw literal now fails
  OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection),
  the manifest exports them, and get_resource(name="ops_event_types") serves
  categories + severities + types to the model from the generated copy.

Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent.

Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an
older backend gets silently unfiltered rows back and presents them as filtered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: register the ops notification channels doc in the index and log

The concept doc landed with the feature commits; its index entry and log entry
were still uncommitted in the working tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:57:41 +02:00

323 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Notifications;
using AutopilotMonitor.Functions.Tests.Offboarding;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Offboarding;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Unit tests for the offboarding-feedback submission helper. The HTTP entrypoint itself
/// sits on top of <see cref="HttpRequestData"/> (abstract, hard to fake without booting the
/// Functions host) so these tests target <c>ProcessSubmitAsync</c> directly — same pattern
/// used by <c>TenantOffboardFunctionTests</c> for <c>ResumeExistingMarkerAsync</c>.
/// </summary>
public sealed class SubmitOffboardingFeedbackFunctionTests
{
private const string TenantId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string HistoryRowKey = "20260519091523123_aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
private const string Upn = "alice@contoso.invalid";
private const string DisplayName = "Alice (Contoso)";
[Fact]
public async Task Submit_HappyPath_PersistsFeedback_AndEmitsOpsEvent()
{
var h = Harness.WithInProgressMarker();
var result = await h.Sut.ProcessSubmitAsync(
TenantId, Upn, DisplayName, "Pricing was too high for our use case.");
Assert.Equal(SubmitOutcome.Ok, result.Outcome);
Assert.NotNull(h.Repo.PersistedOffboardingFeedback);
Assert.Equal(HistoryRowKey, h.Repo.PersistedOffboardingFeedback!.HistoryRowKey);
Assert.Equal(TenantId, h.Repo.PersistedOffboardingFeedback.TenantId);
Assert.Equal(Upn, h.Repo.PersistedOffboardingFeedback.Upn);
Assert.Equal(DisplayName, h.Repo.PersistedOffboardingFeedback.DisplayName);
Assert.Equal("contoso.invalid", h.Repo.PersistedOffboardingFeedback.DomainName);
Assert.Equal("Pricing was too high for our use case.", h.Repo.PersistedOffboardingFeedback.Comment);
Assert.NotNull(h.Repo.PersistedOffboardingFeedback.InteractedAt);
// OpsEvent emitted with the canonical type catalog-registered for Telegram-routing.
var ops = Assert.Single(h.OpsEvents);
Assert.Equal("OffboardingFeedbackReceived", ops.EventType);
Assert.Equal(TenantId, ops.TenantId);
Assert.Equal(Upn, ops.UserId);
}
[Fact]
public async Task Submit_TrimsAndTruncatesComment_To4096Chars()
{
// Departing tenants often have more context to share than the in-app 500-char
// limit allows; the offboarding endpoint accepts up to 4096 chars (still well
// under the Azure Tables 64 KB property cap). Anything beyond is silently
// truncated rather than rejected so the user's submit doesn't fail outright.
var h = Harness.WithInProgressMarker();
var longComment = new string('x', 5000);
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, " " + longComment + " ");
Assert.Equal(SubmitOutcome.Ok, result.Outcome);
Assert.Equal(4096, h.Repo.PersistedOffboardingFeedback!.Comment!.Length);
Assert.DoesNotContain(" ", h.Repo.PersistedOffboardingFeedback.Comment!.Substring(0, 1));
}
[Fact]
public async Task Submit_EmptyComment_400_AndNoSave()
{
var h = Harness.WithInProgressMarker();
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "");
Assert.Equal(SubmitOutcome.BadRequest, result.Outcome);
Assert.Null(h.Repo.PersistedOffboardingFeedback);
Assert.Empty(h.OpsEvents);
}
[Fact]
public async Task Submit_WhitespaceOnlyComment_400_AndNoSave()
{
var h = Harness.WithInProgressMarker();
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, " \t\n ");
Assert.Equal(SubmitOutcome.BadRequest, result.Outcome);
Assert.Null(h.Repo.PersistedOffboardingFeedback);
}
[Fact]
public async Task Submit_NullComment_400_AndNoSave()
{
var h = Harness.WithInProgressMarker();
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, null);
Assert.Equal(SubmitOutcome.BadRequest, result.Outcome);
Assert.Null(h.Repo.PersistedOffboardingFeedback);
}
[Fact]
public async Task Submit_NoMarker_404_AndNoSave()
{
var h = Harness.WithNoMarker();
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "Some thoughts.");
Assert.Equal(SubmitOutcome.NotFound, result.Outcome);
Assert.Null(h.Repo.PersistedOffboardingFeedback);
}
[Fact]
public async Task Submit_CompletedMarker_409_AndNoSave()
{
var h = Harness.WithMarker("Completed");
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "Too late, sorry");
Assert.Equal(SubmitOutcome.Conflict, result.Outcome);
Assert.Contains("Completed", result.Message ?? "");
Assert.Null(h.Repo.PersistedOffboardingFeedback);
}
[Fact]
public async Task Submit_FailedMarker_409_AndNoSave()
{
var h = Harness.WithMarker("Failed");
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "Feedback");
Assert.Equal(SubmitOutcome.Conflict, result.Outcome);
Assert.Null(h.Repo.PersistedOffboardingFeedback);
}
[Fact]
public async Task Submit_InitiatedMarker_OkPath()
{
var h = Harness.WithMarker("Initiated");
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "Initial thoughts.");
Assert.Equal(SubmitOutcome.Ok, result.Outcome);
}
[Fact]
public async Task Submit_RepositoryThrows_500_AndNoOpsEvent()
{
var h = Harness.WithInProgressMarker();
h.Repo.ThrowOnSaveOffboardingFeedback =
new InvalidOperationException("simulated table-storage outage");
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "Feedback");
Assert.Equal(SubmitOutcome.InternalError, result.Outcome);
Assert.Empty(h.OpsEvents);
}
[Fact]
public async Task Submit_OpsEventThrows_StillReturnsOk_BestEffort()
{
// OpsEvent is best-effort observability — its failure must NOT mask the successful
// user-feedback persistence. The end user's submit succeeded; the Ops dashboard
// misses one row.
var h = Harness.WithInProgressMarker(opsEventThrows: true);
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "Feedback");
Assert.Equal(SubmitOutcome.Ok, result.Outcome);
Assert.NotNull(h.Repo.PersistedOffboardingFeedback);
}
[Fact]
public async Task Submit_HistoryRowMissing_StillSucceeds_WithNullDomainName()
{
// Defensive: marker references a history rowKey that no longer resolves (timing race
// or admin manually deleted history). Submit still succeeds; DomainName falls through
// to null so the Reports page shows the tenantId GUID instead of a friendly domain.
var h = Harness.WithInProgressMarker(includeHistoryRow: false);
var result = await h.Sut.ProcessSubmitAsync(TenantId, Upn, DisplayName, "Feedback");
Assert.Equal(SubmitOutcome.Ok, result.Outcome);
Assert.NotNull(h.Repo.PersistedOffboardingFeedback);
Assert.Null(h.Repo.PersistedOffboardingFeedback!.DomainName);
}
// ── Harness ─────────────────────────────────────────────────────────────
private sealed class Harness
{
public SubmitOffboardingFeedbackFunction Sut { get; }
public FakeOffboardingAuditRepository Audit { get; }
public FakeFeedbackRepository Repo { get; }
public List<OpsEventEntry> OpsEvents { get; } = new();
private Harness(FakeOffboardingAuditRepository audit, FakeFeedbackRepository repo,
OpsEventService opsEvents, List<OpsEventEntry> opsCapture)
{
Audit = audit; Repo = repo; OpsEvents = opsCapture;
Sut = new SubmitOffboardingFeedbackFunction(
NullLogger<SubmitOffboardingFeedbackFunction>.Instance,
audit, repo, opsEvents);
}
public static Harness WithInProgressMarker(bool includeHistoryRow = true, bool opsEventThrows = false)
=> Build("InProgress", includeHistoryRow, opsEventThrows);
public static Harness WithMarker(string status, bool includeHistoryRow = true)
=> Build(status, includeHistoryRow, opsEventThrows: false);
public static Harness WithNoMarker()
{
var (ops, capture) = BuildOpsEventService(opsEventThrows: false);
return new Harness(new FakeOffboardingAuditRepository(),
new FakeFeedbackRepository(), ops, capture);
}
private static Harness Build(string status, bool includeHistoryRow, bool opsEventThrows)
{
var audit = new FakeOffboardingAuditRepository();
audit.Markers[TenantId] = new OffboardingMarkerEntry
{
PartitionKey = Constants.OffboardingPartitionKeys.Marker,
RowKey = TenantId,
TenantId = TenantId,
OffboardingHistoryRowKey = HistoryRowKey,
InitiatedAt = DateTime.UtcNow.AddMinutes(-1),
InitiatedBy = Upn,
Status = status,
};
if (includeHistoryRow)
{
audit.History[HistoryRowKey] = new OffboardingHistoryEntry
{
PartitionKey = Constants.OffboardingPartitionKeys.History,
RowKey = HistoryRowKey,
TenantId = TenantId,
DomainName = "contoso.invalid",
InitiatedBy = Upn,
OffboardedAt = DateTime.UtcNow.AddMinutes(-1),
Status = status,
};
}
var (ops, capture) = BuildOpsEventService(opsEventThrows);
return new Harness(audit, new FakeFeedbackRepository(), ops, capture);
}
/// <summary>
/// Builds a real <see cref="OpsEventService"/> with a stub repository that captures
/// every <c>SaveOpsEventAsync</c> into a list for assertions. Real service =&gt; we
/// exercise the actual <c>RecordOffboardingFeedbackReceivedAsync</c> branch, including
/// the catalog-registered EventType name.
/// </summary>
private static (OpsEventService Service, List<OpsEventEntry> Captured) BuildOpsEventService(bool opsEventThrows)
{
var captured = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
.Callback<OpsEventEntry>(e =>
{
if (opsEventThrows)
throw new InvalidOperationException("simulated OpsEvent storage outage");
captured.Add(e);
})
.Returns(Task.CompletedTask);
// Alert-dispatch dependencies are inert: empty AdminConfig service + bare HTTP
// clients. The feedback path emits Info-severity so even if alerts ARE evaluated,
// no Telegram/webhook fires for an unconfigured rule set.
var memCache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(),
NullLogger<AdminConfigurationService>.Instance,
memCache);
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
return (
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch),
captured);
}
}
/// <summary>
/// In-memory feedback repo. Only <see cref="SaveOffboardingFeedbackAsync"/> is exercised
/// by this function; the other methods throw to make accidental coupling visible.
/// </summary>
private sealed class FakeFeedbackRepository : IFeedbackRepository
{
public FeedbackEntry? PersistedOffboardingFeedback { get; private set; }
public Exception? ThrowOnSaveOffboardingFeedback { get; set; }
public Task<FeedbackEntry?> GetInAppFeedbackAsync(string upn)
=> throw new NotSupportedException("Test should not read InApp feedback");
public Task SaveInAppFeedbackAsync(FeedbackEntry entry)
=> throw new NotSupportedException("Test should not write InApp feedback");
public Task<FeedbackEntry?> GetOffboardingFeedbackAsync(string historyRowKey)
=> throw new NotSupportedException("Test should not read offboarding feedback");
public Task SaveOffboardingFeedbackAsync(FeedbackEntry entry)
{
if (ThrowOnSaveOffboardingFeedback is { } ex) throw ex;
PersistedOffboardingFeedback = entry;
return Task.CompletedTask;
}
public Task<List<FeedbackEntry>> GetAllAsync()
=> throw new NotSupportedException("Test should not enumerate feedback");
}
}