diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 79ee3f7ac3..934067d85f 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -38,15 +38,16 @@ You need the Dotnet 6.0 SDK and npm>=v5.7 (in your PATH) to compile the server. The recommended IDE is Visual Studio 2019 which has installation options for both of these. -In order to run the integration tests you must have the following environment variables set: +In order to run the integration tests you must have the following environment variables set. To run them more accurately, include the optional ones. - `TGS_TEST_DATABASE_TYPE`: `MySql`, `MariaDB`, `PostgresSql`, or `SqlServer`. - `TGS_TEST_CONNECTION_STRING`: To a valid database connection string. You can use the setup wizard to create one. -- `TSG_TEST_DISCORD_TOKEN`: To a valid discord bot token. -- `TGS_TEST_DISCORD_CHANNEL`: To a valid discord channel ID that the above bot can access. -- `TGS_TEST_IRC_CONNECTION_STRING`: To a valid IRC connection string. See the code for [IrcConnectionStringBuilder](../src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs) for details. -- `TGS_TEST_IRC_CHANNEL`: To a valid IRC channel accessible with the above connection. - `TGS_TEST_BRANCH`: Should be either `dev` or `master` depending on what you are working off of. Used for repository tests. - (Optional) `TGS_TEST_GITHUB_TOKEN`: A GitHub personal access token with no scopes used to bypass rate limits. +- (Optional) The following variables are all interdependent, so if one is set they all must be. + - `TSG_TEST_DISCORD_TOKEN`: To a valid discord bot token. + - `TGS_TEST_DISCORD_CHANNEL`: To a valid discord channel ID that the above bot can access. + - `TGS_TEST_IRC_CONNECTION_STRING`: To a valid IRC connection string. See the code for [IrcConnectionStringBuilder](../src/Tgstation.Server.Api/Models/IrcConnectionStringBuilder.cs) for details. + - `TGS_TEST_IRC_CHANNEL`: To a valid IRC channel accessible with the above connection. ### Know your Code diff --git a/.github/workflows/ci-suite.yml b/.github/workflows/ci-suite.yml index cd9e7b5efa..5b934f08da 100644 --- a/.github/workflows/ci-suite.yml +++ b/.github/workflows/ci-suite.yml @@ -17,8 +17,6 @@ on: env: TGS_DOTNET_VERSION: 6.0.x - TGS_TEST_DISCORD_CHANNEL: ${{ secrets.DISCORD_CHANNEL_ID }} - TGS_TEST_IRC_CHANNEL: ${{ secrets.IRC_CHANNEL }} TGS_TEST_GITHUB_TOKEN: ${{ secrets.LIVE_TESTS_TOKEN }} TGS_RELEASE_NOTES_TOKEN: ${{ secrets.DEV_PUSH_TOKEN }} @@ -229,6 +227,9 @@ jobs: fail-fast: false matrix: configuration: [ 'Debug', 'Release' ] + env: + TGS_TEST_DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} + TGS_TEST_IRC_CONNECTION_STRING: ${{ secrets.IRC_CONNECTION_STRING }} runs-on: ubuntu-latest steps: - name: Setup dotnet @@ -269,6 +270,9 @@ jobs: fail-fast: false matrix: configuration: [ 'Debug', 'Release' ] + env: + TGS_TEST_DISCORD_TOKEN: ${{ secrets.DISCORD_TOKEN }} + TGS_TEST_IRC_CONNECTION_STRING: ${{ secrets.IRC_CONNECTION_STRING }} runs-on: windows-latest steps: - name: Setup dotnet @@ -334,8 +338,6 @@ jobs: TGS_CONNSTRING_VALUE="Server=(localdb)\MSSQLLocalDB;Integrated Security=true;Initial Catalog=TGS_${{ matrix.watchdog-type }}_${{ matrix.configuration }};Application Name=tgstation-server" echo "TGS_TEST_CONNECTION_STRING=$(echo $TGS_CONNSTRING_VALUE)" >> $GITHUB_ENV echo "TGS_TEST_DATABASE_TYPE=SqlServer" >> $GITHUB_ENV - echo "TGS_TEST_DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN_WINDOWS_SQLSERVER }}" >> $GITHUB_ENV - echo "TGS_TEST_IRC_CONNECTION_STRING=${{ secrets.IRC_CONNECTION_STRING_WINDOWS_SQLSERVER }}" >> $GITHUB_ENV - name: Checkout (Branch Push) uses: actions/checkout@v3 @@ -458,24 +460,18 @@ jobs: run: | echo "TGS_TEST_DATABASE_TYPE=Sqlite" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Data Source=TGS_${{ matrix.watchdog-type }}_${{ matrix.configuration }}.sqlite3;Mode=ReadWriteCreate" >> $GITHUB_ENV - echo "TGS_TEST_DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN_LINUX_SQLITE }}" >> $GITHUB_ENV - echo "TGS_TEST_IRC_CONNECTION_STRING=${{ secrets.IRC_CONNECTION_STRING_LINUX_SQLITE }}" >> $GITHUB_ENV - name: Set PostgresSql Connection Info if: ${{ matrix.database-type == 'PostgresSql' }} run: | echo "TGS_TEST_DATABASE_TYPE=PostgresSql" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Application Name=tgstation-server;Host=127.0.0.1;Username=postgres;Password=postgres;Database=TGS__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV - echo "TGS_TEST_DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN_LINUX_POSTGRES }}" >> $GITHUB_ENV - echo "TGS_TEST_IRC_CONNECTION_STRING=${{ secrets.IRC_CONNECTION_STRING_LINUX_POSTGRES }}" >> $GITHUB_ENV - name: Set MariaDB Connection Info if: ${{ matrix.database-type == 'MariaDB' }} run: | echo "TGS_TEST_DATABASE_TYPE=MariaDB" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Server=127.0.0.1;uid=root;pwd=mariadb;database=tgs__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV - echo "TGS_TEST_DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN_LINUX_MARIADB }}" >> $GITHUB_ENV - echo "TGS_TEST_IRC_CONNECTION_STRING=${{ secrets.IRC_CONNECTION_STRING_LINUX_MARIADB }}" >> $GITHUB_ENV - name: Set MySQL Connection Info if: ${{ matrix.database-type == 'MySql' }} @@ -483,8 +479,6 @@ jobs: echo "TGS_TEST_DATABASE_TYPE=MySql" >> $GITHUB_ENV echo "TGS_TEST_CONNECTION_STRING=Server=127.0.0.1;Port=3307;uid=root;pwd=mysql;database=tgs__${{ matrix.watchdog-type }}_${{ matrix.configuration }}" >> $GITHUB_ENV echo "Database__ServerVersion=5.7.31" >> $GITHUB_ENV - echo "TGS_TEST_DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN_LINUX_MYSQL }}" >> $GITHUB_ENV - echo "TGS_TEST_IRC_CONNECTION_STRING=${{ secrets.IRC_CONNECTION_STRING_LINUX_MYSQL }}" >> $GITHUB_ENV - name: Set General__UseBasicWatchdog if: ${{ matrix.watchdog-type == 'Basic' }} diff --git a/src/Tgstation.Server.Host/Core/Application.cs b/src/Tgstation.Server.Host/Core/Application.cs index c2b4547e7d..fa5897a9e5 100644 --- a/src/Tgstation.Server.Host/Core/Application.cs +++ b/src/Tgstation.Server.Host/Core/Application.cs @@ -30,7 +30,6 @@ using Tgstation.Server.Common; using Tgstation.Server.Host.Components; using Tgstation.Server.Host.Components.Byond; using Tgstation.Server.Host.Components.Chat; -using Tgstation.Server.Host.Components.Chat.Providers; using Tgstation.Server.Host.Components.Deployment.Remote; using Tgstation.Server.Host.Components.Interop; using Tgstation.Server.Host.Components.Interop.Bridge; @@ -360,7 +359,7 @@ namespace Tgstation.Server.Host.Core services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); + services.AddChatProviderFactory(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs index 46bb712d39..4fcbc5d32e 100644 --- a/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs +++ b/src/Tgstation.Server.Host/Extensions/ServiceCollectionExtensions.cs @@ -11,6 +11,7 @@ using Serilog; using Serilog.Configuration; using Serilog.Sinks.Elasticsearch; +using Tgstation.Server.Host.Components.Chat.Providers; using Tgstation.Server.Host.Configuration; using Tgstation.Server.Host.Utils; @@ -21,6 +22,30 @@ namespace Tgstation.Server.Host.Extensions /// static class ServiceCollectionExtensions { + /// + /// The implementation used in calls to . + /// + static Type chatProviderFactoryType = typeof(ProviderFactory); + + /// + /// Change the used as an implementation for calls to . + /// + /// The implementation to use. + public static void UseChatProviderFactory() where TProviderFactory : IProviderFactory + { + chatProviderFactoryType = typeof(TProviderFactory); + } + + /// + /// Adds a implementation to the given . + /// + /// The to configure. + /// . + public static IServiceCollection AddChatProviderFactory(this IServiceCollection serviceCollection) + { + return serviceCollection.AddSingleton(typeof(IProviderFactory), chatProviderFactoryType); + } + /// /// Add a standard binding. /// diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs index e346a6e78d..f4457c285e 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestDiscordProvider.cs @@ -10,6 +10,7 @@ using Moq; using Tgstation.Server.Host.Jobs; using Tgstation.Server.Host.Models; using Tgstation.Server.Host.System; +using Tgstation.Server.Host.Utils; namespace Tgstation.Server.Host.Components.Chat.Providers.Tests { @@ -50,13 +51,15 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests ReconnectionInterval = 1, }; - Assert.ThrowsException(() => new DiscordProvider(null, null, null, null)); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, null, null, null)); - var mockAss = new Mock(); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockAss.Object, null, null)); - var mockLogger = new Mock>(); - Assert.ThrowsException(() => new DiscordProvider(mockJobManager, null, mockLogger.Object, null)); - await new DiscordProvider(mockJobManager, mockAss.Object, mockLogger.Object, bot).DisposeAsync(); + Assert.ThrowsException(() => new DiscordProvider(null, null, null, null, null)); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, null, null, null, null)); + var mockDel = Mock.Of(); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, null, null, null)); + var mockLogger = Mock.Of>(); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, null, null)); + var mockAss = Mock.Of(); + Assert.ThrowsException(() => new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, null)); + await new DiscordProvider(mockJobManager, mockDel, mockLogger, mockAss, bot).DisposeAsync(); } static Task InvokeConnect(IProvider provider, CancellationToken cancellationToken = default) => (Task)provider.GetType().GetMethod("Connect", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(provider, new object[] { cancellationToken }); @@ -65,7 +68,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests public async Task TestConnectWithFakeTokenFails() { var mockLogger = new Mock>(); - await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, new ChatBot + await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, Mock.Of(), new ChatBot { ReconnectionInterval = 1, ConnectionString = "asdf" @@ -81,7 +84,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Assert.Inconclusive("Required environment variable TGS_TEST_DISCORD_TOKEN isn't set!"); var mockLogger = new Mock>(); - await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, testToken1); + await using var provider = new DiscordProvider(mockJobManager, Mock.Of(), mockLogger.Object, Mock.Of(), testToken1); Assert.IsFalse(provider.Connected); await InvokeConnect(provider); Assert.IsTrue(provider.Connected); diff --git a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs index 89867bb98b..2e8921a6a2 100644 --- a/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs +++ b/tests/Tgstation.Server.Host.Tests/Components/Chat/Providers/TestIrcProvider.cs @@ -26,12 +26,12 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Assert.ThrowsException(() => new IrcProvider(null, null, null, null, null)); var mockJobManager = new Mock(); Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, null, null, null, null)); - var mockAss = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAss.Object, null, null, null)); var mockAsyncDelayer = new Mock(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAss.Object, mockAsyncDelayer.Object, null, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, null, null, null)); var mockLogger = new Mock>(); - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAss.Object, mockAsyncDelayer.Object, mockLogger.Object, null)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, null, null)); + var mockAss = new Mock(); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, null)); var mockBot = new ChatBot { @@ -39,7 +39,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Provider = ChatProvider.Irc }; - Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAss.Object, mockAsyncDelayer.Object, mockLogger.Object, mockBot)); + Assert.ThrowsException(() => new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot)); mockBot.ConnectionString = new IrcConnectionStringBuilder { @@ -49,7 +49,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests Port = 6667 }.ToString(); - await new IrcProvider(mockJobManager.Object, mockAss.Object, mockAsyncDelayer.Object, mockLogger.Object, mockBot).DisposeAsync(); + await new IrcProvider(mockJobManager.Object, mockAsyncDelayer.Object, mockLogger.Object, mockAss.Object, mockBot).DisposeAsync(); } static Task InvokeConnect(IProvider provider, CancellationToken cancellationToken = default) => (Task)provider.GetType().GetMethod("Connect", BindingFlags.Instance | BindingFlags.NonPublic).Invoke(provider, new object[] { cancellationToken }); @@ -75,7 +75,7 @@ namespace Tgstation.Server.Host.Components.Chat.Providers.Tests .Setup(x => x.WaitForJobCompletion(It.IsNotNull(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(Task.CompletedTask); var mockJobManager = mockSetup.Object; - await using var provider = new IrcProvider(mockJobManager, Mock.Of(), new AsyncDelayer(), loggerFactory.CreateLogger(), new ChatBot + await using var provider = new IrcProvider(mockJobManager, new AsyncDelayer(), loggerFactory.CreateLogger(), Mock.Of(), new ChatBot { ConnectionString = actualToken, Provider = ChatProvider.Irc, diff --git a/tests/Tgstation.Server.Tests/Live/DummyChatProvider.cs b/tests/Tgstation.Server.Tests/Live/DummyChatProvider.cs new file mode 100644 index 0000000000..331931cef6 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/DummyChatProvider.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using Moq; + +using Tgstation.Server.Host.Components.Chat; +using Tgstation.Server.Host.Components.Chat.Commands; +using Tgstation.Server.Host.Components.Chat.Providers; +using Tgstation.Server.Host.Components.Interop; +using Tgstation.Server.Host.Jobs; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.Utils; + +namespace Tgstation.Server.Tests.Live +{ + sealed class DummyChatProvider : Provider + { + public override bool Connected => connected; + + public override string BotMention => $"Dummy{ChatBot.Provider}-I-{ChatBot.InstanceId}-N-{ChatBot.Name}"; + + static int enableRandomDisconnections = 1; + + readonly Random random; // this RNG isn't perfect as calls into this class can theoretically happen in a random order due to async + + readonly IReadOnlyCollection commands; + readonly ICryptographySuite cryptographySuite; + readonly CancellationTokenSource randomMessageCts; + readonly Task randomMessageTask; + + bool connectedOnce; + bool connected; + + ulong channelIdAllocator; + + static ILoggerFactory CreateLoggerFactoryForLogger(ILogger logger, out Mock mockLoggerFactory) + { + mockLoggerFactory = new Mock(); + mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(() => + { + var temp = logger; + logger = null; + + Assert.IsNotNull(temp); + return temp; + }) + .Verifiable(); + return mockLoggerFactory.Object; + } + + static IAsyncDelayer CreateMockDelayer() + { + // at time of writing, this is used exclusively for the reconnection interval which works in minutes + // shorten it to 3s + var mock = new Mock(); + mock.Setup(x => x.Delay(It.IsAny(), It.IsAny())).Returns((delay, cancellationToken) => Task.Delay(TimeSpan.FromSeconds(3), cancellationToken)); + return mock.Object; + } + public static async Task RandomDisconnections(bool enabled, CancellationToken cancellationToken) + { + if (Interlocked.Exchange(ref enableRandomDisconnections, enabled ? 1 : 0) != 0 && !enabled) + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + + public DummyChatProvider( + IJobManager jobManager, + ILogger logger, + ChatBot chatBot, + ICryptographySuite cryptographySuite, + IReadOnlyCollection commands, + Random random) + : base(jobManager, CreateMockDelayer(), new Logger(CreateLoggerFactoryForLogger(logger, out var mockLoggerFactory)), chatBot) + { + mockLoggerFactory.VerifyAll(); + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + this.commands = commands ?? throw new ArgumentNullException(nameof(commands)); + this.random = random ?? throw new ArgumentNullException(nameof(random)); + + // this could be random but there's no point + channelIdAllocator = 100000; + logger.LogTrace("Base channel ID {baseChannelId}", channelIdAllocator); + + this.randomMessageCts = new CancellationTokenSource(); + this.randomMessageTask = RandomMessageLoop(this.randomMessageCts.Token); + } + + public override async ValueTask DisposeAsync() + { + Logger.LogTrace("DisposeAsync Child"); + this.randomMessageCts.Cancel(); + this.randomMessageCts.Dispose(); + await this.randomMessageTask; + await base.DisposeAsync(); + } + + public override Task SendMessage(Message replyTo, MessageContent message, ulong channelId, CancellationToken cancellationToken) + { + if (message == null) + throw new ArgumentNullException(nameof(message)); + + Logger.LogTrace("SendMessage"); + + Assert.AreNotEqual(0UL, channelId); + Assert.IsTrue(channelId <= channelIdAllocator); + + cancellationToken.ThrowIfCancellationRequested(); + + /* SendMessage is no-throw + if (random.Next(0, 100) > 70) + throw new Exception("Random SendMessage failure!"); */ + + return Task.CompletedTask; + } + + public override Task> SendUpdateMessage(RevisionInformation revisionInformation, Version byondVersion, DateTimeOffset? estimatedCompletionTime, string gitHubOwner, string gitHubRepo, ulong channelId, bool localCommitPushed, CancellationToken cancellationToken) + { + if (revisionInformation == null) + throw new ArgumentNullException(nameof(revisionInformation)); + if (byondVersion == null) + throw new ArgumentNullException(nameof(byondVersion)); + if (gitHubOwner == null) + throw new ArgumentNullException(nameof(gitHubOwner)); + if (gitHubRepo == null) + throw new ArgumentNullException(nameof(gitHubRepo)); + + Logger.LogTrace("SendUpdateMessage"); + + Assert.AreNotEqual(0UL, channelId); + Assert.IsTrue(channelId <= channelIdAllocator); + + cancellationToken.ThrowIfCancellationRequested(); + + /* SendUpdateMessage is no-throw + if (random.Next(0, 100) > 70) + throw new Exception("Random SendUpdateMessage failure!"); */ + + return Task.FromResult>((_, _) => + { + cancellationToken.ThrowIfCancellationRequested(); + + /* SendUpdateMessage callbacks are no-throw + if (random.Next(0, 100) > 70) + throw new Exception("Random SendUpdateMessage failure!"); */ + + return Task.CompletedTask; + }); + } + + protected override Task Connect(CancellationToken cancellationToken) + { + Logger.LogTrace("Connect"); + cancellationToken.ThrowIfCancellationRequested(); + + // 30% chance to fail AFTER initial connection + if (connectedOnce && enableRandomDisconnections != 0 && random.Next(0, 100) > 70) + throw new Exception("Random connection failure!"); + + connected = true; + connectedOnce = true; + return Task.CompletedTask; + } + + protected override Task DisconnectImpl(CancellationToken cancellationToken) + { + Logger.LogTrace("DisconnectImpl"); + cancellationToken.ThrowIfCancellationRequested(); + connected = false; + + if (random.Next(0, 100) > 70) + throw new Exception("Random disconnection failure!"); + return Task.CompletedTask; + } + + protected override Task>> MapChannelsImpl(IEnumerable channels, CancellationToken cancellationToken) + { + channels = channels.ToList(); + Logger.LogTrace("MapChannels: [{channels}]", String.Join(", ", channels.Select(channel => channel.IrcChannel ?? channel.DiscordChannelId?.ToString() ?? throw new InvalidOperationException("BAD CHANNEL")))); + + cancellationToken.ThrowIfCancellationRequested(); + + /* MapChannelsImpl is no-throw + if (random.Next(0, 100) > 70) + throw new Exception("Random MapChannelsImpl failure!"); */ + + return Task.FromResult( + new Dictionary>( + channels.Select( + channel => new KeyValuePair>( + channel, + new List + { + new ChannelRepresentation + { + IsAdminChannel = channel.IsAdminChannel.Value, + ConnectionName = $"Connection_{channelIdAllocator + 1}", + EmbedsSupported = ChatBot.Provider.Value != Api.Models.ChatProvider.Irc, + FriendlyName = $"(Friendly) Channel_ID_{channelIdAllocator + 1}", + IsPrivateChannel = false, + RealId = ++channelIdAllocator, + Tag = channel.Tag, + } + })))); + } + + async Task RandomMessageLoop(CancellationToken cancellationToken) + { + Logger.LogTrace("RandomMessageLoop"); + try + { + for (var i = 0UL; !cancellationToken.IsCancellationRequested; ++i) + { + // random intervals under 10s + var delay = random.Next(0, 10000); + await Task.Delay(delay, cancellationToken); + + if (!connected) + continue; + + // %5 chance to disconnect randomly + if (enableRandomDisconnections != 0 && random.Next(0, 100) > 95) + connected = false; + + if (channelIdAllocator >= Int32.MaxValue / 2) + Assert.Fail("Too many channels have been allocated!"); + + var isPm = channelIdAllocator == 0 || random.Next(0, 100) > 20; + var realId = (ulong)random.Next(1, (int)channelIdAllocator); + + if (isPm) + realId += Int32.MaxValue / 2; + + var username = $"RandomUser{i}"; + var sender = new ChatUser + { + Channel = new ChannelRepresentation + { + RealId = realId, + IsPrivateChannel = isPm, + ConnectionName = isPm ? $"{username}_Connection" : $"Connection_{realId}", + FriendlyName = isPm ? $"{username}_Channel" : $"(Friendly) Channel_ID_{realId}", + EmbedsSupported = ChatBot.Provider.Value != Api.Models.ChatProvider.Irc, + + // isAdmin and Tag populated by manager + }, + FriendlyName = username, + RealId = i + 50000, + Mention = $"@{username}", + }; + + var dice = random.Next(0, 100); + string content; + // 70% chance to be random chat + if (dice < 70) + content = cryptographySuite.GetSecureString(); + // 15% chance to be a !tgs + else if (dice < 85) + content = "!tgs"; + // 15% chance to be a strict mention + else + content = BotMention; + + // 30% chance to request help + if (random.Next(0, 100) > 70) + content = $"{content} help"; + + dice = random.Next(0, 100); + + // 20% chance to whiff + if (dice > 20) + // 40% chance to attempt a built-in TGS command + if (dice < 68) + // equal chance for each + content = $"{content} {commands.ElementAt(random.Next(0, commands.Count)).Name}"; + // 40% chance to attempt a custom chat command in long_running_test + else + // equal chance for each + if (random.Next(0, 100) > 50) + content = $"{content} embeds_test"; + else + content = $"{content} response_overload_test"; + + EnqueueMessage(new Message + { + Content = content, + User = sender, + }); + } + + } + catch (OperationCanceledException) + { + Logger.LogTrace("RandomMessageLoop cancelled"); + } + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/DummyChatProviderFactory.cs b/tests/Tgstation.Server.Tests/Live/DummyChatProviderFactory.cs new file mode 100644 index 0000000000..55162d85c2 --- /dev/null +++ b/tests/Tgstation.Server.Tests/Live/DummyChatProviderFactory.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; + +using Microsoft.Extensions.Logging; + +using Moq; + +using Tgstation.Server.Api.Models; +using Tgstation.Server.Host.Components.Byond; +using Tgstation.Server.Host.Components.Chat.Commands; +using Tgstation.Server.Host.Components.Chat.Providers; +using Tgstation.Server.Host.Components.Repository; +using Tgstation.Server.Host.Components.Watchdog; +using Tgstation.Server.Host.Database; +using Tgstation.Server.Host.Jobs; +using Tgstation.Server.Host.Models; +using Tgstation.Server.Host.Security; +using Tgstation.Server.Host.System; + +namespace Tgstation.Server.Tests.Live +{ + sealed class DummyChatProviderFactory : IProviderFactory + { + readonly IJobManager jobManager; + readonly ICryptographySuite cryptographySuite; + readonly ILoggerFactory loggerFactory; + readonly ILogger logger; + + readonly IReadOnlyList commands; + + readonly Dictionary seededRng; + + public DummyChatProviderFactory(IJobManager jobManager, ICryptographySuite cryptographySuite, ILoggerFactory loggerFactory, ILogger logger) + { + this.jobManager = jobManager ?? throw new ArgumentNullException(nameof(jobManager)); + this.cryptographySuite = cryptographySuite ?? throw new ArgumentNullException(nameof(cryptographySuite)); + this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + var commandFactory = new CommandFactory( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + new Host.Models.Instance()); + + commandFactory.SetWatchdog(Mock.Of()); + commands = commandFactory.GenerateCommands(); + + var baseRng = new Random(22475); + seededRng = new Dictionary{ + { ChatProvider.Irc, new Random(baseRng.Next()) }, + { ChatProvider.Discord, new Random(baseRng.Next()) }, + }; // hope you get the reference + } + + public IProvider CreateProvider(ChatBot settings) + { + logger.LogTrace("CreateProvider"); + if (settings == null) + throw new ArgumentNullException(nameof(settings)); + + var provider = settings.Provider; + switch (provider) + { + case ChatProvider.Irc: + case ChatProvider.Discord: + logger.LogTrace("Creating DummyChatProvider in place of requested {providerType}Provider", settings.Provider); + + // for RNG to work, chat bots need to get created in a certain order + // the ChatTest creates one of each provider type + return new DummyChatProvider( + jobManager, + loggerFactory.CreateLogger($"Dummy{settings.Provider}Provider"), + settings, + cryptographySuite, + commands, + new Random(seededRng[provider.Value].Next())); + default: + throw new InvalidOperationException($"Invalid ChatProvider: {provider}"); + } + } + } +} diff --git a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs index 37d63e884b..78b287a641 100644 --- a/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs +++ b/tests/Tgstation.Server.Tests/Live/Instance/WatchdogTest.cs @@ -79,6 +79,9 @@ namespace Tgstation.Server.Tests.Live.Instance await TestDMApiFreeDeploy(cancellationToken); + // long running test likes consistency with the channels + await DummyChatProvider.RandomDisconnections(false, cancellationToken); + await RunLongRunningTestThenUpdate(cancellationToken); await RunLongRunningTestThenUpdateWithNewDme(cancellationToken); diff --git a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs index 1f38d6cb21..01c50b5903 100644 --- a/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs +++ b/tests/Tgstation.Server.Tests/Live/TestLiveServer.cs @@ -82,6 +82,7 @@ namespace Tgstation.Server.Tests.Live [TestInitialize] public async Task Initialize() { + await DummyChatProvider.RandomDisconnections(true, default); ServerClientFactory.ApiClientFactory = new RateLimitRetryingApiClientFactory(); var connectionString = Environment.GetEnvironmentVariable("TGS_TEST_CONNECTION_STRING"); @@ -697,6 +698,24 @@ namespace Tgstation.Server.Tests.Live [TestMethod] public async Task TestStandardTgsOperation() { + var missingChatVarsCount = Convert.ToInt32(String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TGS_TEST_DISCORD_TOKEN"))) + + Convert.ToInt32(String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TGS_TEST_DISCORD_CHANNEL"))) + + Convert.ToInt32(String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TGS_TEST_IRC_CONNECTION_STRING"))) + + Convert.ToInt32(String.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TGS_TEST_IRC_CHANNEL"))); + + const int TotalChatVars = 4; + + // uncomment to force this test to run with DummyChatProviders + missingChatVarsCount = TotalChatVars; + + if (missingChatVarsCount != 0) + { + if (missingChatVarsCount != TotalChatVars) + Assert.Fail("All TGS_TEST_* chat environment variables must be present or none at all!"); + + ServiceCollectionExtensions.UseChatProviderFactory(); + } + var procs = System.Diagnostics.Process.GetProcessesByName("byond"); if (procs.Any()) {