using System.Globalization; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.GalaxyRepository; using ZB.MOM.WW.GalaxyRepository.Grpc; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; using ZB.MOM.WW.MxGateway.Server.Workers; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; public sealed class DashboardSnapshotServiceTests { /// /// Verifies snapshot returns empty collections and healthy status when registry is empty. /// [Fact] public void GetSnapshot_WhenRegistryEmpty_ReturnsEmptyOperationalState() { using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Empty(snapshot.Sessions); Assert.Empty(snapshot.Workers); Assert.Empty(snapshot.Faults); Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.sessions.open" && metric.Value == 0); Assert.Equal("Healthy", snapshot.GatewayStatus); Assert.NotNull(snapshot.Configuration); } /// /// Verifies snapshot projects active, faulted, and closed session states with worker and metrics data. /// [Fact] public void GetSnapshot_ProjectsActiveAndFaultedSessionsWorkersMetricsAndFaults() { SessionRegistry registry = new(); GatewaySession activeSession = CreateSession( "session-active", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); activeSession.AttachWorkerClient(new FakeWorkerClient("session-active", 1201, WorkerClientState.Ready)); activeSession.MarkReady(); GatewaySession faultedSession = CreateSession( "session-faulted", "client-two", DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture)); faultedSession.AttachWorkerClient(new FakeWorkerClient("session-faulted", 1202, WorkerClientState.Faulted)); faultedSession.MarkFaulted("worker pipe disconnected"); GatewaySession closedSession = CreateSession( "session-closed", "client-three", DateTimeOffset.Parse("2026-04-26T09:59:00Z", CultureInfo.InvariantCulture)); closedSession.AttachWorkerClient(new FakeWorkerClient("session-closed", 1203, WorkerClientState.Closed)); closedSession.TransitionTo(SessionState.Closed); registry.TryAdd(activeSession); registry.TryAdd(faultedSession); registry.TryAdd(closedSession); using GatewayMetrics metrics = new(); metrics.SessionOpened(); metrics.SessionOpened(); metrics.CommandStarted("Register"); metrics.CommandFailed("Register", "WorkerFaulted", TimeSpan.FromMilliseconds(7)); metrics.EventReceived("session-active", "OnDataChange"); metrics.Fault("WorkerFaulted"); DashboardSnapshotService service = CreateService(registry, metrics); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Equal(3, snapshot.Sessions.Count); Assert.Equal("session-faulted", snapshot.Sessions[0].SessionId); Assert.Equal(SessionState.Faulted, snapshot.Sessions[0].State); DashboardSessionSummary activeSummary = Assert.Single( snapshot.Sessions, session => session.SessionId == "session-active"); Assert.Equal(1, activeSummary.EventsReceived); Assert.Equal(2, snapshot.Workers.Count); Assert.DoesNotContain(snapshot.Workers, worker => worker.SessionId == "session-closed"); Assert.Contains(snapshot.Metrics, metric => metric.Name == "mxgateway.commands.started" && metric.Value == 1); Assert.Contains( snapshot.Metrics, metric => metric.Name == "mxgateway.events.received" && metric.Dimension == "OnDataChange" && metric.Value == 1); DashboardFaultSummary fault = Assert.Single(snapshot.Faults); Assert.Equal("Worker", fault.Source); Assert.Equal("session-faulted", fault.SessionId); Assert.Equal("worker pipe disconnected", fault.Message); } /// /// Verifies snapshot metrics include the cumulative alarm provider switch count. /// [Fact] public void GetSnapshot_IncludesAlarmProviderSwitchCountMetric() { SessionRegistry registry = new(); using GatewayMetrics metrics = new(); metrics.AlarmProviderSwitched(1, 2, AlarmProviderSwitchReason.Failover); DashboardSnapshotService service = CreateService(registry, metrics); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Contains( snapshot.Metrics, metric => metric.Name == "mxgateway.alarms.provider_switches" && metric.Value == 1); } /// /// Verifies snapshot redacts sensitive values from client identity, session name, and fault messages. /// [Fact] public void GetSnapshot_RedactsSecretsFromSessionAndFaultFields() { SessionRegistry registry = new(); GatewaySession session = CreateSession( "session-redacted", "Bearer mxgw_admin_super-secret", DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture), clientSessionName: "password=hunter2", clientCorrelationId: "token=abc123"); session.MarkFaulted("secret=credential-value"); registry.TryAdd(session); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(registry, metrics); DashboardSnapshot snapshot = service.GetSnapshot(); DashboardSessionSummary summary = Assert.Single(snapshot.Sessions); Assert.Equal("Bearer mxgw_admin_[redacted]", summary.ClientIdentity); Assert.Equal("[redacted]", summary.ClientSessionName); Assert.Equal("[redacted]", summary.ClientCorrelationId); Assert.Equal("[redacted]", summary.LastFault); Assert.Equal("[redacted]", Assert.Single(snapshot.Faults).Message); Assert.Equal("[redacted]", snapshot.Configuration.Authentication.PepperSecretName); } /// /// Verifies snapshot generation does not mutate session or worker client state. /// [Fact] public void GetSnapshot_DoesNotMutateSessionOrWorkerState() { SessionRegistry registry = new(); GatewaySession session = CreateSession( "session-active", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); FakeWorkerClient workerClient = new("session-active", 1201, WorkerClientState.Ready); session.AttachWorkerClient(workerClient); session.MarkReady(); registry.TryAdd(session); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(registry, metrics); service.GetSnapshot(); service.GetSnapshot(); Assert.Equal(1, registry.ActiveCount); Assert.Equal(SessionState.Ready, session.State); Assert.Equal(WorkerClientState.Ready, workerClient.State); Assert.Equal(0, workerClient.StartCount); Assert.Equal(0, workerClient.ShutdownCount); Assert.Equal(0, workerClient.KillCount); } /// /// Verifies snapshot respects configured limits for recent sessions and faults. /// [Fact] public void GetSnapshot_AppliesRecentSessionAndFaultLimits() { SessionRegistry registry = new(); GatewaySession olderSession = CreateSession( "session-older", "client-one", DateTimeOffset.Parse("2026-04-26T10:00:00Z", CultureInfo.InvariantCulture)); GatewaySession newerSession = CreateSession( "session-newer", "client-two", DateTimeOffset.Parse("2026-04-26T10:01:00Z", CultureInfo.InvariantCulture)); olderSession.MarkFaulted("older fault"); newerSession.MarkFaulted("newer fault"); registry.TryAdd(olderSession); registry.TryAdd(newerSession); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService( registry, metrics, new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, RecentSessionLimit = 1, RecentFaultLimit = 1, }, }); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Equal("session-newer", Assert.Single(snapshot.Sessions).SessionId); Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId); } /// /// Verifies snapshot projects Galaxy hierarchy cache data including templates and categories. /// [Fact] public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache() { // The shared-library cache entry no longer carries a precomputed dashboard summary; // DashboardSnapshotService derives templates/categories from the entry's objects via // DashboardGalaxySummaryProjector. Seed objects that yield $Pump x2 / $Area x1 templates and // categories UserDefined(10) x2 / Area(13) x1, matching the asserted summary. GalaxyObject[] objects = [ new GalaxyObject { GobjectId = 1, BrowseName = "AreaA", IsArea = true, CategoryId = 13, TemplateChain = { "$Area" }, }, new GalaxyObject { GobjectId = 2, BrowseName = "Pump01", CategoryId = 10, TemplateChain = { "$Pump" }, }, new GalaxyObject { GobjectId = 3, BrowseName = "Pump02", CategoryId = 10, TemplateChain = { "$Pump" }, }, ]; GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 7, LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z", CultureInfo.InvariantCulture), LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z", CultureInfo.InvariantCulture), Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), ObjectCount = 3, AreaCount = 1, AttributeCount = 2, HistorizedAttributeCount = 1, AlarmAttributeCount = 1, }; using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, galaxyHierarchyCache: new StubGalaxyHierarchyCache(entry)); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Equal(DashboardGalaxyStatus.Healthy, snapshot.Galaxy.Status); Assert.Equal(3, snapshot.Galaxy.ObjectCount); Assert.Equal(1, snapshot.Galaxy.AreaCount); Assert.Equal(2, snapshot.Galaxy.AttributeCount); Assert.Equal(2, snapshot.Galaxy.TopTemplates.Count); Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName); Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount); Assert.Contains(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Area" && t.InstanceCount == 1); Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2); Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1); } /// /// The shared library replaces the cache entry via /// previous with { ... } at the same Sequence on a steady-state tick and on a /// refresh failure (Status → Unavailable, LastError set). The dashboard summary must reflect /// those per-tick-volatile fields, not a summary frozen at the last heavy-refresh sequence — /// otherwise the Galaxy health indicator keeps showing Healthy throughout a SQL outage. /// [Fact] public void GetSnapshot_WhenGalaxyEntryChangesAtSameSequence_ReflectsVolatileStatusAndError() { GalaxyHierarchyCacheEntry healthy = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 5, LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:00:00Z", CultureInfo.InvariantCulture), LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:00:00Z", CultureInfo.InvariantCulture), }; MutableGalaxyHierarchyCache cache = new(healthy); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache); Assert.Equal(DashboardGalaxyStatus.Healthy, service.GetSnapshot().Galaxy.Status); // SQL outage: no heavy refresh can succeed, so Sequence cannot advance; the library // mutates the entry in place to Unavailable with an error at the same Sequence. cache.Current = healthy with { Status = GalaxyCacheStatus.Unavailable, LastError = "SQL unreachable", LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:00:05Z", CultureInfo.InvariantCulture), }; DashboardGalaxySummary summary = service.GetSnapshot().Galaxy; Assert.Equal(DashboardGalaxyStatus.Unavailable, summary.Status); Assert.Equal("SQL unreachable", summary.LastError); Assert.Equal( DateTimeOffset.Parse("2026-04-28T11:00:05Z", CultureInfo.InvariantCulture), summary.LastQueriedAt); } /// /// Verifies the O(N) template/category breakdown is memoized on the cache Sequence: in /// production the object set only changes when the library bumps Sequence on a heavy refresh, /// so the breakdown is reused at an unchanged Sequence. Proves the memo keys on Sequence by /// swapping Objects at the same Sequence and asserting the breakdown does not recompute. /// [Fact] public void GetSnapshot_WhenSequenceUnchanged_ReusesExpensiveTemplateBreakdown() { GalaxyObject[] first = [new GalaxyObject { GobjectId = 1, BrowseName = "Pump01", CategoryId = 10, TemplateChain = { "$Pump" } }]; GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 9, Objects = first, Index = GalaxyHierarchyIndex.Build(first), ObjectCount = 1, }; MutableGalaxyHierarchyCache cache = new(entry); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache); Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName); GalaxyObject[] second = [new GalaxyObject { GobjectId = 2, BrowseName = "Valve01", CategoryId = 10, TemplateChain = { "$Valve" } }]; cache.Current = entry with { Objects = second, Index = GalaxyHierarchyIndex.Build(second) }; // Same Sequence (9) → breakdown reused → still "$Pump", not "$Valve". Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName); } /// /// Verifies that a changed cache Sequence invalidates the memoized template breakdown and the /// next snapshot reflects the new object set. Guards against an inverted sequence check that /// would freeze the Galaxy section after its first load. /// [Fact] public void GetSnapshot_WhenSequenceChanges_RecomputesTemplateBreakdown() { GalaxyObject[] first = [new GalaxyObject { GobjectId = 1, BrowseName = "Pump01", CategoryId = 10, TemplateChain = { "$Pump" } }]; GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Sequence = 9, Objects = first, Index = GalaxyHierarchyIndex.Build(first), ObjectCount = 1, }; MutableGalaxyHierarchyCache cache = new(entry); using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService(new SessionRegistry(), metrics, galaxyHierarchyCache: cache); Assert.Equal("$Pump", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName); GalaxyObject[] second = [new GalaxyObject { GobjectId = 2, BrowseName = "Valve01", CategoryId = 10, TemplateChain = { "$Valve" } }]; cache.Current = entry with { Sequence = 10, Objects = second, Index = GalaxyHierarchyIndex.Build(second) }; // New Sequence (10) → breakdown recomputed → now "$Valve". Assert.Equal("$Valve", Assert.Single(service.GetSnapshot().Galaxy.TopTemplates).TemplateName); } /// /// Verifies snapshot watcher cancels cleanly when subscriber cancels. /// [Fact] public void GetSnapshot_DoesNotSynchronouslyListApiKeys() { using GatewayMetrics metrics = new(); CountingApiKeyAdminStore apiKeyAdminStore = new(); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, apiKeyAdminStore: apiKeyAdminStore); DashboardSnapshot snapshot = service.GetSnapshot(); Assert.Empty(snapshot.ApiKeys); Assert.Equal(0, apiKeyAdminStore.ListCount); } /// Verifies that snapshot service refreshes API key summaries before each snapshot. /// A task that represents the asynchronous operation. [Fact] public async Task WatchSnapshotsAsync_RefreshesApiKeySummariesBeforeSnapshot() { using GatewayMetrics metrics = new(); CountingApiKeyAdminStore apiKeyAdminStore = new( new ApiKeyListItem( KeyId: "operator01", KeyPrefix: "mxgw", DisplayName: "Operator", Scopes: new HashSet([GatewayScopes.MetadataRead], StringComparer.Ordinal), ConstraintsJson: ApiKeyConstraintSerializer.Serialize(ApiKeyConstraints.Empty with { BrowseSubtrees = ["Area1/*"], }), CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture), LastUsedUtc: null, RevokedUtc: null)); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, apiKeyAdminStore: apiKeyAdminStore); using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2)); await using IAsyncEnumerator enumerator = service .WatchSnapshotsAsync(cancellation.Token) .GetAsyncEnumerator(cancellation.Token); Assert.True(await enumerator.MoveNextAsync()); DashboardSnapshot snapshot = enumerator.Current; DashboardApiKeySummary key = Assert.Single(snapshot.ApiKeys); Assert.Equal("operator01", key.KeyId); Assert.Equal(["Area1/*"], key.Constraints.BrowseSubtrees); Assert.Equal(1, apiKeyAdminStore.ListCount); } /// Verifies that snapshot service reuses previous summaries when API key refresh fails. /// A task that represents the asynchronous operation. [Fact] public async Task WatchSnapshotsAsync_WhenApiKeyRefreshFails_ReusesPreviousSummaries() { using GatewayMetrics metrics = new(); SequencedApiKeyAdminStore apiKeyAdminStore = new( new ApiKeyListItem( KeyId: "operator01", KeyPrefix: "mxgw", DisplayName: "Operator", Scopes: new HashSet([GatewayScopes.MetadataRead], StringComparer.Ordinal), ConstraintsJson: null, CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture), LastUsedUtc: null, RevokedUtc: null)); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, }, }, apiKeyAdminStore: apiKeyAdminStore); using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(2)); await using IAsyncEnumerator enumerator = service .WatchSnapshotsAsync(cancellation.Token) .GetAsyncEnumerator(cancellation.Token); Assert.True(await enumerator.MoveNextAsync()); DashboardSnapshot first = enumerator.Current; apiKeyAdminStore.FailNext = true; Assert.True(await enumerator.MoveNextAsync()); DashboardSnapshot second = enumerator.Current; Assert.Equal("operator01", Assert.Single(first.ApiKeys).KeyId); Assert.Equal("operator01", Assert.Single(second.ApiKeys).KeyId); Assert.Equal(2, apiKeyAdminStore.ListCount); } /// Verifies that snapshot service disposes cleanly when subscriber cancels. /// A task that represents the asynchronous operation. [Fact] public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly() { using GatewayMetrics metrics = new(); DashboardSnapshotService service = CreateService( new SessionRegistry(), metrics, new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, }, }); using CancellationTokenSource cancellation = new(); await using IAsyncEnumerator enumerator = service .WatchSnapshotsAsync(cancellation.Token) .GetAsyncEnumerator(); Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1))); await cancellation.CancelAsync(); bool hasNext = await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)); Assert.False(hasNext); } private static DashboardSnapshotService CreateService( SessionRegistry registry, GatewayMetrics metrics, GatewayOptions? options = null, IGalaxyHierarchyCache? galaxyHierarchyCache = null, IApiKeyAdminStore? apiKeyAdminStore = null) { GatewayOptions resolvedOptions = options ?? new GatewayOptions { Dashboard = new DashboardOptions { SnapshotIntervalMilliseconds = 1, }, }; GatewayConfigurationProvider configurationProvider = new(Options.Create(resolvedOptions)); return new DashboardSnapshotService( registry, metrics, configurationProvider, galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty), apiKeyAdminStore ?? new FakeApiKeyAdminStore(), Options.Create(resolvedOptions)); } private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { /// /// Gets the current Galaxy hierarchy cache entry. /// public GalaxyHierarchyCacheEntry Current { get; } = current; /// /// Refreshes the cache asynchronously. /// /// Cancellation token. /// Completed task. public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// /// Waits for the first cache load asynchronously. /// /// Cancellation token. /// Completed task. public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class MutableGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { /// Gets or sets the current Galaxy hierarchy cache entry, swappable between snapshots. public GalaxyHierarchyCacheEntry Current { get; set; } = current; /// Refreshes the cache asynchronously. /// Cancellation token. /// Completed task. public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// Waits for the first cache load asynchronously. /// Cancellation token. /// Completed task. public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private class FakeApiKeyAdminStore : IApiKeyAdminStore { /// Does nothing; the fake never persists created keys. /// API key record to create. /// Cancellation token. /// A task that represents the asynchronous operation. public Task CreateAsync(ApiKeyRecord record, CancellationToken ct) { return Task.CompletedTask; } /// Lists API keys; the base fake always returns an empty list. /// Cancellation token. /// An empty list of API key summaries. public virtual Task> ListAsync(CancellationToken ct) { return Task.FromResult>([]); } /// Does nothing; the fake never revokes keys. /// Key identifier to revoke. /// Revocation timestamp. /// Cancellation token. /// , always. public Task RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) { return Task.FromResult(false); } /// Does nothing; the fake never rotates key secrets. /// Key identifier to rotate. /// New secret hash. /// Cancellation token. /// , always. public Task RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct) { return Task.FromResult(false); } /// Does nothing; the fake never deletes keys. /// Key identifier to delete. /// Cancellation token. /// , always. public Task DeleteAsync(string keyId, CancellationToken ct) { return Task.FromResult(false); } } private class CountingApiKeyAdminStore(params ApiKeyListItem[] records) : FakeApiKeyAdminStore { /// Gets the count of list operations performed. public int ListCount { get; protected set; } /// public override Task> ListAsync(CancellationToken ct) { ListCount++; return Task.FromResult>(records); } } private sealed class SequencedApiKeyAdminStore(ApiKeyListItem record) : CountingApiKeyAdminStore(record) { /// Gets or sets a value indicating whether the next list operation should fail. public bool FailNext { get; set; } /// public override Task> ListAsync(CancellationToken ct) { if (FailNext) { FailNext = false; ListCount++; throw new InvalidOperationException("Simulated SQLite failure."); } return base.ListAsync(ct); } } private static GatewaySession CreateSession( string sessionId, string? clientIdentity, DateTimeOffset openedAt, string? clientSessionName = "test-session", string? clientCorrelationId = "client-correlation") { return new GatewaySession( sessionId, "mxaccess", $"mxaccess-gateway-1-{sessionId}", "nonce", clientIdentity, clientSessionName, clientCorrelationId, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), openedAt); } private sealed class FakeWorkerClient( string sessionId, int? processId, WorkerClientState state) : IWorkerClient { /// public string SessionId { get; } = sessionId; /// public int? ProcessId { get; } = processId; /// public WorkerClientState State { get; private set; } = state; /// public DateTimeOffset LastHeartbeatAt { get; } = DateTimeOffset.Parse("2026-04-26T10:02:00Z", CultureInfo.InvariantCulture); /// /// Gets the count of start invocations. /// public int StartCount { get; private set; } /// /// Gets the count of shutdown invocations. /// public int ShutdownCount { get; private set; } /// /// Gets the count of kill invocations. /// public int KillCount { get; private set; } /// public Task StartAsync(CancellationToken cancellationToken) { StartCount++; return Task.CompletedTask; } /// public Task InvokeAsync( WorkerCommand command, TimeSpan timeout, CancellationToken cancellationToken) { return Task.FromResult(new WorkerCommandReply()); } /// public async IAsyncEnumerable ReadEventsAsync( [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) { await Task.CompletedTask; yield break; } /// public Task ShutdownAsync( TimeSpan timeout, CancellationToken cancellationToken) { ShutdownCount++; State = WorkerClientState.Closed; return Task.CompletedTask; } /// public void Kill(string reason) { KillCount++; State = WorkerClientState.Faulted; } /// /// Releases resources used by this worker client. /// /// Completed value task. public ValueTask DisposeAsync() { return ValueTask.CompletedTask; } } }