diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index d52612f..9a91db5 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -2,9 +2,9 @@ name: .NET Build and Test on: push: - branches: [main] + branches: [ main ] pull_request: - branches: [main] + branches: [ main ] permissions: packages: write jobs: diff --git a/.github/workflows/push_to_live.yml b/.github/workflows/push_to_live.yml index 67447a4..155246a 100644 --- a/.github/workflows/push_to_live.yml +++ b/.github/workflows/push_to_live.yml @@ -2,9 +2,9 @@ name: Push to Live on: workflow_run: - workflows: [.NET Build and Test] - types: [completed] - branches: [main] + workflows: [ .NET Build and Test ] + types: [ completed ] + branches: [ main ] jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/Bottomly.ServiceDefaults/Extensions.cs b/Bottomly.ServiceDefaults/Extensions.cs index 5e47062..7fc0291 100644 --- a/Bottomly.ServiceDefaults/Extensions.cs +++ b/Bottomly.ServiceDefaults/Extensions.cs @@ -92,7 +92,7 @@ private static TBuilder AddOpenTelemetryExporters(this TBuilder builde if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) { builder.Services.AddOpenTelemetry() - .UseAzureMonitor(); + .UseAzureMonitor(); } return builder; diff --git a/Bottomly.Tests/Bottomly.Tests.csproj b/Bottomly.Tests/Bottomly.Tests.csproj index f92ffc2..07afc25 100644 --- a/Bottomly.Tests/Bottomly.Tests.csproj +++ b/Bottomly.Tests/Bottomly.Tests.csproj @@ -9,24 +9,24 @@ - - - - - - - - - - + + + + + + + + + + - + - + \ No newline at end of file diff --git a/Bottomly.Tests/Commands/AddKarmaCommandTests.cs b/Bottomly.Tests/Commands/AddKarmaCommandTests.cs index 87850ec..40a731b 100644 --- a/Bottomly.Tests/Commands/AddKarmaCommandTests.cs +++ b/Bottomly.Tests/Commands/AddKarmaCommandTests.cs @@ -1,6 +1,7 @@ using Bottomly.Commands; using Bottomly.Models; using Bottomly.Repositories; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; @@ -11,15 +12,16 @@ public class AddKarmaCommandTests private readonly AddKarmaCommand _command; private readonly Mock _mockRepo = new(); - public AddKarmaCommandTests() => _command = new AddKarmaCommand(_mockRepo.Object); + public AddKarmaCommandTests() => _command = new AddKarmaCommand(_mockRepo.Object, NullLogger.Instance); [Fact] public async Task ExecuteAsync_AwardsKarma_PersistsToRepository() { _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); - await _command.ExecuteAsync("alice", "bob", "great job", KarmaType.PozzyPoz); + var result = await _command.ExecuteAsync("alice", "bob", "great job", KarmaType.PozzyPoz); + result.ShouldBeOfType(); _mockRepo.Verify(r => r.AddAsync(It.Is(k => k.AwardedToUsername == "alice" && k.AwardedByUsername == "bob" && @@ -28,15 +30,31 @@ public async Task ExecuteAsync_AwardsKarma_PersistsToRepository() } [Fact] - public async Task ExecuteAsync_SelfPositiveKarma_ThrowsInvalidOperation() => - await Should.ThrowAsync(() => - _command.ExecuteAsync("alice", "alice", "", KarmaType.PozzyPoz)); + public async Task ExecuteAsync_SelfPositiveKarma_ReturnsSelfAwardResult() + { + var result = await _command.ExecuteAsync("alice", "alice", "", KarmaType.PozzyPoz); + + result.ShouldBeOfType(); + } [Fact] - public async Task ExecuteAsync_SelfNegativeKarma_DoesNotThrow() + public async Task ExecuteAsync_SelfNegativeKarma_ReturnsSuccessResult() { _mockRepo.Setup(r => r.AddAsync(It.IsAny())).Returns(Task.CompletedTask); - await Should.NotThrowAsync(() => _command.ExecuteAsync("alice", "alice", "", KarmaType.NeggyNeg)); + var result = await _command.ExecuteAsync("alice", "alice", "", KarmaType.NeggyNeg); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_RepositoryThrows_ReturnsErrorResult() + { + _mockRepo.Setup(r => r.AddAsync(It.IsAny())).ThrowsAsync(new Exception("DB error")); + + var result = await _command.ExecuteAsync("alice", "bob", "great job", KarmaType.PozzyPoz); + + var error = result.ShouldBeOfType(); + error.Error.ShouldBe("DB error"); } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/GiphyCommandTests.cs b/Bottomly.Tests/Commands/GiphyCommandTests.cs index 43e2a12..16ed148 100644 --- a/Bottomly.Tests/Commands/GiphyCommandTests.cs +++ b/Bottomly.Tests/Commands/GiphyCommandTests.cs @@ -25,14 +25,16 @@ public async Task ExecuteAsync_EmptyInput_ReturnsBadInputResult() [Fact] public async Task ExecuteAsync_WithResult_ReturnsSuccessResult() { - const string json = """{"data":{"url":"https://giphy.com/gifs/funny-cat"}}"""; + const string json = + """{"data":{"images":{"original":{"url":"https://media.giphy.com/media/funny-cat/giphy.gif"}}}}"""; var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" }); - var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options, NullLogger.Instance); + var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options, + NullLogger.Instance); var result = await command.ExecuteAsync("cat"); var successResult = result.ShouldBeOfType(); - successResult.Url.ShouldBe("https://giphy.com/gifs/funny-cat"); + successResult.Url.ShouldBe("https://media.giphy.com/media/funny-cat/giphy.gif"); } [Fact] @@ -40,7 +42,8 @@ public async Task ExecuteAsync_EmptyDataArray_ReturnsEmptyResult() { const string json = """{"data":[]}"""; var options = Options.Create(new BottomlyOptions { GiphyApiKey = "test" }); - var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options, NullLogger.Instance); + var command = new GiphyCommand(TestHelpers.CreateHttpClientFactory(json), options, + NullLogger.Instance); var result = await command.ExecuteAsync("obscuresearch"); diff --git a/Bottomly.Tests/Commands/ImageSearchCommandTests.cs b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs index 20708b8..0db1e25 100644 --- a/Bottomly.Tests/Commands/ImageSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs @@ -15,11 +15,9 @@ public class ImageSearchCommandTests Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { BraveApiKey = "fake-key" }); private static ImageSearchCommand CreateCommand(string responseJson, - HttpStatusCode statusCode = HttpStatusCode.OK) - { - return new ImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode), + HttpStatusCode statusCode = HttpStatusCode.OK) => + new(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode), NullLogger.Instance); - } [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() diff --git a/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs index 39514b9..8e2a3e7 100644 --- a/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs @@ -29,10 +29,8 @@ public class ImageSearchCommandIntegrationTests private readonly ILogger _logger; - public ImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) - { + public ImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) => _logger = XUnitLogger.CreateLogger(outputHelper); - } private static string? ApiKey => Configuration["bottomly_brave_api_key"]; @@ -47,11 +45,6 @@ private ImageSearchCommand CreateCommand() }), factory, _logger); } - private sealed class DefaultHttpClientFactory : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(); - } - [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { @@ -97,4 +90,9 @@ public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult() searchResult.Link.ShouldNotBeNullOrEmpty(); searchResult.Link.ShouldStartWith("http"); } + + private sealed class DefaultHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs index 72f4b84..d1fd6ed 100644 --- a/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs @@ -29,10 +29,8 @@ public class SearchCommandIntegrationTests private readonly ILogger _logger; - public SearchCommandIntegrationTests(ITestOutputHelper outputHelper) - { + public SearchCommandIntegrationTests(ITestOutputHelper outputHelper) => _logger = XUnitLogger.CreateLogger(outputHelper); - } private static string? ApiKey => Configuration["bottomly_brave_api_key"]; @@ -47,11 +45,6 @@ private SearchCommand CreateCommand() }), _logger, factory); } - private sealed class DefaultHttpClientFactory : IHttpClientFactory - { - public HttpClient CreateClient(string name) => new(); - } - [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { @@ -96,4 +89,9 @@ public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult() var searchResult = (SearchResult)result; searchResult.Link.ShouldContain("wikipedia"); } + + private sealed class DefaultHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs index 46ac021..ca1089c 100644 --- a/Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs @@ -1,4 +1,5 @@ using Bottomly.Commands; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; @@ -19,25 +20,25 @@ public WikipediaSearchCommandIntegrationTests() var factory = new Mock(); factory.Setup(f => f.CreateClient(It.IsAny())).Returns(client); - _sut = new WikipediaSearchCommand(factory.Object); + _sut = new WikipediaSearchCommand(factory.Object, NullLogger.Instance); } [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() + public async Task ExecuteAsync_EmptyInput_ReturnsEmptyInputResult() { var result = await _sut.ExecuteAsync(""); - result.ShouldBeNull(); + result.ShouldBeOfType(); } [Fact] - public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithWikipediaLink() + public async Task ExecuteAsync_KnownSearchTerm_ReturnsSuccessResultWithWikipediaLink() { var result = await _sut.ExecuteAsync("Albert Einstein"); - result.ShouldNotBeNull(); - result!.Text.ShouldNotBeNullOrEmpty(); - result.Link.ShouldStartWith("https://en.wikipedia.org/wiki/"); + var success = result.ShouldBeOfType(); + success.Text.ShouldNotBeNullOrEmpty(); + success.Link.ShouldStartWith("https://en.wikipedia.org/wiki/"); } [Fact] @@ -45,15 +46,15 @@ public async Task ExecuteAsync_KnownSearchTerm_ReturnsExpectedTitle() { var result = await _sut.ExecuteAsync("London"); - result.ShouldNotBeNull(); - result!.Text.ShouldBe("London"); + var success = result.ShouldBeOfType(); + success.Text.ShouldBe("London"); } [Fact] - public async Task ExecuteAsync_GibberishInput_ReturnsNull() + public async Task ExecuteAsync_GibberishInput_ReturnsNotFoundResult() { var result = await _sut.ExecuteAsync("xyzzy_no_such_article_12345"); - result.ShouldBeNull(); + result.ShouldBeOfType(); } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/RegSearchCommandTests.cs b/Bottomly.Tests/Commands/RegSearchCommandTests.cs index 8f1c363..1305176 100644 --- a/Bottomly.Tests/Commands/RegSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/RegSearchCommandTests.cs @@ -41,14 +41,14 @@ public async Task ExecuteAsync_SpecialChars_ReturnsSpecialCharsMessage() public async Task ExecuteAsync_ValidReg_ParsesHtmlResponse() { const string html = """ - - - - - - - - """; + + + + + + + + """; var factory = TestHelpers.CreateHttpClientFactory(html); var command = new RegSearchCommand(factory); @@ -81,11 +81,11 @@ public async Task ExecuteAsync_HtmlWithError_ReturnsErrorText() { // An element with an empty value causes make[0] to throw, which triggers the catch block const string html = """ - - -

Vehicle not found

- - """; + + +

Vehicle not found

+ + """; var factory = TestHelpers.CreateHttpClientFactory(html); var command = new RegSearchCommand(factory); diff --git a/Bottomly.Tests/Commands/SearchCommandTests.cs b/Bottomly.Tests/Commands/SearchCommandTests.cs index f28a1b4..fa52348 100644 --- a/Bottomly.Tests/Commands/SearchCommandTests.cs +++ b/Bottomly.Tests/Commands/SearchCommandTests.cs @@ -1,10 +1,10 @@ +using System.Net; using Bottomly.Commands.Search; using Bottomly.Configuration; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Shouldly; -using System.Net; namespace Bottomly.Tests.Commands; @@ -47,13 +47,13 @@ public async Task ExecuteAsync_WhitespaceInput_ReturnsEmptySearchTermErrorResult public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResultWithTitleAndLink() { const string json = """ - { - "type": "search", - "web": { - "results": [{ "title": "DotNet", "url": "https://dotnet.microsoft.com" }] - } - } - """; + { + "type": "search", + "web": { + "results": [{ "title": "DotNet", "url": "https://dotnet.microsoft.com" }] + } + } + """; var result = await CreateCommand(json).ExecuteAsync("dotnet"); diff --git a/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs b/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs index 0d1a09e..69591cf 100644 --- a/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/UrbanSearchCommandTests.cs @@ -1,5 +1,6 @@ using Bottomly.Commands; using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; @@ -8,35 +9,36 @@ namespace Bottomly.Tests.Commands; public class UrbanSearchCommandTests { [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() + public async Task ExecuteAsync_EmptyInput_ReturnsEmptyInputResult() { var mockFactory = new Mock(); - var command = new UrbanSearchCommand(mockFactory.Object); + var command = new UrbanSearchCommand(mockFactory.Object, NullLogger.Instance); var result = await command.ExecuteAsync(""); - result.ShouldBeNull(); + result.ShouldBeOfType(); } [Fact] - public async Task ExecuteAsync_WithResults_ReturnsDefinition() + public async Task ExecuteAsync_WithResults_ReturnsSuccessResult() { const string json = """{"list":[{"definition":"A domestic animal that owns you."}]}"""; - var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json), NullLogger.Instance); var result = await command.ExecuteAsync("cat"); - result.ShouldBe("A domestic animal that owns you."); + var success = result.ShouldBeOfType(); + success.Definition.ShouldBe("A domestic animal that owns you."); } [Fact] - public async Task ExecuteAsync_NoResults_ReturnsNull() + public async Task ExecuteAsync_NoResults_ReturnsNotFoundResult() { const string json = """{"list":[]}"""; - var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + var command = new UrbanSearchCommand(TestHelpers.CreateHttpClientFactory(json), NullLogger.Instance); var result = await command.ExecuteAsync("xyznotaword"); - result.ShouldBeNull(); + result.ShouldBeOfType(); } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs b/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs index 95f50dd..f833510 100644 --- a/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/WikipediaSearchCommandTests.cs @@ -1,5 +1,6 @@ using Bottomly.Commands; using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; @@ -8,37 +9,38 @@ namespace Bottomly.Tests.Commands; public class WikipediaSearchCommandTests { [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() + public async Task ExecuteAsync_EmptyInput_ReturnsEmptyInputResult() { var mockFactory = new Mock(); - var command = new WikipediaSearchCommand(mockFactory.Object); + var command = new WikipediaSearchCommand(mockFactory.Object, NullLogger.Instance); var result = await command.ExecuteAsync(""); - result.ShouldBeNull(); + result.ShouldBeOfType(); } [Fact] - public async Task ExecuteAsync_WithResults_ReturnsTitleAndLink() + public async Task ExecuteAsync_WithResults_ReturnsSuccessResult() { - const string json = """["cat",["Cat","Cat (disambiguation)"],["",""],["https://en.wikipedia.org/wiki/Cat","https://en.wikipedia.org/wiki/Cat_(disambiguation)"]]"""; - var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + const string json = + """["cat",["Cat","Cat (disambiguation)"],["",""],["https://en.wikipedia.org/wiki/Cat","https://en.wikipedia.org/wiki/Cat_(disambiguation)"]]"""; + var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json), NullLogger.Instance); var result = await command.ExecuteAsync("cat"); - result.ShouldNotBeNull(); - result!.Text.ShouldBe("Cat"); - result.Link.ShouldBe("https://en.wikipedia.org/wiki/Cat"); + var success = result.ShouldBeOfType(); + success.Text.ShouldBe("Cat"); + success.Link.ShouldBe("https://en.wikipedia.org/wiki/Cat"); } [Fact] - public async Task ExecuteAsync_NoResults_ReturnsNull() + public async Task ExecuteAsync_NoResults_ReturnsNotFoundResult() { const string json = """["unknownxyz",[],[],[]]"""; - var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json)); + var command = new WikipediaSearchCommand(TestHelpers.CreateHttpClientFactory(json), NullLogger.Instance); var result = await command.ExecuteAsync("unknownxyz"); - result.ShouldBeNull(); + result.ShouldBeOfType(); } } \ No newline at end of file diff --git a/Bottomly.Tests/Helpers/TestHelpers.cs b/Bottomly.Tests/Helpers/TestHelpers.cs index d562e7a..1e1e944 100644 --- a/Bottomly.Tests/Helpers/TestHelpers.cs +++ b/Bottomly.Tests/Helpers/TestHelpers.cs @@ -14,8 +14,8 @@ public static IOptions CreateOptions(string prefix = TestPrefix Options.Create(new BottomlyOptions { Prefix = prefix }); /// - /// Creates a that returns a fake HTTP response, - /// allowing unit tests to exercise HTTP-based commands without hitting real external APIs. + /// Creates a that returns a fake HTTP response, + /// allowing unit tests to exercise HTTP-based commands without hitting real external APIs. /// public static MsHttpClientFactory CreateHttpClientFactory(string responseContent, HttpStatusCode statusCode = HttpStatusCode.OK) @@ -26,7 +26,6 @@ public static MsHttpClientFactory CreateHttpClientFactory(string responseContent factory.Setup(f => f.CreateClient(It.IsAny())).Returns(client); return factory.Object; } - } internal class FakeHttpMessageHandler(string content, HttpStatusCode statusCode = HttpStatusCode.OK) diff --git a/Bottomly.Tests/Infrastructure/MongoDbFixture.cs b/Bottomly.Tests/Infrastructure/MongoDbFixture.cs index 2778318..ff38b40 100644 --- a/Bottomly.Tests/Infrastructure/MongoDbFixture.cs +++ b/Bottomly.Tests/Infrastructure/MongoDbFixture.cs @@ -4,8 +4,8 @@ namespace Bottomly.Tests.Infrastructure; /// -/// Shared xUnit fixture that starts a single MongoDB container for the entire test collection. -/// Each test class should call with a unique name to ensure isolation. +/// Shared xUnit fixture that starts a single MongoDB container for the entire test collection. +/// Each test class should call with a unique name to ensure isolation. /// public sealed class MongoDbFixture : IAsyncLifetime { @@ -14,8 +14,6 @@ public sealed class MongoDbFixture : IAsyncLifetime public IMongoClient Client { get; private set; } = null!; - public IMongoDatabase GetDatabase(string name) => Client.GetDatabase(name); - public async Task InitializeAsync() { await _container.StartAsync(); @@ -23,4 +21,6 @@ public async Task InitializeAsync() } public async Task DisposeAsync() => await _container.DisposeAsync(); + + public IMongoDatabase GetDatabase(string name) => Client.GetDatabase(name); } \ No newline at end of file diff --git a/Bottomly.Tests/LlmBot/LlmClientTests.cs b/Bottomly.Tests/LlmBot/LlmClientTests.cs index daa613a..530c124 100644 --- a/Bottomly.Tests/LlmBot/LlmClientTests.cs +++ b/Bottomly.Tests/LlmBot/LlmClientTests.cs @@ -8,13 +8,10 @@ namespace Bottomly.Tests.LlmBot; public class LlmClientTests { - private readonly Mock _mockChatClient = new(); private readonly LlmClient _broker; + private readonly Mock _mockChatClient = new(); - public LlmClientTests() - { - _broker = new LlmClient(_mockChatClient.Object, NullLogger.Instance); - } + public LlmClientTests() => _broker = new LlmClient(_mockChatClient.Object, NullLogger.Instance); [Fact] public async Task Respond_SuccessfulResponse_ReturnsLlmMessageResponse() diff --git a/Bottomly.Tests/Repositories/Integration/FeatureFlagRepositoryIntegrationTests.cs b/Bottomly.Tests/Repositories/Integration/FeatureFlagRepositoryIntegrationTests.cs index 9cb47a4..fdb0d89 100644 --- a/Bottomly.Tests/Repositories/Integration/FeatureFlagRepositoryIntegrationTests.cs +++ b/Bottomly.Tests/Repositories/Integration/FeatureFlagRepositoryIntegrationTests.cs @@ -127,4 +127,4 @@ public async Task SetAsync_DifferentFlagIds_AreStoredIndependently() (await _sut.GetAsync("EnableLlm")).ShouldBeTrue(); (await _sut.GetAsync("AnotherFlag")).ShouldBeFalse(); } -} +} \ No newline at end of file diff --git a/Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs b/Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs index dda33d4..3075c22 100644 --- a/Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs +++ b/Bottomly.Tests/Repositories/Integration/KarmaRepositoryIntegrationTests.cs @@ -91,7 +91,7 @@ public async Task GetKarmaReasonsAsync_SeparatesReasonedFromReasonless() result.Reasonless.ShouldBe(1); result.Reasoned.Count.ShouldBe(2); result.Reasoned.Select(k => k.Reason).ShouldBe( - ["fixed the build", "great docs"], ignoreOrder: true); + ["fixed the build", "great docs"], true); } [Fact] @@ -122,12 +122,12 @@ public async Task GetLeaderBoardAsync_ReturnsCorrectOrderAndSize() { // net_karma: PozzyPoz → –1, NeggyNeg → +1 // Leader board is sorted Descending by net_karma - await AddKarmaMultiple("leader-a", KarmaType.NeggyNeg, 5); // net +5 - await AddKarmaMultiple("leader-b", KarmaType.NeggyNeg, 3); // net +3 - await AddKarmaMultiple("leader-c", KarmaType.NeggyNeg, 1); // net +1 + await AddKarmaMultiple("leader-a", KarmaType.NeggyNeg, 5); // net +5 + await AddKarmaMultiple("leader-b", KarmaType.NeggyNeg, 3); // net +3 + await AddKarmaMultiple("leader-c", KarmaType.NeggyNeg, 1); // net +1 await AddKarmaMultiple("leader-d", KarmaType.PozzyPoz, 2); // net –2 (should not appear in top 3) - var board = await _sut.GetLeaderBoardAsync(3); + var board = await _sut.GetLeaderBoardAsync(); board.Count.ShouldBe(3); board[0].Username.ShouldBe("leader-a"); @@ -142,12 +142,12 @@ public async Task GetLeaderBoardAsync_ReturnsCorrectOrderAndSize() public async Task GetLoserBoardAsync_ReturnsCorrectOrderAndSize() { // Loser board is sorted Ascending by net_karma - await AddKarmaMultiple("loser-a", KarmaType.PozzyPoz, 5); // net –5 - await AddKarmaMultiple("loser-b", KarmaType.PozzyPoz, 3); // net –3 - await AddKarmaMultiple("loser-c", KarmaType.PozzyPoz, 1); // net –1 - await AddKarmaMultiple("loser-d", KarmaType.NeggyNeg, 2); // net +2 (should not appear in top 3) + await AddKarmaMultiple("loser-a", KarmaType.PozzyPoz, 5); // net –5 + await AddKarmaMultiple("loser-b", KarmaType.PozzyPoz, 3); // net –3 + await AddKarmaMultiple("loser-c", KarmaType.PozzyPoz, 1); // net –1 + await AddKarmaMultiple("loser-d", KarmaType.NeggyNeg, 2); // net +2 (should not appear in top 3) - var board = await _sut.GetLoserBoardAsync(3); + var board = await _sut.GetLoserBoardAsync(); board.Count.ShouldBe(3); board[0].Username.ShouldBe("loser-a"); diff --git a/Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs b/Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs index 2c9e04a..2a8740f 100644 --- a/Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs +++ b/Bottomly.Tests/Repositories/Integration/MemberRepositoryIntegrationTests.cs @@ -74,7 +74,7 @@ await _sut.AddAsync([ var result = await _sut.GetBySlackIdsAsync(["U001", "U003"]); result.Count.ShouldBe(2); - result.Select(m => m.Username).ShouldBe(["alice", "carol"], ignoreOrder: true); + result.Select(m => m.Username).ShouldBe(["alice", "carol"], true); } [Fact] @@ -145,10 +145,8 @@ await _sut.AddAsync(new Member } [Fact] - public async Task UpdateInfoAsync_WhenUsernameDoesNotExist_DoesNotThrow() - { + public async Task UpdateInfoAsync_WhenUsernameDoesNotExist_DoesNotThrow() => // Should silently no-op (UpdateOne with no match) await Should.NotThrowAsync(() => _sut.UpdateInfoAsync("ghost", "Ghost", Gender.Unknown, SassLevel.None, "")); - } } \ No newline at end of file diff --git a/Bottomly.Tests/Repositories/Unit/CachingMemberRepositoryTests.cs b/Bottomly.Tests/Repositories/Unit/CachingMemberRepositoryTests.cs index f92aad7..f39d18e 100644 --- a/Bottomly.Tests/Repositories/Unit/CachingMemberRepositoryTests.cs +++ b/Bottomly.Tests/Repositories/Unit/CachingMemberRepositoryTests.cs @@ -9,14 +9,11 @@ namespace Bottomly.Tests.Repositories.Unit; public class CachingMemberRepositoryTests { - private readonly Mock _mockInner = new(); private readonly IMemoryCache _cache = new MemoryCache(Options.Create(new MemoryCacheOptions())); + private readonly Mock _mockInner = new(); private readonly CachingMemberRepository _repo; - public CachingMemberRepositoryTests() - { - _repo = new CachingMemberRepository(_mockInner.Object, _cache); - } + public CachingMemberRepositoryTests() => _repo = new CachingMemberRepository(_mockInner.Object, _cache); [Fact] public async Task GetBySlackIdAsync_CacheMiss_CallsInnerAndCachesResult() @@ -73,7 +70,7 @@ public async Task GetBySlackIdAsync_AfterGetByUsernameAsync_DoesNotCallInner() _mockInner.Setup(r => r.GetByUsernameAsync("alice")).ReturnsAsync(member); await _repo.GetByUsernameAsync("alice"); // Caches by both keys - await _repo.GetBySlackIdAsync("U1"); // Should hit cache (cross-key) + await _repo.GetBySlackIdAsync("U1"); // Should hit cache (cross-key) _mockInner.Verify(r => r.GetBySlackIdAsync("U1"), Times.Never); } @@ -148,7 +145,9 @@ public async Task AddAsync_SingleMember_CachesMember() public async Task UpdateInfoAsync_UpdatesCacheWithFreshData() { var updated = new Member { SlackId = "U1", Username = "alice", FullName = "Alice Smith" }; - _mockInner.Setup(r => r.UpdateInfoAsync("alice", "Alice Smith", It.IsAny(), It.IsAny(), It.IsAny())) + _mockInner.Setup(r => + r.UpdateInfoAsync("alice", "Alice Smith", It.IsAny(), It.IsAny(), + It.IsAny())) .Returns(Task.CompletedTask); _mockInner.Setup(r => r.GetByUsernameAsync("alice")).ReturnsAsync(updated); @@ -158,4 +157,4 @@ public async Task UpdateInfoAsync_UpdatesCacheWithFreshData() result!.FullName.ShouldBe("Alice Smith"); _mockInner.Verify(r => r.GetByUsernameAsync("alice"), Times.Once); // Called once during UpdateInfoAsync } -} +} \ No newline at end of file diff --git a/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs index 421f76d..2ce89fe 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GiphyHandlerTests.cs @@ -52,7 +52,8 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsImageBlock() IReadOnlyList? capturedBlocks = null; _mockBroker - .Setup(b => b.SendBlocksMessageAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(b => b.SendBlocksMessageAsync(It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny())) .Callback, string, string?, string?>((blocks, _, _, _) => capturedBlocks = blocks) .Returns(Task.CompletedTask); diff --git a/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs index e1ef848..cc5f5a3 100644 --- a/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs @@ -25,22 +25,15 @@ public ImageSearchHandlerTests() NullLogger.Instance); } - private static MessageEvent CreateMessage(string text) - { - return new MessageEvent { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; - } + private static MessageEvent CreateMessage(string text) => + new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; [Fact] - public void CanHandle_ValidEvent_ReturnsTrue() - { - _handler.CanHandle(CreateMessage("_gi cats")).ShouldBeTrue(); - } + public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("_gi cats")).ShouldBeTrue(); [Fact] - public void CanHandle_InvalidEvent_ReturnsFalse() - { + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix here")).ShouldBeFalse(); - } [Fact] public async Task HandleAsync_ValidEvent_CallsCommandWithQuery() @@ -60,7 +53,8 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsImageBlock() IReadOnlyList? capturedBlocks = null; _mockBroker - .Setup(b => b.SendBlocksMessageAsync(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(b => b.SendBlocksMessageAsync(It.IsAny>(), It.IsAny(), + It.IsAny(), It.IsAny())) .Callback, string, string?, string?>((blocks, _, _, _) => capturedBlocks = blocks) .Returns(Task.CompletedTask); diff --git a/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs index 439869b..1a5dfb6 100644 --- a/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/DecrementMessageKarmaEventHandlerTests.cs @@ -21,7 +21,7 @@ public class DecrementMessageKarmaEventHandlerTests public DecrementMessageKarmaEventHandlerTests() { var options = TestHelpers.CreateOptions(); - var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var command = new AddKarmaCommand(_mockKarmaRepo.Object, NullLogger.Instance); var parser = new SlackParser(_mockMemberRepo.Object); _handler = new DecrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, diff --git a/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs index 7e99892..5d429b8 100644 --- a/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/IncrementMessageKarmaEventHandlerTests.cs @@ -21,7 +21,7 @@ public class IncrementMessageKarmaEventHandlerTests public IncrementMessageKarmaEventHandlerTests() { var options = TestHelpers.CreateOptions(); - var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var command = new AddKarmaCommand(_mockKarmaRepo.Object, NullLogger.Instance); var parser = new SlackParser(_mockMemberRepo.Object); _handler = new IncrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, diff --git a/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs b/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs index 120ca15..0821c67 100644 --- a/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/KarmaEventHandlers/KarmaHandlerCommandParsingTests.cs @@ -20,7 +20,7 @@ public class KarmaHandlerCommandParsingTests public KarmaHandlerCommandParsingTests() { var options = TestHelpers.CreateOptions(); - var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var command = new AddKarmaCommand(_mockKarmaRepo.Object, NullLogger.Instance); var parser = new SlackParser(_mockMemberRepo.Object); _handler = new IncrementMessageKarmaEventHandler(command, parser, _mockMemberRepo.Object, _mockBroker.Object, options, diff --git a/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs index 9b6050b..0709b92 100644 --- a/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/RegHandlerTests.cs @@ -1,5 +1,4 @@ using Bottomly.Commands; -using Bottomly.Configuration; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; diff --git a/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs index e63c524..4883586 100644 --- a/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/ReleaseHandlerTests.cs @@ -38,7 +38,7 @@ private static MessageEvent CreateMessage(string text) => [Fact] public async Task HandleAsync_WithResult_SendsResult() { - _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync("Latest Release: *v1.0* v1.0"); + _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync(new ReleaseSuccessResult("Latest Release: *v1.0* v1.0")); await _handler.HandleAsync(CreateMessage("_release")); @@ -46,9 +46,9 @@ public async Task HandleAsync_WithResult_SendsResult() } [Fact] - public async Task HandleAsync_NullResult_SendsUnableMessage() + public async Task HandleAsync_ErrorResult_SendsUnableMessage() { - _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync((string?)null); + _mockCommand.Setup(c => c.ExecuteAsync()).ReturnsAsync(new ReleaseErrorResult()); await _handler.HandleAsync(CreateMessage("_release")); diff --git a/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs index eaf358b..09a485b 100644 --- a/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs @@ -24,22 +24,16 @@ public SearchHandlerTests() NullLogger.Instance); } - private static MessageEvent CreateMessage(string text) - { - return new MessageEvent { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; - } + private static MessageEvent CreateMessage(string text) => + new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; [Fact] - public void CanHandle_ValidEvent_ReturnsTrue() - { + public void CanHandle_ValidEvent_ReturnsTrue() => _handler.CanHandle(CreateMessage("_g a valid google command")).ShouldBeTrue(); - } [Fact] - public void CanHandle_InvalidEvent_ReturnsFalse() - { + public void CanHandle_InvalidEvent_ReturnsFalse() => _handler.CanHandle(CreateMessage("no prefix here")).ShouldBeFalse(); - } [Fact] public async Task HandleAsync_ValidEvent_CallsCommandWithQuery() diff --git a/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs index d9987ba..e6bdfac 100644 --- a/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/UrbanHandlerTests.cs @@ -19,7 +19,7 @@ public UrbanHandlerTests() { var options = TestHelpers.CreateOptions(); var mockFactory = new Mock(); - _mockCommand = new Mock(mockFactory.Object); + _mockCommand = new Mock(mockFactory.Object, NullLogger.Instance); _handler = new UrbanHandler(_mockCommand.Object, _mockBroker.Object, options, NullLogger.Instance); } @@ -36,7 +36,7 @@ private static MessageEvent CreateMessage(string text) => [Fact] public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() { - _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync((string?)null); + _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync(new UrbanNotFoundResult()); await _handler.HandleAsync(CreateMessage("_ud hello")); @@ -46,7 +46,7 @@ public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() [Fact] public async Task HandleAsync_ValidEvent_WithResult_SendsReplyResponse() { - _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync("a greeting"); + _mockCommand.Setup(c => c.ExecuteAsync("hello")).ReturnsAsync(new UrbanSuccessResult("a greeting")); var message = CreateMessage("_ud hello"); await _handler.HandleAsync(message); @@ -56,9 +56,9 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsReplyResponse() } [Fact] - public async Task HandleAsync_ValidEvent_NullResult_SendsExerciseMessage() + public async Task HandleAsync_ValidEvent_NotFoundResult_SendsExerciseMessage() { - _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((string?)null); + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync(new UrbanNotFoundResult()); await _handler.HandleAsync(CreateMessage("_ud xyz")); diff --git a/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs index 0f736a3..c81da1e 100644 --- a/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/WikipediaHandlerTests.cs @@ -19,7 +19,7 @@ public WikipediaHandlerTests() { var options = TestHelpers.CreateOptions(); var mockFactory = new Mock(); - _mockCommand = new Mock(mockFactory.Object); + _mockCommand = new Mock(mockFactory.Object, NullLogger.Instance); _handler = new WikipediaHandler(_mockCommand.Object, _mockBroker.Object, options, NullLogger.Instance); } @@ -36,7 +36,7 @@ private static MessageEvent CreateMessage(string text) => [Fact] public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() { - _mockCommand.Setup(c => c.ExecuteAsync("octopus")).ReturnsAsync((WikipediaResult?)null); + _mockCommand.Setup(c => c.ExecuteAsync("octopus")).ReturnsAsync(new WikipediaNotFoundResult()); await _handler.HandleAsync(CreateMessage("_wik octopus")); @@ -47,7 +47,7 @@ public async Task HandleAsync_ValidEvent_CallsCommandWithTerm() public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() { _mockCommand.Setup(c => c.ExecuteAsync("octopus")) - .ReturnsAsync(new WikipediaResult("Octopus", "https://en.wikipedia.org/wiki/Octopus")); + .ReturnsAsync(new WikipediaSuccessResult("Octopus", "https://en.wikipedia.org/wiki/Octopus")); await _handler.HandleAsync(CreateMessage("_wik octopus")); @@ -56,9 +56,9 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() } [Fact] - public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage() + public async Task HandleAsync_ValidEvent_NotFoundResult_SendsNoResultMessage() { - _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((WikipediaResult?)null); + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync(new WikipediaNotFoundResult()); await _handler.HandleAsync(CreateMessage("_wik xyz")); diff --git a/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs b/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs index 2b34336..c6147b5 100644 --- a/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs +++ b/Bottomly.Tests/Slack/MemberlistPopulatorTests.cs @@ -1,7 +1,6 @@ using Bottomly.Models; using Bottomly.Repositories; using Bottomly.Slack; -using Microsoft.Extensions.Logging.Abstractions; using Moq; using Shouldly; using SlackNet; @@ -11,9 +10,9 @@ namespace Bottomly.Tests.Slack; public class MemberlistPopulatorTests { + private readonly Mock _mockRepo = new(); private readonly Mock _mockSlack = new(); private readonly Mock _mockUsers = new(); - private readonly Mock _mockRepo = new(); private readonly MemberlistPopulator _populator; public MemberlistPopulatorTests() diff --git a/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs b/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs index ad5137c..6295036 100644 --- a/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs +++ b/Bottomly.Tests/Slack/MembershipEventHandlers/MemberJoinedEventHandlerTests.cs @@ -11,10 +11,10 @@ namespace Bottomly.Tests.Slack.MembershipEventHandlers; public class MemberJoinedEventHandlerTests { + private readonly MemberJoinedEventHandler _handler; private readonly Mock _mockRepo = new(); private readonly Mock _mockSlack = new(); private readonly Mock _mockUsers = new(); - private readonly MemberJoinedEventHandler _handler; public MemberJoinedEventHandlerTests() { diff --git a/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs b/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs index 9c8147d..49121fa 100644 --- a/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs +++ b/Bottomly.Tests/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandlerTests.cs @@ -14,13 +14,13 @@ namespace Bottomly.Tests.Slack.MessageEventHandlers.ConversationMessageHandling; public class ConversationMessageHandlerTests { - private readonly Mock _mockLlmBroker = new(); - private readonly Mock _mockSlackBroker = new(); + private readonly ConversationMessageHandler _handler; private readonly Mock _mockApiClient = new(); private readonly Mock _mockConversations = new(); - private readonly Mock _mockMemberRepo = new(); private readonly Mock _mockFeatureFlagRepo = new(); - private readonly ConversationMessageHandler _handler; + private readonly Mock _mockLlmBroker = new(); + private readonly Mock _mockMemberRepo = new(); + private readonly Mock _mockSlackBroker = new(); public ConversationMessageHandlerTests() { @@ -38,7 +38,8 @@ public ConversationMessageHandlerTests() NullLogger.Instance); } - private static MessageEvent CreateMessage(string text, string user = "U1", string channel = "C1", string? threadTs = null) => + private static MessageEvent CreateMessage(string text, string user = "U1", string channel = "C1", + string? threadTs = null) => new() { Text = text, User = user, Channel = channel, Ts = "ts1", ThreadTs = threadTs }; [Theory] @@ -70,7 +71,7 @@ public async Task HandleAsync_SuccessfulLlmResponse_SendsReplyToChannel() _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) .ReturnsAsync(LlmMessageResponse.Create("Indeed, sir.")); - await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1")); + await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?")); _mockSlackBroker.Verify(b => b.SendMessageAsync("Indeed, sir.", "C1", null), Times.Once()); } @@ -91,9 +92,10 @@ public async Task HandleAsync_BuildsContextFromHistory() _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) .ReturnsAsync(LlmMessageResponse.Create("Of course.")); - await _handler.HandleAsync(CreateMessage("bottomly something", "U1", "C1")); + await _handler.HandleAsync(CreateMessage("bottomly something")); - _mockLlmBroker.Verify(b => b.Respond(It.IsAny(), It.IsAny()), Times.Once()); + _mockLlmBroker.Verify(b => b.Respond(It.IsAny(), It.IsAny()), + Times.Once()); } [Theory] @@ -113,7 +115,7 @@ public async Task HandleAsync_ErrorLlmResponse_SendsReplyToMessage(string respon _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) .ReturnsAsync(errorResponse); - await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1")); + await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?")); _mockSlackBroker.Verify(b => b.SendMessageAsync(It.IsAny(), "C1", "ts1"), Times.Once()); } @@ -126,7 +128,7 @@ public async Task HandleAsync_ErrorLlmResponseInThread_SendsReplyToThread() _mockLlmBroker.Setup(b => b.Respond(It.IsAny(), It.IsAny())) .ReturnsAsync(new LlmTimeoutResponse()); - await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1", threadTs: "thread_ts1")); + await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1", "thread_ts1")); _mockSlackBroker.Verify(b => b.SendMessageAsync(It.IsAny(), "C1", "thread_ts1"), Times.Once()); } @@ -136,7 +138,7 @@ public async Task HandleAsync_LlmFlagDisabled_SkipsLlmAndSendsNothing() { _mockFeatureFlagRepo.Setup(r => r.GetAsync("EnableLlm")).ReturnsAsync(false); - await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?", "U1", "C1")); + await _handler.HandleAsync(CreateMessage("bottomly what is 2+2?")); _mockLlmBroker.Verify( b => b.Respond(It.IsAny(), It.IsAny()), diff --git a/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs b/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs index 6189f32..2a2da84 100644 --- a/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs +++ b/Bottomly.Tests/Slack/ReactionHandlers/AddKarmaReactionHandlerTests.cs @@ -20,7 +20,7 @@ public class AddKarmaReactionHandlerTests public AddKarmaReactionHandlerTests() { - var command = new AddKarmaCommand(_mockKarmaRepo.Object); + var command = new AddKarmaCommand(_mockKarmaRepo.Object, NullLogger.Instance); _handler = new AddKarmaReactionHandler( command, new KarmaReactionMap(), diff --git a/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs b/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs index ba8837b..ad9341e 100644 --- a/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs +++ b/Bottomly.Tests/Slack/SlackMessageBrokerTests.cs @@ -13,16 +13,11 @@ namespace Bottomly.Tests.Slack; public class SlackMessageBrokerTests { - private readonly Mock _mockRepo = new(); - private readonly Mock _mockSlack = new(); private readonly Mock _mockChat = new(); - private readonly Mock _mockReactions = new(); private readonly Mock _mockConversations = new(); - - private SlackMessageBroker CreateBroker(string environment = "live") => - new(_mockRepo.Object, _mockSlack.Object, - Options.Create(new BottomlyOptions { Environment = environment }), - NullLogger.Instance); + private readonly Mock _mockReactions = new(); + private readonly Mock _mockRepo = new(); + private readonly Mock _mockSlack = new(); public SlackMessageBrokerTests() { @@ -34,6 +29,11 @@ public SlackMessageBrokerTests() .Returns(Task.CompletedTask); } + private SlackMessageBroker CreateBroker(string environment = "live") => + new(_mockRepo.Object, _mockSlack.Object, + Options.Create(new BottomlyOptions { Environment = environment }), + NullLogger.Instance); + [Fact] public async Task SendMessageAsync_EmptyText_DoesNotPost() { @@ -69,7 +69,7 @@ public async Task SendMessageAsync_WithReplyTs_SetsThreadTs() [Fact] public async Task SendMessageAsync_DebugMode_PrependsPrefixToText() { - var broker = CreateBroker(environment: "Dev"); + var broker = CreateBroker("Dev"); await broker.SendMessageAsync("Hello!", "C1"); @@ -107,7 +107,7 @@ public async Task SendBlocksMessageAsync_WithReplyTs_SetsThreadTs() [Fact] public async Task SendBlocksMessageAsync_DebugMode_PrependsPrefixToText() { - var broker = CreateBroker(environment: "Dev"); + var broker = CreateBroker("Dev"); var blocks = new List { new ImageBlock { ImageUrl = "https://example.com/img.jpg", AltText = "test" } }; await broker.SendBlocksMessageAsync(blocks, "C1", "fallback"); diff --git a/Bottomly.Tests/Slack/SlackWorkerTests.cs b/Bottomly.Tests/Slack/SlackWorkerTests.cs index a4e410a..4e88660 100644 --- a/Bottomly.Tests/Slack/SlackWorkerTests.cs +++ b/Bottomly.Tests/Slack/SlackWorkerTests.cs @@ -15,9 +15,9 @@ namespace Bottomly.Tests.Slack; public class SlackWorkerTests { - private readonly Mock _mockSocket = new(); private readonly Mock _mockBroker = new(); private readonly Mock _mockMemberRepo = new(); + private readonly Mock _mockSocket = new(); private SlackWorker CreateWorker( IEnumerable? handlers = null, @@ -149,7 +149,7 @@ public async Task ProcessMessageAsync_HelpMessage_RoutesToHelpHandler() mockHandler.Setup(h => h.BuildHelpMessage()).Returns("some help text"); var worker = CreateWorker([mockHandler.Object]); - await worker.ProcessMessageAsync(CreateMessage("_help", user: "U1")); + await worker.ProcessMessageAsync(CreateMessage("_help", "U1")); _mockBroker.Verify(b => b.SendDmAsync(It.IsAny(), "U1"), Times.Once()); mockHandler.Verify(h => h.HandleAsync(It.IsAny()), Times.Never()); @@ -170,7 +170,7 @@ public async Task ProcessMessageAsync_ResolvesUsernameFromSlackId() .Returns(Task.CompletedTask); var worker = CreateWorker([mockHandler.Object]); - await worker.ProcessMessageAsync(CreateMessage("_wiki cats", user: "U1")); + await worker.ProcessMessageAsync(CreateMessage("_wiki cats", "U1")); capturedUser.ShouldBe("alice"); } @@ -189,7 +189,7 @@ public async Task ProcessMessageAsync_UnknownSlackId_LeavesUserUnchanged() .Returns(Task.CompletedTask); var worker = CreateWorker([mockHandler.Object]); - await worker.ProcessMessageAsync(CreateMessage("_wiki cats", user: "U_UNKNOWN")); + await worker.ProcessMessageAsync(CreateMessage("_wiki cats", "U_UNKNOWN")); capturedUser.ShouldBe("U_UNKNOWN"); } diff --git a/Bottomly/AppInitialisation.cs b/Bottomly/AppInitialisation.cs index d74d2b7..da2e2db 100644 --- a/Bottomly/AppInitialisation.cs +++ b/Bottomly/AppInitialisation.cs @@ -23,4 +23,4 @@ public static async Task InitialiseAsync(this IHost app) await importer.ImportAsync(); } } -} +} \ No newline at end of file diff --git a/Bottomly/Commands/AddKarmaCommand.cs b/Bottomly/Commands/AddKarmaCommand.cs index 73190a7..a1f5de5 100644 --- a/Bottomly/Commands/AddKarmaCommand.cs +++ b/Bottomly/Commands/AddKarmaCommand.cs @@ -1,29 +1,43 @@ using Bottomly.Models; using Bottomly.Repositories; +using Microsoft.Extensions.Logging; namespace Bottomly.Commands; -public class AddKarmaCommand(IKarmaRepository karmaRepository) : ICommand +public abstract record AddKarmaResult; +public record AddKarmaSuccessResult(Karma Karma) : AddKarmaResult; +public record AddKarmaSelfAwardResult : AddKarmaResult; +public record AddKarmaErrorResult(string Error) : AddKarmaResult; + +public class AddKarmaCommand(IKarmaRepository karmaRepository, ILogger logger) : ICommand { public string GetPurpose() => "Awards an imaginary internet point to someone/something."; - public async Task ExecuteAsync(string awardedTo, string awardedBy, string reason, KarmaType karmaType) + public async Task ExecuteAsync(string awardedTo, string awardedBy, string reason, KarmaType karmaType) { if (karmaType == KarmaType.PozzyPoz && awardedBy.Equals(awardedTo, StringComparison.OrdinalIgnoreCase)) { - throw new InvalidOperationException("Can't give yourself positive karma"); + return new AddKarmaSelfAwardResult(); } - var karma = new Karma + try { - AwardedToUsername = awardedTo, - AwardedByUsername = awardedBy, - Reason = reason, - Awarded = DateTime.UtcNow, - KarmaType = karmaType - }; + var karma = new Karma + { + AwardedToUsername = awardedTo, + AwardedByUsername = awardedBy, + Reason = reason, + Awarded = DateTime.UtcNow, + KarmaType = karmaType + }; - await karmaRepository.AddAsync(karma); - return karma; + await karmaRepository.AddAsync(karma); + return new AddKarmaSuccessResult(karma); + } + catch (Exception ex) + { + logger.LogError(ex, "Error adding karma for {AwardedTo}", awardedTo); + return new AddKarmaErrorResult(ex.Message); + } } } \ No newline at end of file diff --git a/Bottomly/Commands/GiphyCommand.cs b/Bottomly/Commands/GiphyCommand.cs index 511914a..839bb4c 100644 --- a/Bottomly/Commands/GiphyCommand.cs +++ b/Bottomly/Commands/GiphyCommand.cs @@ -39,7 +39,7 @@ public virtual async Task ExecuteAsync(string searchTerm) return new GiphyEmptyResult(); } - var gifUrl = data.GetProperty("url").GetString(); + var gifUrl = data.GetProperty("images").GetProperty("original").GetProperty("url").GetString(); return string.IsNullOrEmpty(gifUrl) ? new GiphyEmptyResult() : new GiphySuccessResult(gifUrl); diff --git a/Bottomly/Commands/ReleaseCommand.cs b/Bottomly/Commands/ReleaseCommand.cs index 124b3fd..fc42d02 100644 --- a/Bottomly/Commands/ReleaseCommand.cs +++ b/Bottomly/Commands/ReleaseCommand.cs @@ -5,6 +5,10 @@ namespace Bottomly.Commands; +public abstract record ReleaseResult; +public record ReleaseSuccessResult(string Description) : ReleaseResult; +public record ReleaseErrorResult : ReleaseResult; + public class ReleaseCommand(IOptions options, ILogger logger) : ICommand { @@ -12,7 +16,7 @@ public class ReleaseCommand(IOptions options, ILogger "Describes the latest release of bottomly"; - public virtual async Task ExecuteAsync() + public virtual async Task ExecuteAsync() { try { @@ -31,12 +35,12 @@ public class ReleaseCommand(IOptions options, ILogger logger) : ICommand { private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; public string GetPurpose() => "Tells you what something _really_ means."; - public virtual async Task ExecuteAsync(string searchTerm) + public virtual async Task ExecuteAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) { - return null; + return new UrbanEmptyInputResult(); } - var url = $"http://api.urbandictionary.com/v0/define?term={Uri.EscapeDataString(searchTerm)}"; - var httpClient = _httpClientFactory.CreateClient(); - var response = await httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var list = doc.RootElement.GetProperty("list"); - - if (list.GetArrayLength() == 0) + try { - return null; + var url = $"http://api.urbandictionary.com/v0/define?term={Uri.EscapeDataString(searchTerm)}"; + var httpClient = _httpClientFactory.CreateClient(); + var response = await httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var list = doc.RootElement.GetProperty("list"); + + if (list.GetArrayLength() == 0) + { + return new UrbanNotFoundResult(); + } + + var index = Random.Shared.Next(list.GetArrayLength()); + var definition = list[index].GetProperty("definition").GetString(); + return definition is not null + ? new UrbanSuccessResult(definition) + : new UrbanNotFoundResult(); + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing Urban Dictionary search"); + return new UrbanErrorResult(ex.Message); } - - var index = Random.Shared.Next(list.GetArrayLength()); - return list[index].GetProperty("definition").GetString(); } } \ No newline at end of file diff --git a/Bottomly/Commands/WikipediaSearchCommand.cs b/Bottomly/Commands/WikipediaSearchCommand.cs index 9c46ab6..132a406 100644 --- a/Bottomly/Commands/WikipediaSearchCommand.cs +++ b/Bottomly/Commands/WikipediaSearchCommand.cs @@ -1,44 +1,51 @@ using System.Text.Json; +using Microsoft.Extensions.Logging; namespace Bottomly.Commands; -public record WikipediaResult(string Text, string Link); +public abstract record WikipediaResult; +public record WikipediaSuccessResult(string Text, string Link) : WikipediaResult; +public record WikipediaNotFoundResult : WikipediaResult; +public record WikipediaEmptyInputResult : WikipediaResult; +public record WikipediaErrorResult(string Error) : WikipediaResult; -public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICommand +public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory, ILogger logger) : ICommand { private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - public string GetPurpose() - { - return "Performs a wikipedia search and returns the top hit."; - } + public string GetPurpose() => "Performs a wikipedia search and returns the top hit."; - public virtual async Task ExecuteAsync(string searchTerm) + public virtual async Task ExecuteAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) { - return null; + return new WikipediaEmptyInputResult(); } - var url = - $"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search={Uri.EscapeDataString(searchTerm)}"; - var httpClient = _httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Bottomly/1.0"); - var response = await httpClient.GetAsync(url); - response.EnsureSuccessStatusCode(); - - using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); - var root = doc.RootElement; - - // Response is: [searchTerm, [titles], [descriptions], [links]] - var titles = root[1]; - var links = root[3]; - - if (titles.GetArrayLength() == 0) + try { - return null; + var url = + $"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search={Uri.EscapeDataString(searchTerm)}"; + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Bottomly/1.0"); + var response = await httpClient.GetAsync(url); + response.EnsureSuccessStatusCode(); + + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = doc.RootElement; + + // Response is: [searchTerm, [titles], [descriptions], [links]] + var titles = root[1]; + var links = root[3]; + + return titles.GetArrayLength() == 0 + ? new WikipediaNotFoundResult() + : new WikipediaSuccessResult(titles[0].GetString()!, links[0].GetString()!); + } + catch (Exception ex) + { + logger.LogError(ex, "Error executing Wikipedia search"); + return new WikipediaErrorResult(ex.Message); } - - return new WikipediaResult(titles[0].GetString()!, links[0].GetString()!); } } \ No newline at end of file diff --git a/Bottomly/Configuration/ServiceCollectionExtensions.cs b/Bottomly/Configuration/ServiceCollectionExtensions.cs index f36e971..2bd0303 100644 --- a/Bottomly/Configuration/ServiceCollectionExtensions.cs +++ b/Bottomly/Configuration/ServiceCollectionExtensions.cs @@ -5,7 +5,8 @@ namespace Bottomly.Configuration; public static class ServiceCollectionExtensions { - public static IServiceCollection AddBottomlyConfiguration(this IServiceCollection services, IConfiguration configuration) + public static IServiceCollection AddBottomlyConfiguration(this IServiceCollection services, + IConfiguration configuration) { services.Configure(opts => { @@ -23,4 +24,4 @@ public static IServiceCollection AddBottomlyConfiguration(this IServiceCollectio return services; } -} +} \ No newline at end of file diff --git a/Bottomly/HostBuilderExtensions.cs b/Bottomly/HostBuilderExtensions.cs index 184e418..dd7d2fa 100644 --- a/Bottomly/HostBuilderExtensions.cs +++ b/Bottomly/HostBuilderExtensions.cs @@ -25,4 +25,4 @@ public void RegisterCommands(Assembly assembly) => .ToList() .ForEach(t => builder.Services.AddSingleton(t)); } -} +} \ No newline at end of file diff --git a/Bottomly/LlmBot/ServiceCollectionExtensions.cs b/Bottomly/LlmBot/ServiceCollectionExtensions.cs index 7497075..67b20ae 100644 --- a/Bottomly/LlmBot/ServiceCollectionExtensions.cs +++ b/Bottomly/LlmBot/ServiceCollectionExtensions.cs @@ -34,4 +34,4 @@ public static IHostApplicationBuilder AddBottomlyLlm(this IHostApplicationBuilde return builder; } -} +} \ No newline at end of file diff --git a/Bottomly/Models/FeatureFlag.cs b/Bottomly/Models/FeatureFlag.cs index 6c578a4..b4ab9f2 100644 --- a/Bottomly/Models/FeatureFlag.cs +++ b/Bottomly/Models/FeatureFlag.cs @@ -4,7 +4,7 @@ namespace Bottomly.Models; public class FeatureFlag { - [BsonId][BsonElement("_id")] public string Id { get; set; } = string.Empty; + [BsonId] [BsonElement("_id")] public string Id { get; set; } = string.Empty; [BsonElement("enabled")] public bool Enabled { get; set; } -} +} \ No newline at end of file diff --git a/Bottomly/Models/Karma.cs b/Bottomly/Models/Karma.cs index 2bd7a2c..a75dcf7 100644 --- a/Bottomly/Models/Karma.cs +++ b/Bottomly/Models/Karma.cs @@ -22,9 +22,10 @@ public class Karma [BsonElement("karma_type")] public string KarmaTypeValue { get; set; } = string.Empty; /// - /// Legacy element to enable support from pymongo persisted data + /// Legacy element to enable support from pymongo persisted data /// - [BsonElement("_cls")] public string Cls { get; set; } = string.Empty; + [BsonElement("_cls")] + public string Cls { get; set; } = string.Empty; [BsonIgnore] public KarmaType KarmaType diff --git a/Bottomly/Models/Member.cs b/Bottomly/Models/Member.cs index 11f2770..eae6c81 100644 --- a/Bottomly/Models/Member.cs +++ b/Bottomly/Models/Member.cs @@ -4,7 +4,7 @@ namespace Bottomly.Models; public class Member { - [BsonId][BsonElement("_id")] public string Username { get; set; } = string.Empty; + [BsonId] [BsonElement("_id")] public string Username { get; set; } = string.Empty; [BsonElement("slack_id")] public string SlackId { get; set; } = string.Empty; diff --git a/Bottomly/Repositories/CachingMemberRepository.cs b/Bottomly/Repositories/CachingMemberRepository.cs index fec0737..628cc1c 100644 --- a/Bottomly/Repositories/CachingMemberRepository.cs +++ b/Bottomly/Repositories/CachingMemberRepository.cs @@ -12,29 +12,42 @@ public async Task> GetAllAsync() { var members = await inner.GetAllAsync(); foreach (var member in members) + { CacheMember(member); + } + return members; } public async Task GetByUsernameAsync(string username) { if (cache.TryGetValue(UsernameKeyPrefix + username, out Member? cached)) + { return cached; + } var member = await inner.GetByUsernameAsync(username); if (member is not null) + { CacheMember(member); + } + return member; } public async Task GetBySlackIdAsync(string slackId) { if (cache.TryGetValue(SlackKeyPrefix + slackId, out Member? cached)) + { return cached; + } var member = await inner.GetBySlackIdAsync(slackId); if (member is not null) + { CacheMember(member); + } + return member; } @@ -47,9 +60,13 @@ public async Task> GetBySlackIdsAsync(IEnumerable slackIds) foreach (var id in idList) { if (cache.TryGetValue(SlackKeyPrefix + id, out Member? cached)) + { result.Add(cached!); + } else + { misses.Add(id); + } } if (misses.Count > 0) @@ -76,17 +93,24 @@ public async Task AddAsync(IEnumerable members) var memberList = members.ToList(); await inner.AddAsync(memberList); foreach (var member in memberList) + { CacheMember(member); + } } - public async Task UpdateInfoAsync(string username, string fullName, Gender gender, SassLevel sassLevel, string miscInfo) + public async Task UpdateInfoAsync(string username, string fullName, Gender gender, SassLevel sassLevel, + string miscInfo) { await inner.UpdateInfoAsync(username, fullName, gender, sassLevel, miscInfo); var updated = await inner.GetByUsernameAsync(username); if (updated is not null) + { CacheMember(updated); + } else + { cache.Remove(UsernameKeyPrefix + username); + } } private void CacheMember(Member member) @@ -94,4 +118,4 @@ private void CacheMember(Member member) cache.Set(SlackKeyPrefix + member.SlackId, member); cache.Set(UsernameKeyPrefix + member.Username, member); } -} +} \ No newline at end of file diff --git a/Bottomly/Repositories/FeatureFlagRepository.cs b/Bottomly/Repositories/FeatureFlagRepository.cs index 0dbfc47..6a7b300 100644 --- a/Bottomly/Repositories/FeatureFlagRepository.cs +++ b/Bottomly/Repositories/FeatureFlagRepository.cs @@ -27,4 +27,4 @@ public async Task SeedAsync(string flagId, bool defaultValue) var update = Builders.Update.SetOnInsert(f => f.Enabled, defaultValue); await _collection.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true }); } -} +} \ No newline at end of file diff --git a/Bottomly/Repositories/IFeatureFlagRepository.cs b/Bottomly/Repositories/IFeatureFlagRepository.cs index 805b29f..442dcb0 100644 --- a/Bottomly/Repositories/IFeatureFlagRepository.cs +++ b/Bottomly/Repositories/IFeatureFlagRepository.cs @@ -5,4 +5,4 @@ public interface IFeatureFlagRepository Task GetAsync(string flagId); Task SetAsync(string flagId, bool enabled); Task SeedAsync(string flagId, bool defaultValue); -} +} \ No newline at end of file diff --git a/Bottomly/Repositories/MemberCachePopulator.cs b/Bottomly/Repositories/MemberCachePopulator.cs index 6a738ca..2abf7e2 100644 --- a/Bottomly/Repositories/MemberCachePopulator.cs +++ b/Bottomly/Repositories/MemberCachePopulator.cs @@ -13,4 +13,4 @@ public async Task StartAsync(CancellationToken cancellationToken) } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; -} +} \ No newline at end of file diff --git a/Bottomly/Repositories/MemberRepository.cs b/Bottomly/Repositories/MemberRepository.cs index b69a853..aa9a337 100644 --- a/Bottomly/Repositories/MemberRepository.cs +++ b/Bottomly/Repositories/MemberRepository.cs @@ -22,7 +22,8 @@ public Task> GetBySlackIdsAsync(IEnumerable slackIds) => public async Task AddAsync(Member member) => await _collection.InsertOneAsync(member); public async Task AddAsync(IEnumerable members) => await _collection.InsertManyAsync(members); - public async Task UpdateInfoAsync(string username, string fullName, Gender gender, SassLevel sassLevel, string miscInfo) + public async Task UpdateInfoAsync(string username, string fullName, Gender gender, SassLevel sassLevel, + string miscInfo) { var update = Builders.Update .Set(m => m.FullName, fullName) diff --git a/Bottomly/Repositories/ServiceCollectionExtensions.cs b/Bottomly/Repositories/ServiceCollectionExtensions.cs index 8c71e45..a50d41b 100644 --- a/Bottomly/Repositories/ServiceCollectionExtensions.cs +++ b/Bottomly/Repositories/ServiceCollectionExtensions.cs @@ -18,4 +18,4 @@ public static IServiceCollection AddBottomlyRepositories(this IServiceCollection return services; } -} +} \ No newline at end of file diff --git a/Bottomly/Seed/MemberSeedDataImporter.cs b/Bottomly/Seed/MemberSeedDataImporter.cs index e1a43d8..9cf3ca6 100644 --- a/Bottomly/Seed/MemberSeedDataImporter.cs +++ b/Bottomly/Seed/MemberSeedDataImporter.cs @@ -50,6 +50,7 @@ private string ResolveSeedDir() dir = dir.Parent; } + return Path.Combine(env.ContentRootPath, "MemberSeedData"); } @@ -60,15 +61,17 @@ private async Task ImportFileAsync(string file) var yaml = await File.ReadAllTextAsync(file); var dto = _deserializer.Deserialize(yaml); - if (!Enum.TryParse(dto.Gender, ignoreCase: true, out var gender)) + if (!Enum.TryParse(dto.Gender, true, out var gender)) { - logger.LogWarning("Unknown gender value '{Value}' in {File}. Defaulting to Unknown.", dto.Gender, Path.GetFileName(file)); + logger.LogWarning("Unknown gender value '{Value}' in {File}. Defaulting to Unknown.", dto.Gender, + Path.GetFileName(file)); gender = Gender.Unknown; } - if (!Enum.TryParse(dto.SassLevel, ignoreCase: true, out var sassLevel)) + if (!Enum.TryParse(dto.SassLevel, true, out var sassLevel)) { - logger.LogWarning("Unknown sass_level value '{Value}' in {File}. Defaulting to Moderate.", dto.SassLevel, Path.GetFileName(file)); + logger.LogWarning("Unknown sass_level value '{Value}' in {File}. Defaulting to Moderate.", + dto.SassLevel, Path.GetFileName(file)); sassLevel = SassLevel.Moderate; } diff --git a/Bottomly/Seed/ServiceCollectionExtensions.cs b/Bottomly/Seed/ServiceCollectionExtensions.cs index 544c4de..a85bb3c 100644 --- a/Bottomly/Seed/ServiceCollectionExtensions.cs +++ b/Bottomly/Seed/ServiceCollectionExtensions.cs @@ -12,4 +12,4 @@ public static IServiceCollection AddBottomlySeeding(this IServiceCollection serv return services; } -} +} \ No newline at end of file diff --git a/Bottomly/Slack/ISlackMessageBroker.cs b/Bottomly/Slack/ISlackMessageBroker.cs index 2d33984..dd7b3af 100644 --- a/Bottomly/Slack/ISlackMessageBroker.cs +++ b/Bottomly/Slack/ISlackMessageBroker.cs @@ -5,7 +5,10 @@ namespace Bottomly.Slack; public interface ISlackMessageBroker { Task SendMessageAsync(string text, string channel, string? replyToTs = null); - Task SendBlocksMessageAsync(IReadOnlyList blocks, string channel, string? text = null, string? replyToTs = null); + + Task SendBlocksMessageAsync(IReadOnlyList blocks, string channel, string? text = null, + string? replyToTs = null); + Task SendReactionAsync(string emoji, string channel, string timestamp); Task SendDmAsync(string text, string username); } \ No newline at end of file diff --git a/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs b/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs index 0d1f14b..64b4dde 100644 --- a/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/AbstractMessageEventHandler.cs @@ -74,7 +74,8 @@ protected async Task SendMessageResponseAsync(string text, MessageEvent message, await Broker.SendMessageAsync(text, message.Channel, replyTs); } - protected async Task SendBlocksResponseAsync(IReadOnlyList blocks, MessageEvent message, string? text = null, bool asReply = false) + protected async Task SendBlocksResponseAsync(IReadOnlyList blocks, MessageEvent message, string? text = null, + bool asReply = false) { var replyTs = asReply ? message.TsForReply() : null; await Broker.SendBlocksMessageAsync(blocks, message.Channel, text, replyTs); diff --git a/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs b/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs index e462c86..0fd2df7 100644 --- a/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/ConversationMessageHandling/ConversationMessageHandler.cs @@ -20,10 +20,14 @@ ILogger logger public bool CanHandle(MessageEvent message) { - if (message.Text.Contains("bottomly")) return true; + if (message.Text.Contains("bottomly")) + { + return true; + } + return _botMemberTask.IsCompletedSuccessfully - && _botMemberTask.Result?.SlackId is { } botId - && message.Text.Contains($"<@{botId}>"); + && _botMemberTask.Result?.SlackId is { } botId + && message.Text.Contains($"<@{botId}>"); } public async Task HandleAsync(MessageEvent message) diff --git a/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs b/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs index 7675448..a435b6e 100644 --- a/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/KarmaEventHandlers/AbstractMessageKarmaEventHandler.cs @@ -36,12 +36,24 @@ public override bool CanHandle(MessageEvent message) => protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var args = await ParseCommandTextAsync(message.Text!); - await KarmaCommand.ExecuteAsync( + var result = await KarmaCommand.ExecuteAsync( args.Recipient, message.User, args.Reason, args.KarmaType); - await SendReactionResponseAsync(message); + + switch (result) + { + case AddKarmaSuccessResult: + await SendReactionResponseAsync(message); + break; + case AddKarmaSelfAwardResult: + await SendMessageResponseAsync("Can't give yourself positive karma", message); + break; + case AddKarmaErrorResult error: + Logger.LogError("Karma command failed: {Error}", error.Error); + break; + } } private async Task ParseCommandTextAsync(string commandText) diff --git a/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs b/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs index ec8cc69..8d5a0a1 100644 --- a/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/ReleaseHandler.cs @@ -21,7 +21,11 @@ public class ReleaseHandler( protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var result = await command.ExecuteAsync(); - var response = result ?? "Unable to retrieve latest release info."; + var response = result switch + { + ReleaseSuccessResult success => success.Description, + _ => "Unable to retrieve latest release info." + }; await SendMessageResponseAsync(response, message); } } \ No newline at end of file diff --git a/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs index ea89fe4..d6dbad3 100644 --- a/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs @@ -18,10 +18,7 @@ public class SearchHandler( protected override ICommand Command => command; protected override string CommandSymbol => "g"; - protected override string GetUsage() - { - return CommandTrigger + ""; - } + protected override string GetUsage() => CommandTrigger + ""; protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { diff --git a/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs b/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs index 7745bfb..90d4ba8 100644 --- a/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/UrbanHandler.cs @@ -22,7 +22,11 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var term = message.Text![CommandTrigger.Length..]; var result = await command.ExecuteAsync(term); - var response = result ?? "Left as an exercise for the reader."; + var response = result switch + { + UrbanSuccessResult success => success.Definition, + _ => "Left as an exercise for the reader." + }; await SendMessageResponseAsync(response, message, true); } } \ No newline at end of file diff --git a/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs b/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs index 95f4ef4..7c44c99 100644 --- a/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/WikipediaHandler.cs @@ -22,9 +22,14 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var term = message.Text![CommandTrigger.Length..]; var result = await command.ExecuteAsync(term); - var response = result is null - ? $"No results found for \"{term}\"" - : $"<{result.Link}|{result.Text}>"; + var response = result switch + { + WikipediaSuccessResult success => $"<{success.Link}|{success.Text}>", + WikipediaNotFoundResult => $"No results found for \"{term}\"", + WikipediaEmptyInputResult => $"No results found for \"{term}\"", + WikipediaErrorResult => $"No results found for \"{term}\"", + _ => $"No results found for \"{term}\"" + }; await SendMessageResponseAsync(response, message); } } \ No newline at end of file diff --git a/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs b/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs index 55a11a7..1c49ebd 100644 --- a/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs +++ b/Bottomly/Slack/ReactionHandlers/AddKarmaReactionHandler.cs @@ -36,13 +36,13 @@ public async Task HandleAsync(ReactionAdded reactionEvent) return; } - await command.ExecuteAsync( + var result = await command.ExecuteAsync( reactee.Username, reactor.Username, $"Reacted with {reaction}", reactionMap.GetKarmaType(reaction)); - if (reactionEvent.Item is ReactionMessage messageItem) + if (result is AddKarmaSuccessResult && reactionEvent.Item is ReactionMessage messageItem) { await broker.SendReactionAsync("robot_face", messageItem.Channel, messageItem.Ts); } diff --git a/Bottomly/Slack/ServiceCollectionExtensions.cs b/Bottomly/Slack/ServiceCollectionExtensions.cs index d39d11f..45ea9a7 100644 --- a/Bottomly/Slack/ServiceCollectionExtensions.cs +++ b/Bottomly/Slack/ServiceCollectionExtensions.cs @@ -48,4 +48,4 @@ public static IServiceCollection AddBottomlySlack(this IServiceCollection servic return services; } -} +} \ No newline at end of file diff --git a/Bottomly/Slack/SlackMessageBroker.cs b/Bottomly/Slack/SlackMessageBroker.cs index 27fc152..5079002 100644 --- a/Bottomly/Slack/SlackMessageBroker.cs +++ b/Bottomly/Slack/SlackMessageBroker.cs @@ -45,7 +45,8 @@ public async Task SendMessageAsync(string text, string channel, string? replyToT } } - public async Task SendBlocksMessageAsync(IReadOnlyList blocks, string channel, string? text = null, string? replyToTs = null) + public async Task SendBlocksMessageAsync(IReadOnlyList blocks, string channel, string? text = null, + string? replyToTs = null) { try { diff --git a/Bottomly/Slack/SlackWorker.cs b/Bottomly/Slack/SlackWorker.cs index 444d4b1..39cffb6 100644 --- a/Bottomly/Slack/SlackWorker.cs +++ b/Bottomly/Slack/SlackWorker.cs @@ -1,4 +1,3 @@ -using Bottomly.LlmBot; using Bottomly.Repositories; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Slack.ReactionHandlers; diff --git a/README.md b/README.md index 2b58a1c..ba847ec 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ A .NET Slack bot ### Development -The project uses [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) for local orchestration. Running the `Bottomly.AppHost` project will spin up MongoDB, Ollama (LLM), and the bot itself: +The project uses [.NET Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview) for local +orchestration. Running the `Bottomly.AppHost` project will spin up MongoDB, Ollama (LLM), and the bot itself: ```bash dotnet run --project Bottomly.AppHost @@ -16,7 +17,8 @@ dotnet run --project Bottomly.AppHost ### Production -Run the `Bottomly` project directly, ensuring MongoDB and Ollama are available and all required environment variables are set: +Run the `Bottomly` project directly, ensuring MongoDB and Ollama are available and all required environment variables +are set: ```bash dotnet run --project Bottomly @@ -35,27 +37,31 @@ Within the `Bottomly` project, code is organised by responsibility: ### Commands -Classes here implement the actions triggered by bot commands. Each command performs an action and returns a result, delegating any persistence work to the `Repositories` layer. +Classes here implement the actions triggered by bot commands. Each command performs an action and returns a result, +delegating any persistence work to the `Repositories` layer. ### Repositories -Defines the persistence layer. All behaviours which relate to persisting information beyond a simple request/response cycle are defined here, e.g. user karma and member lists. Backed by MongoDB. +Defines the persistence layer. All behaviours which relate to persisting information beyond a simple request/response +cycle are defined here, e.g. user karma and member lists. Backed by MongoDB. ### Slack Implements the Slack delivery channel using [SlackNet](https://github.com/soxtoby/SlackNet) in Socket Mode. -`SlackWorker` is a background service that maintains the Slack connection and dispatches incoming events. Event handlers are split by type: +`SlackWorker` is a background service that maintains the Slack connection and dispatches incoming events. Event handlers +are split by type: * `MessageEventHandlers/` — handle text messages. Each handler implements `IMessageEventHandler`, which requires: - * `CanHandle(message)`: returns `true` if this handler should process the given message. - * `Handle(message)`: extracts relevant information and invokes the corresponding command. + * `CanHandle(message)`: returns `true` if this handler should process the given message. + * `Handle(message)`: extracts relevant information and invokes the corresponding command. * `ReactionHandlers/` — handle emoji reactions (e.g. karma changes). * `MembershipEventHandlers/` — handle member join events. ### LlmBot -Wraps [OllamaSharp](https://github.com/awaescher/OllamaSharp) to provide conversational AI responses via a locally-hosted Ollama instance. +Wraps [OllamaSharp](https://github.com/awaescher/OllamaSharp) to provide conversational AI responses via a +locally-hosted Ollama instance. ### Tests @@ -63,15 +69,20 @@ Has an internal structure matching the rest of the app. Each app file should hav The test suite is split into two categories: -* **Unit tests** — fast, in-process tests using [Moq](https://github.com/devlooped/moq) for mocking and [Shouldly](https://github.com/shouldly/shouldly) for assertions. Cover commands, event handlers, and other logic that can be exercised without external dependencies. -* **Integration tests** (`Repositories/Integration/`) — use [Testcontainers](https://dotnet.testcontainers.org/) to spin up a real MongoDB container and exercise the repository layer end-to-end, including aggregation pipelines. These require Docker to be running locally; they run automatically in CI on `ubuntu-latest`. +* **Unit tests** — fast, in-process tests using [Moq](https://github.com/devlooped/moq) for mocking + and [Shouldly](https://github.com/shouldly/shouldly) for assertions. Cover commands, event handlers, and other logic + that can be exercised without external dependencies. +* **Integration tests** (`Repositories/Integration/`) — use [Testcontainers](https://dotnet.testcontainers.org/) to spin + up a real MongoDB container and exercise the repository layer end-to-end, including aggregation pipelines. These + require Docker to be running locally; they run automatically in CI on `ubuntu-latest`. ## Configuration The following secrets/environment variables _must_ be configured for the app to run: * `bottomly_env`: Describes the active environment. If not set to `live`, output messages will be marked as "DEBUG". -* `bottomly_prefix`: The prefix for bot commands (e.g. `!`). Most commands require this prefix, allowing easy switching between test and production environments. +* `bottomly_prefix`: The prefix for bot commands (e.g. `!`). Most commands require this prefix, allowing easy switching + between test and production environments. * `bottomly_slack_bot_token`: The Slack bot token (`xoxb-...`) for Socket Mode access. * `bottomly_slack_app_token`: The Slack app-level token (`xapp-...`) for Socket Mode. * `bottomly_google_api_key`: A valid Google API key. @@ -79,8 +90,10 @@ The following secrets/environment variables _must_ be configured for the app to * `bottomly_giphy_api_key`: A valid Giphy API key. * `bottomly_github_token`: A GitHub personal access token. -MongoDB and Ollama connection strings are managed automatically by Aspire in development. In production, configure them via standard .NET connection string settings. +MongoDB and Ollama connection strings are managed automatically by Aspire in development. In production, configure them +via standard .NET connection string settings. ## Contributing -Most additions will involve adding a handler and command pair in the relevant modules, with the command delegating to the repository layer for any persistence work. PRs will *not be merged* without proper test coverage. +Most additions will involve adding a handler and command pair in the relevant modules, with the command delegating to +the repository layer for any persistence work. PRs will *not be merged* without proper test coverage. diff --git a/bottomly.net.slnx b/bottomly.net.slnx index d78ed50..f6ce000 100644 --- a/bottomly.net.slnx +++ b/bottomly.net.slnx @@ -1,17 +1,17 @@ - - - + + + - - - - + + + + - - - - + + + +