diff --git a/src/StevesBot.Worker.Tests/.editorconfig b/src/StevesBot.Worker.Tests/.editorconfig index a84e56b..33e7631 100644 --- a/src/StevesBot.Worker.Tests/.editorconfig +++ b/src/StevesBot.Worker.Tests/.editorconfig @@ -2,3 +2,4 @@ dotnet_diagnostic.CA1515.severity = none dotnet_diagnostic.CA1707.severity = none dotnet_diagnostic.CA2201.severity = none +dotnet_diagnostic.CA2007.severity = none diff --git a/src/StevesBot.Worker.Tests/Unit/AsyncLockTests.cs b/src/StevesBot.Worker.Tests/Unit/AsyncLockTests.cs new file mode 100644 index 0000000..e0bf26e --- /dev/null +++ b/src/StevesBot.Worker.Tests/Unit/AsyncLockTests.cs @@ -0,0 +1,199 @@ +namespace StevesBot.Worker.Tests.Unit; + +public class AsyncLockTests +{ + [Fact] + public async Task LockAsync_WhenCalled_ItShouldReturnsNonNullRelease() + { + using var asyncLock = new AsyncLock(); + var releaser = await asyncLock.LockAsync(); + + releaser.Should().NotBeNull(); + releaser.Should().BeAssignableTo(); + } + + [Fact] + public async Task LockAsync_WhenLockIsAcquired_ItShouldForceSubsequentCallsToWait() + { + using var asyncLock = new AsyncLock(); + var lockOneAcquired = new TaskCompletionSource(); + var lockTwoAttempted = new TaskCompletionSource(); + var lockTwoAcquired = new TaskCompletionSource(); + + async Task FirstLockAction() + { + using (await asyncLock.LockAsync()) + { + lockOneAcquired.SetResult(true); + await lockTwoAttempted.Task; + await Task.Delay(100); + } + } + + async Task SecondLockAction() + { + await lockOneAcquired.Task; + lockTwoAttempted.SetResult(true); + using (await asyncLock.LockAsync()) + { + lockTwoAcquired.SetResult(true); + } + } + + var taskOne = FirstLockAction(); + var taskTwo = SecondLockAction(); + + await Task.WhenAll(taskOne, Task.WhenAny(taskTwo, Task.Delay(500))); + + lockOneAcquired.Task.IsCompleted.Should().BeTrue(); + lockTwoAttempted.Task.IsCompleted.Should().BeTrue(); + lockTwoAcquired.Task.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task LockAsync_WhenReleaserIsDisposed_ItShouldAllowAnotherLockAcquisition() + { + using var asyncLock = new AsyncLock(); + + var releaser = await asyncLock.LockAsync(); + releaser.Dispose(); + + IDisposable? newReleaser = null; + var act = async () => newReleaser = await asyncLock.LockAsync(); + + await act.Should().NotThrowAsync(); + newReleaser.Should().NotBeNull(); + newReleaser.Dispose(); + } + + [Fact] + public async Task LockAsync_WhenCalledWithCancellationTokenAndTokenIsCancelled_ItShouldThrowOperationCanceledException() + { + using var asyncLock = new AsyncLock(); + using var cts = new CancellationTokenSource(); + + var initialReleaser = await asyncLock.LockAsync(); + + async Task Act() + { + return await asyncLock.LockAsync(cts.Token); + } + + var lockTask = Act(); + await Task.Delay(100); + await cts.CancelAsync(); + + var act = () => lockTask; + await act.Should().ThrowAsync(); + + initialReleaser.Dispose(); + } + + [Fact] + public async Task LockAsync_WhenCalledWithAlreadyCancelledToken_ItShouldThrowOperationCanceledException() + { + using var asyncLock = new AsyncLock(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + var act = async () => await asyncLock.LockAsync(cts.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Dispose_WhenCalled_ItShouldDisposeSemaphore() + { + var asyncLock = new AsyncLock(); + + asyncLock.Dispose(); + + var act = () => asyncLock.LockAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public void Dispose_WhenCalledMultipleTimes_ItShouldNotThrow() + { + using var asyncLock = new AsyncLock(); + + var act = () => + { + asyncLock.Dispose(); + asyncLock.Dispose(); + }; + + act.Should().NotThrow(); + } + + [Fact] + public async Task LockAsync_WhenCalledAfterDispose_ItShouldThrowObjectDisposedException() + { + var asyncLock = new AsyncLock(); + asyncLock.Dispose(); + + var act = () => asyncLock.LockAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LockAsync_WhenCalled_ItShouldOnlyAllowConcurrentAccessToOneThread() + { + using var asyncLock = new AsyncLock(); + var concurrentAccessCount = 0; + var maxConcurrentAccessCount = 0; + const int numberOfTasks = 10; + var tasks = new Task[numberOfTasks]; + + for (var i = 0; i < numberOfTasks; i++) + { + tasks[i] = Task.Run(async () => + { + using (await asyncLock.LockAsync()) + { + Interlocked.Increment(ref concurrentAccessCount); + maxConcurrentAccessCount = Math.Max(maxConcurrentAccessCount, concurrentAccessCount); + await Task.Delay(20); + Interlocked.Decrement(ref concurrentAccessCount); + } + }); + } + + await Task.WhenAll(tasks); + + maxConcurrentAccessCount.Should().Be(1); + concurrentAccessCount.Should().Be(0); + } + + [Fact] + public async Task LockAsync_WhenCalled_ItShouldPreventRaceConditionsInHighContentionScenario() + { + using var asyncLock = new AsyncLock(); + var sharedResource = 0; + const int numberOfIterations = 10; + const int numberOfTasks = 10; + var tasks = new Task[numberOfTasks]; + + for (var i = 0; i < numberOfTasks; i++) + { + tasks[i] = Task.Run(async () => + { + for (var j = 0; j < numberOfIterations; j++) + { + using (await asyncLock.LockAsync()) + { + var temp = sharedResource; + await Task.Delay(1); + sharedResource = temp + 1; + } + } + }); + } + + await Task.WhenAll(tasks); + + sharedResource.Should().Be(numberOfTasks * numberOfIterations); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker.Tests/Unit/DiscordEventTests.cs b/src/StevesBot.Worker.Tests/Unit/DiscordEventTests.cs new file mode 100644 index 0000000..7390fff --- /dev/null +++ b/src/StevesBot.Worker.Tests/Unit/DiscordEventTests.cs @@ -0,0 +1,41 @@ +namespace StevesBot.Worker.Tests.Unit; + +public class DiscordEventTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldCreateInstance() + { + var e = new DiscordEvent(); + + e.Should().NotBeNull(); + e.Should().BeOfType(); + e.OpCode.Should().Be(0); + e.Sequence.Should().BeNull(); + e.Type.Should().BeNull(); + e.Data.Should().BeNull(); + } + + [Fact] + public void Constructor_WhenCalledWithParameters_ItShouldCreateInstance() + { + var opCode = 1; + var sequence = 2; + var type = "test"; + var data = new object(); + + var e = new DiscordEvent + { + OpCode = opCode, + Sequence = sequence, + Type = type, + Data = data, + }; + + e.Should().NotBeNull(); + e.Should().BeOfType(); + e.OpCode.Should().Be(opCode); + e.Sequence.Should().Be(sequence); + e.Type.Should().Be(type); + e.Data.Should().BeSameAs(data); + } +} \ No newline at end of file diff --git a/src/StevesBot.Worker.Tests/Usings.cs b/src/StevesBot.Worker.Tests/Usings.cs index a1cf763..f34706e 100644 --- a/src/StevesBot.Worker.Tests/Usings.cs +++ b/src/StevesBot.Worker.Tests/Usings.cs @@ -1 +1,3 @@ -global using StevesBot.Worker.Discord; \ No newline at end of file +global using StevesBot.Worker.Discord; +global using StevesBot.Worker.Discord.Events; +global using StevesBot.Worker.Threading; \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/DiscordClientException.cs b/src/StevesBot.Worker/Discord/DiscordClientException.cs index 791cd04..62803d8 100644 --- a/src/StevesBot.Worker/Discord/DiscordClientException.cs +++ b/src/StevesBot.Worker/Discord/DiscordClientException.cs @@ -1,6 +1,6 @@ namespace StevesBot.Worker.Discord; -internal class DiscordClientException : Exception +internal sealed class DiscordClientException : Exception { public DiscordClientException() { diff --git a/src/StevesBot.Worker/Discord/DiscordClientOptions.cs b/src/StevesBot.Worker/Discord/DiscordClientOptions.cs index 885d548..130390d 100644 --- a/src/StevesBot.Worker/Discord/DiscordClientOptions.cs +++ b/src/StevesBot.Worker/Discord/DiscordClientOptions.cs @@ -1,8 +1,8 @@ namespace StevesBot.Worker.Discord; -internal class DiscordClientOptions +internal sealed class DiscordClientOptions { public string ApiUrl { get; init; } = string.Empty; public string AppToken { get; init; } = string.Empty; public int Intents { get; init; } -} +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Discord/Events/DiscordEvent.cs b/src/StevesBot.Worker/Discord/Events/DiscordEvent.cs index 7ac4ff8..749bccb 100644 --- a/src/StevesBot.Worker/Discord/Events/DiscordEvent.cs +++ b/src/StevesBot.Worker/Discord/Events/DiscordEvent.cs @@ -3,7 +3,7 @@ namespace StevesBot.Worker.Discord.Events; internal record DiscordEvent { [JsonPropertyName("op")] - public int Op { get; init; } + public int OpCode { get; init; } [JsonPropertyName("s")] public int? Sequence { get; init; } diff --git a/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs b/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs index 8426030..da3f641 100644 --- a/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs +++ b/src/StevesBot.Worker/Discord/Events/DiscordEventConverter.cs @@ -1,6 +1,6 @@ namespace StevesBot.Worker.Discord.Events; -internal class DiscordEventConverter : JsonConverter +internal sealed class DiscordEventConverter : JsonConverter { private const string OpPropertyName = "op"; diff --git a/src/StevesBot.Worker/Discord/Events/HelloDiscordEvent.cs b/src/StevesBot.Worker/Discord/Events/HelloDiscordEvent.cs index 689442f..732cf78 100644 --- a/src/StevesBot.Worker/Discord/Events/HelloDiscordEvent.cs +++ b/src/StevesBot.Worker/Discord/Events/HelloDiscordEvent.cs @@ -1,13 +1,13 @@ namespace StevesBot.Worker.Discord.Events; -internal record HelloDiscordEvent : DiscordEvent +internal sealed record HelloDiscordEvent : DiscordEvent { [JsonPropertyName("d")] public new HelloData? Data { get; init; } } -internal record HelloData +internal sealed record HelloData { [JsonPropertyName("heartbeat_interval")] public int HeartbeatInterval { get; init; } -} +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Threading/AsyncLock.cs b/src/StevesBot.Worker/Threading/AsyncLock.cs new file mode 100644 index 0000000..528122f --- /dev/null +++ b/src/StevesBot.Worker/Threading/AsyncLock.cs @@ -0,0 +1,24 @@ +namespace StevesBot.Worker.Threading; + +internal sealed class AsyncLock : IAsyncLock +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + private bool _disposed; + + public async Task LockAsync(CancellationToken cancellationToken = default) + { + await _semaphore.WaitAsync(cancellationToken); + return new LockReleaser(_semaphore); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _semaphore.Dispose(); + _disposed = true; + } +} diff --git a/src/StevesBot.Worker/Threading/IAsyncLock.cs b/src/StevesBot.Worker/Threading/IAsyncLock.cs new file mode 100644 index 0000000..175b773 --- /dev/null +++ b/src/StevesBot.Worker/Threading/IAsyncLock.cs @@ -0,0 +1,6 @@ +namespace StevesBot.Worker.Threading; + +internal interface IAsyncLock : IDisposable +{ + Task LockAsync(CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/StevesBot.Worker/Threading/LockReleaser.cs b/src/StevesBot.Worker/Threading/LockReleaser.cs new file mode 100644 index 0000000..60a530b --- /dev/null +++ b/src/StevesBot.Worker/Threading/LockReleaser.cs @@ -0,0 +1,23 @@ +namespace StevesBot.Worker.Threading; + +internal sealed class LockReleaser : IDisposable +{ + private readonly SemaphoreSlim _semaphore; + private bool _released; + + public LockReleaser(SemaphoreSlim semaphore) + { + _semaphore = semaphore; + } + + public void Dispose() + { + if (_released) + { + return; + } + + _semaphore.Release(); + _released = true; + } +} \ No newline at end of file