diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostInitialConnectionGateTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostInitialConnectionGateTests.cs new file mode 100644 index 000000000..a4cf396a8 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostInitialConnectionGateTests.cs @@ -0,0 +1,130 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost.Events; +using Microsoft.Extensions.Time.Testing; +using Netclaw.Channels.Mattermost.Transport; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostInitialConnectionGateTests +{ + [Fact] + public async Task StartWaitsForConnectedAndRemovesHandler() + { + var timeProvider = new FakeTimeProvider(); + var subscription = new ConnectionSubscription(); + var startCount = 0; + + var start = MattermostInitialConnectionGate.StartAndWaitAsync( + cancellationToken => + { + Assert.False(cancellationToken.IsCancellationRequested); + Assert.NotNull(subscription.Handler); + startCount++; + return Task.CompletedTask; + }, + subscription.Subscribe, + subscription.Unsubscribe, + timeProvider, + TestContext.Current.CancellationToken); + + Assert.Equal(1, startCount); + Assert.False(start.IsCompleted); + + subscription.RaiseConnected(timeProvider); + await start; + + Assert.Equal(1, subscription.UnsubscribeCount); + Assert.Equal(subscription.Handler, subscription.RemovedHandler); + } + + [Fact] + public async Task TimeoutUsesTimeProviderAndRemovesHandler() + { + var timeProvider = new FakeTimeProvider(); + var subscription = new ConnectionSubscription(); + var start = MattermostInitialConnectionGate.StartAndWaitAsync( + _ => Task.CompletedTask, + subscription.Subscribe, + subscription.Unsubscribe, + timeProvider, + TestContext.Current.CancellationToken); + + timeProvider.Advance(MattermostInitialConnectionGate.Timeout); + + await Assert.ThrowsAsync(() => start); + Assert.Equal(1, subscription.UnsubscribeCount); + Assert.Equal(subscription.Handler, subscription.RemovedHandler); + } + + [Fact] + public async Task CancellationRemovesHandler() + { + var timeProvider = new FakeTimeProvider(); + var subscription = new ConnectionSubscription(); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + TestContext.Current.CancellationToken); + var start = MattermostInitialConnectionGate.StartAndWaitAsync( + _ => Task.CompletedTask, + subscription.Subscribe, + subscription.Unsubscribe, + timeProvider, + cancellation.Token); + + await cancellation.CancelAsync(); + + await Assert.ThrowsAnyAsync(() => start); + Assert.Equal(1, subscription.UnsubscribeCount); + Assert.Equal(subscription.Handler, subscription.RemovedHandler); + } + + [Fact] + public async Task StartFailureRemovesHandler() + { + var timeProvider = new FakeTimeProvider(); + var subscription = new ConnectionSubscription(); + var start = MattermostInitialConnectionGate.StartAndWaitAsync( + _ => throw new InvalidOperationException("Start failed."), + subscription.Subscribe, + subscription.Unsubscribe, + timeProvider, + TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync(() => start); + + Assert.Equal("Start failed.", exception.Message); + Assert.Equal(1, subscription.UnsubscribeCount); + Assert.Equal(subscription.Handler, subscription.RemovedHandler); + } + + private sealed class ConnectionSubscription + { + public EventHandler? Handler { get; private set; } + + public EventHandler? RemovedHandler { get; private set; } + + public int UnsubscribeCount { get; private set; } + + public void Subscribe(EventHandler handler) => Handler = handler; + + public void Unsubscribe(EventHandler handler) + { + RemovedHandler = handler; + UnsubscribeCount++; + } + + public void RaiseConnected(TimeProvider timeProvider) + { + Assert.NotNull(Handler); + Handler( + this, + new ConnectionEventArgs( + new Uri("wss://mattermost.test/api/v4/websocket"), + timeProvider.GetUtcNow().UtcDateTime)); + } + } +} diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs index 88a0cd895..f55dd86d0 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -195,7 +195,15 @@ public async Task StartAsync(string serverUrl, string bot _logger.LogInformation("Bot identity resolved: {BotUserId} (@{Username})", me.Id, me.Username); - await _client.StartReceivingAsync(cancellationToken); + // Mattermost.NET starts its receiver before the authenticated WebSocket + // exists. Preserve the transport contract by waiting for OnConnected. + await MattermostInitialConnectionGate.StartAndWaitAsync( + _client.StartReceivingAsync, + handler => _client.OnConnected += handler, + handler => _client.OnConnected -= handler, + _timeProvider, + cancellationToken); + return new MattermostBotIdentity(me.Id, me.Username); } @@ -400,3 +408,30 @@ private async Task AwaitDispatchAsync(string operation, Task task) } } } + +internal static class MattermostInitialConnectionGate +{ + internal static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + public static async Task StartAndWaitAsync( + Func startReceivingAsync, + Action> subscribe, + Action> unsubscribe, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + var connected = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void HandleConnected(object? sender, ConnectionEventArgs e) => connected.TrySetResult(); + + subscribe(HandleConnected); + try + { + await startReceivingAsync(cancellationToken); + await connected.Task.WaitAsync(Timeout, timeProvider, cancellationToken); + } + finally + { + unsubscribe(HandleConnected); + } + } +}