diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 16dd15b..1fd89ff 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 @@ -29,6 +29,8 @@ jobs: - name: Test run: dotnet test bottomly.net.slnx --no-build --configuration Release --verbosity normal + env: + BOTTOMLY_BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} - name: Build Docker image run: docker build -t bottomly . diff --git a/.github/workflows/push_to_live.yml b/.github/workflows/push_to_live.yml index ded1a5f..4f4f2dc 100644 --- a/.github/workflows/push_to_live.yml +++ b/.github/workflows/push_to_live.yml @@ -1,9 +1,9 @@ --- on: workflow_run: - workflows: [".NET Build and Test"] - types: [completed] - branches: [main] + workflows: [ ".NET Build and Test" ] + types: [ completed ] + branches: [ main ] name: Push to Live jobs: build-and-deploy: @@ -14,7 +14,7 @@ jobs: permissions: packages: read steps: - # checkout the repo + # checkout the repo - name: Checkout GitHub Action uses: actions/checkout@main - name: Login via Azure CLI @@ -58,8 +58,7 @@ jobs: name: bottomly-live location: uk south environment-variables: bottomly_giphy_api_key=${{ secrets.GIPHY_API_KEY }} - bottomly_google_api_key=${{ secrets.GOOGLE_API_KEY }} - bottomly_google_cse_id=${{ secrets.GOOGLE_CSE_ID }} + bottomly_brave_api_key=${{ secrets.BRAVE_API_KEY }} bottomly_slack_bot_token=${{ secrets.SLACK_TOKEN }} bottomly_slack_app_token=${{ secrets.SLACK_APP_TOKEN }} bottomly_prefix=${{ secrets.PREFIX }} diff --git a/Bottomly.Tests/Bottomly.Tests.csproj b/Bottomly.Tests/Bottomly.Tests.csproj index 7337624..f92ffc2 100644 --- a/Bottomly.Tests/Bottomly.Tests.csproj +++ b/Bottomly.Tests/Bottomly.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs deleted file mode 100644 index 6b23fe4..0000000 --- a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Options; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GoogleImageSearchCommandTests -{ - [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() - { - var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); - var command = new GoogleImageSearchCommand(options); - - var result = await command.ExecuteAsync(""); - - result.ShouldBeNull(); - } -} \ No newline at end of file diff --git a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs deleted file mode 100644 index e5b8761..0000000 --- a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Bottomly.Commands; -using Bottomly.Configuration; -using Microsoft.Extensions.Options; -using Shouldly; - -namespace Bottomly.Tests.Commands; - -public class GoogleSearchCommandTests -{ - [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() - { - var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); - var command = new GoogleSearchCommand(options); - - var result = await command.ExecuteAsync(""); - - result.ShouldBeNull(); - } -} \ No newline at end of file diff --git a/Bottomly.Tests/Commands/ImageSearchCommandTests.cs b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs new file mode 100644 index 0000000..20708b8 --- /dev/null +++ b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs @@ -0,0 +1,74 @@ +using System.Net; +using Bottomly.Commands.Search; +using Bottomly.Configuration; +using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands; + +public class ImageSearchCommandTests +{ + private static readonly IOptions Options = + 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), + NullLogger.Instance); + } + + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() + { + var command = new ImageSearchCommand(Options, new Mock().Object, + NullLogger.Instance); + + var result = await command.ExecuteAsync(""); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResult() + { + const string json = """ + { + "type": "images", + "results": [ + { "title": "A cat", "properties": { "url": "https://example.com/cat.jpg" } } + ] + } + """; + + var result = await CreateCommand(json).ExecuteAsync("cat"); + + var searchResult = result.ShouldBeOfType(); + searchResult.Title.ShouldBe("A cat"); + searchResult.Link.ShouldBe("https://example.com/cat.jpg"); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsEmptyResults_ReturnsNoResultsFoundResult() + { + const string json = """{ "type": "images", "results": [] }"""; + + var result = await CreateCommand(json).ExecuteAsync("nothing"); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsError_ReturnsSearchApiErrorResult() + { + const string errorJson = """{ "message": "Invalid subscription token" }"""; + + var result = await CreateCommand(errorJson, HttpStatusCode.Unauthorized).ExecuteAsync("something"); + + var errorResult = result.ShouldBeOfType(); + errorResult.Error.ShouldBe("Invalid subscription token"); + } +} \ No newline at end of file diff --git a/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs new file mode 100644 index 0000000..4c8417a --- /dev/null +++ b/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs @@ -0,0 +1,91 @@ +using Bottomly.Commands.Search; +using Bottomly.Configuration; +using Meziantou.Extensions.Logging.Xunit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit.Abstractions; + +namespace Bottomly.Tests.Commands.Integration; + +/// +/// Integration tests that call the real Brave Search API with image search. +/// Credentials are resolved from the standard .NET configuration stack: +/// 1. User secrets stored against the main Bottomly app project (local dev — +/// run `dotnet user-secrets set "bottomly_brave_api_key" "..." --project Bottomly`) +/// 2. Environment variable BOTTOMLY_BRAVE_API_KEY +/// (CI — injected from GitHub repository secrets via the workflow env block) +/// Tests no-op silently when credentials are absent, so the suite stays green +/// for contributors without keys. When credentials are present but expired or +/// invalid the tests will fail, which is exactly the failure mode they exist to expose. +/// +public class ImageSearchCommandIntegrationTests +{ + private static readonly IConfiguration Configuration = new ConfigurationBuilder() + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + private readonly ILogger _logger; + + public ImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) + { + _logger = XUnitLogger.CreateLogger(outputHelper); + } + + private static string? ApiKey => Configuration["bottomly_brave_api_key"]; + + private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey); + + private ImageSearchCommand CreateCommand() + { + var factory = new DefaultHttpClientFactory(); + return new ImageSearchCommand(Options.Create(new BottomlyOptions + { + BraveApiKey = ApiKey! + }), factory, _logger); + } + + private sealed class DefaultHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } + + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() + { + if (!CredentialsAvailable) return; + + var result = await CreateCommand().ExecuteAsync(""); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithLink() + { + if (!CredentialsAvailable) return; + + var result = await CreateCommand().ExecuteAsync("GitHub"); + + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; + searchResult.Title.ShouldNotBeNullOrEmpty(); + searchResult.Link.ShouldNotBeNullOrEmpty(); + searchResult.Link.ShouldStartWith("http"); + } + + [Fact] + public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult() + { + if (!CredentialsAvailable) return; + + var result = await CreateCommand().ExecuteAsync("Wikipedia logo"); + + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; + searchResult.Link.ShouldNotBeNullOrEmpty(); + searchResult.Link.ShouldStartWith("http"); + } +} diff --git a/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs new file mode 100644 index 0000000..4ae44ab --- /dev/null +++ b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs @@ -0,0 +1,90 @@ +using Bottomly.Commands.Search; +using Bottomly.Configuration; +using Meziantou.Extensions.Logging.Xunit; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit.Abstractions; + +namespace Bottomly.Tests.Commands.Integration; + +/// +/// Integration tests that call the real Brave Search API. +/// Credentials are resolved from the standard .NET configuration stack: +/// 1. User secrets stored against the main Bottomly app project (local dev — +/// run `dotnet user-secrets set "bottomly_brave_api_key" "..." --project Bottomly`) +/// 2. Environment variable BOTTOMLY_BRAVE_API_KEY +/// (CI — injected from GitHub repository secrets via the workflow env block) +/// Tests no-op silently when credentials are absent, so the suite stays green +/// for contributors without keys. When credentials are present but expired or +/// invalid the tests will fail, which is exactly the failure mode they exist to expose. +/// +public class SearchCommandIntegrationTests +{ + private static readonly IConfiguration Configuration = new ConfigurationBuilder() + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + private readonly ILogger _logger; + + public SearchCommandIntegrationTests(ITestOutputHelper outputHelper) + { + _logger = XUnitLogger.CreateLogger(outputHelper); + } + + private static string? ApiKey => Configuration["bottomly_brave_api_key"]; + + private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey); + + private SearchCommand CreateCommand() + { + var factory = new DefaultHttpClientFactory(); + return new SearchCommand(Options.Create(new BottomlyOptions + { + BraveApiKey = ApiKey! + }), _logger, factory); + } + + private sealed class DefaultHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); + } + + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() + { + if (!CredentialsAvailable) return; // credentials not configured — skip + + var result = await CreateCommand().ExecuteAsync(""); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithLink() + { + if (!CredentialsAvailable) return; // credentials not configured — skip + + var result = await CreateCommand().ExecuteAsync("GitHub"); + + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; + searchResult.Title.ShouldNotBeNullOrEmpty(); + searchResult.Link.ShouldNotBeNullOrEmpty(); + searchResult.Link.ShouldStartWith("http"); + } + + [Fact] + public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult() + { + if (!CredentialsAvailable) return; // credentials not configured — skip + + var result = await CreateCommand().ExecuteAsync("Wikipedia"); + + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; + searchResult.Link.ShouldContain("wikipedia"); + } +} \ No newline at end of file diff --git a/Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs new file mode 100644 index 0000000..46ac021 --- /dev/null +++ b/Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs @@ -0,0 +1,59 @@ +using Bottomly.Commands; +using Moq; +using Shouldly; + +namespace Bottomly.Tests.Commands.Integration; + +/// +/// Integration tests that call the real Wikipedia API. +/// These tests expose issues with the HTTP call format (e.g. missing headers, wrong URL structure) +/// that mocked unit tests cannot detect. +/// +public class WikipediaSearchCommandIntegrationTests +{ + private readonly WikipediaSearchCommand _sut; + + public WikipediaSearchCommandIntegrationTests() + { + var client = new HttpClient(); + var factory = new Mock(); + factory.Setup(f => f.CreateClient(It.IsAny())).Returns(client); + + _sut = new WikipediaSearchCommand(factory.Object); + } + + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsNull() + { + var result = await _sut.ExecuteAsync(""); + + result.ShouldBeNull(); + } + + [Fact] + public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithWikipediaLink() + { + var result = await _sut.ExecuteAsync("Albert Einstein"); + + result.ShouldNotBeNull(); + result!.Text.ShouldNotBeNullOrEmpty(); + result.Link.ShouldStartWith("https://en.wikipedia.org/wiki/"); + } + + [Fact] + public async Task ExecuteAsync_KnownSearchTerm_ReturnsExpectedTitle() + { + var result = await _sut.ExecuteAsync("London"); + + result.ShouldNotBeNull(); + result!.Text.ShouldBe("London"); + } + + [Fact] + public async Task ExecuteAsync_GibberishInput_ReturnsNull() + { + var result = await _sut.ExecuteAsync("xyzzy_no_such_article_12345"); + + result.ShouldBeNull(); + } +} \ No newline at end of file diff --git a/Bottomly.Tests/Commands/SearchCommandTests.cs b/Bottomly.Tests/Commands/SearchCommandTests.cs new file mode 100644 index 0000000..f28a1b4 --- /dev/null +++ b/Bottomly.Tests/Commands/SearchCommandTests.cs @@ -0,0 +1,103 @@ +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; + +public class SearchCommandTests +{ + private static readonly IOptions Options = + Microsoft.Extensions.Options.Options.Create(new BottomlyOptions + { + BraveApiKey = "fake-key" + }); + + private static SearchCommand CreateCommand(string responseJson, + HttpStatusCode statusCode = HttpStatusCode.OK) => + new(Options, NullLogger.Instance, + TestHelpers.CreateHttpClientFactory(responseJson, statusCode)); + + [Fact] + public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() + { + var command = new SearchCommand(Options, NullLogger.Instance, + TestHelpers.CreateHttpClientFactory(string.Empty)); + + var result = await command.ExecuteAsync(""); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_WhitespaceInput_ReturnsEmptySearchTermErrorResult() + { + var command = new SearchCommand(Options, NullLogger.Instance, + TestHelpers.CreateHttpClientFactory(string.Empty)); + + var result = await command.ExecuteAsync(" "); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResultWithTitleAndLink() + { + const string json = """ + { + "type": "search", + "web": { + "results": [{ "title": "DotNet", "url": "https://dotnet.microsoft.com" }] + } + } + """; + + var result = await CreateCommand(json).ExecuteAsync("dotnet"); + + var searchResult = result.ShouldBeOfType(); + searchResult.Title.ShouldBe("DotNet"); + searchResult.Link.ShouldBe("https://dotnet.microsoft.com"); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsEmptyResults_ReturnsNoResultsFoundResult() + { + const string json = """{ "type": "search", "web": { "results": [] } }"""; + + var result = await CreateCommand(json).ExecuteAsync("anything"); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsNoWebProperty_ReturnsNoResultsFoundResult() + { + const string json = """{ "type": "search" }"""; + + var result = await CreateCommand(json).ExecuteAsync("anything"); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsError_ReturnsSearchApiErrorResult() + { + const string errorJson = """{ "message": "Invalid subscription token" }"""; + + var result = await CreateCommand(errorJson, HttpStatusCode.Unauthorized).ExecuteAsync("anything"); + + var errorResult = result.ShouldBeOfType(); + errorResult.Error.ShouldBe("Invalid subscription token"); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsServerError_ReturnsSearchApiErrorResult() + { + var result = await CreateCommand("{}", HttpStatusCode.InternalServerError).ExecuteAsync("anything"); + + result.ShouldBeOfType(); + } +} \ No newline at end of file diff --git a/Bottomly.Tests/Helpers/TestHelpers.cs b/Bottomly.Tests/Helpers/TestHelpers.cs index 3579b90..d562e7a 100644 --- a/Bottomly.Tests/Helpers/TestHelpers.cs +++ b/Bottomly.Tests/Helpers/TestHelpers.cs @@ -2,6 +2,7 @@ using Bottomly.Configuration; using Microsoft.Extensions.Options; using Moq; +using MsHttpClientFactory = System.Net.Http.IHttpClientFactory; namespace Bottomly.Tests.Helpers; @@ -12,15 +13,20 @@ internal static class TestHelpers public static IOptions CreateOptions(string prefix = TestPrefix) => Options.Create(new BottomlyOptions { Prefix = prefix }); - public static IHttpClientFactory CreateHttpClientFactory(string responseContent, + /// + /// 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) { var handler = new FakeHttpMessageHandler(responseContent, statusCode); var client = new HttpClient(handler); - var factory = new Mock(); + var factory = new Mock(); 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/Slack/EventHandlers/GoogleImageHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs similarity index 59% rename from Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs rename to Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs index 0727841..7383431 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands; +using Bottomly.Commands.Search; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; @@ -9,34 +9,42 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GoogleImageHandlerTests +public class ImageSearchHandlerTests { - private readonly GoogleImageHandler _handler; + private readonly ImageSearchHandler _handler; private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; + private readonly Mock _mockCommand; - public GoogleImageHandlerTests() + public ImageSearchHandlerTests() { var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options); - _handler = new GoogleImageHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _mockCommand = new Mock(options, new Mock().Object, + NullLogger.Instance); + _handler = new ImageSearchHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } - private static MessageEvent CreateMessage(string text) => - new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; + private static MessageEvent CreateMessage(string text) + { + return new MessageEvent { 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() { - _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync((GoogleSearchResult?)null); + _mockCommand.Setup(c => c.ExecuteAsync("cats")).ReturnsAsync(new NoResultsFoundResult()); await _handler.HandleAsync(CreateMessage("_gi cats")); @@ -47,7 +55,7 @@ public async Task HandleAsync_ValidEvent_CallsCommandWithQuery() public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() { _mockCommand.Setup(c => c.ExecuteAsync("cats")) - .ReturnsAsync(new GoogleSearchResult("Cute Cat", "https://example.com/cat.jpg")); + .ReturnsAsync(new SearchResult("Cute Cat", "https://example.com/cat.jpg")); await _handler.HandleAsync(CreateMessage("_gi cats")); @@ -55,9 +63,9 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() } [Fact] - public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage() + public async Task HandleAsync_ValidEvent_NoResultsFound_SendsNoResultMessage() { - _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((GoogleSearchResult?)null); + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync(new NoResultsFoundResult()); await _handler.HandleAsync(CreateMessage("_gi xyz")); @@ -69,7 +77,7 @@ public async Task HandleAsync_HelpEvent_SendsHelpMessage() { await _handler.HandleAsync(CreateMessage("_gi -?")); - _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Google Image")), "C1", null), + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Image Search")), "C1", null), Times.Once()); } } \ No newline at end of file diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs similarity index 61% rename from Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs rename to Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs index c6b6741..a73f15a 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands; +using Bottomly.Commands.Search; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; @@ -9,35 +9,42 @@ namespace Bottomly.Tests.Slack.EventHandlers; -public class GoogleHandlerTests +public class SearchHandlerTests { - private readonly GoogleHandler _handler; + private readonly SearchHandler _handler; private readonly Mock _mockBroker = new(); - private readonly Mock _mockCommand; + private readonly Mock _mockCommand; - public GoogleHandlerTests() + public SearchHandlerTests() { var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options); - _handler = new GoogleHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _mockCommand = new Mock(options, NullLogger.Instance, + new Mock().Object); + _handler = new SearchHandler(_mockCommand.Object, _mockBroker.Object, options, + NullLogger.Instance); } - private static MessageEvent CreateMessage(string text) => - new() { Text = text, User = "U1", Channel = "C1", Ts = "ts1" }; + private static MessageEvent CreateMessage(string text) + { + return new MessageEvent { 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() { - _mockCommand.Setup(c => c.ExecuteAsync("some query")).ReturnsAsync((GoogleSearchResult?)null); + _mockCommand.Setup(c => c.ExecuteAsync("some query")).ReturnsAsync(new NoResultsFoundResult()); await _handler.HandleAsync(CreateMessage("_g some query")); @@ -48,7 +55,7 @@ public async Task HandleAsync_ValidEvent_CallsCommandWithQuery() public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() { _mockCommand.Setup(c => c.ExecuteAsync("dotnet")) - .ReturnsAsync(new GoogleSearchResult("DotNet", "https://dotnet.microsoft.com")); + .ReturnsAsync(new SearchResult("DotNet", "https://dotnet.microsoft.com")); await _handler.HandleAsync(CreateMessage("_g dotnet")); @@ -56,9 +63,9 @@ public async Task HandleAsync_ValidEvent_WithResult_SendsFormattedResponse() } [Fact] - public async Task HandleAsync_ValidEvent_NullResult_SendsNoResultMessage() + public async Task HandleAsync_ValidEvent_EmptySearchTermResult_SendsNoResultMessage() { - _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync((GoogleSearchResult?)null); + _mockCommand.Setup(c => c.ExecuteAsync("xyz")).ReturnsAsync(new EmptySearchTermErrorResult()); await _handler.HandleAsync(CreateMessage("_g xyz")); @@ -70,6 +77,7 @@ public async Task HandleAsync_HelpEvent_SendsHelpMessage() { await _handler.HandleAsync(CreateMessage("_g -?")); - _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Google")), "C1", null), Times.Once()); + _mockBroker.Verify(b => b.SendMessageAsync(It.Is(s => s.Contains("Web Search")), "C1", null), + Times.Once()); } } \ No newline at end of file diff --git a/Bottomly/Bottomly.csproj b/Bottomly/Bottomly.csproj index 8be6f38..a56be01 100644 --- a/Bottomly/Bottomly.csproj +++ b/Bottomly/Bottomly.csproj @@ -11,7 +11,6 @@ - diff --git a/Bottomly/Commands/GoogleImageSearchCommand.cs b/Bottomly/Commands/GoogleImageSearchCommand.cs deleted file mode 100644 index 51fa44a..0000000 --- a/Bottomly/Commands/GoogleImageSearchCommand.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Bottomly.Configuration; -using Google.Apis.CustomSearchAPI.v1; -using Google.Apis.Services; -using Microsoft.Extensions.Options; - -namespace Bottomly.Commands; - -public class GoogleImageSearchCommand : ICommand -{ - private readonly string _cseId; - private readonly CustomSearchAPIService _service; - - public GoogleImageSearchCommand(IOptions options) - { - _cseId = options.Value.GoogleCseId; - _service = new CustomSearchAPIService( - new BaseClientService.Initializer { ApiKey = options.Value.GoogleApiKey }); - } - - public string GetPurpose() => "Performs a google image search and returns the top hit."; - - public virtual async Task ExecuteAsync(string searchTerm) - { - if (string.IsNullOrWhiteSpace(searchTerm)) - { - return null; - } - - var request = _service.Cse.List(); - request.Q = searchTerm; - request.Cx = _cseId; - request.Num = 1; - request.SearchType = CseResource.ListRequest.SearchTypeEnum.Image; - - var result = await request.ExecuteAsync(); - if (result.SearchInformation?.TotalResults == "0" || result.Items is null || !result.Items.Any()) - { - return null; - } - - var top = result.Items[0]; - return new GoogleSearchResult(top.Title, top.Link); - } -} \ No newline at end of file diff --git a/Bottomly/Commands/GoogleSearchCommand.cs b/Bottomly/Commands/GoogleSearchCommand.cs deleted file mode 100644 index 56e440e..0000000 --- a/Bottomly/Commands/GoogleSearchCommand.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Bottomly.Configuration; -using Google.Apis.CustomSearchAPI.v1; -using Google.Apis.Services; -using Microsoft.Extensions.Options; - -namespace Bottomly.Commands; - -public record GoogleSearchResult(string Title, string Link); - -public class GoogleSearchCommand : ICommand -{ - private readonly string _cseId; - private readonly CustomSearchAPIService _service; - - public GoogleSearchCommand(IOptions options) - { - _cseId = options.Value.GoogleCseId; - _service = new CustomSearchAPIService( - new BaseClientService.Initializer { ApiKey = options.Value.GoogleApiKey }); - } - - public string GetPurpose() => "Performs a google search and returns the top hit."; - - public virtual async Task ExecuteAsync(string searchTerm) - { - if (string.IsNullOrWhiteSpace(searchTerm)) - { - return null; - } - - var request = _service.Cse.List(); - request.Q = searchTerm; - request.Cx = _cseId; - request.Num = 1; - - var result = await request.ExecuteAsync(); - if (result.SearchInformation?.TotalResults == "0" || result.Items is null || !result.Items.Any()) - { - return null; - } - - var top = result.Items[0]; - return new GoogleSearchResult(top.Title, top.Link); - } -} \ No newline at end of file diff --git a/Bottomly/Commands/Search/ImageSearchCommand.cs b/Bottomly/Commands/Search/ImageSearchCommand.cs new file mode 100644 index 0000000..ec7356e --- /dev/null +++ b/Bottomly/Commands/Search/ImageSearchCommand.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands.Search; + +public class ImageSearchCommand( + IOptions options, + IHttpClientFactory httpClientFactory, + ILogger logger) : SearchCommandBase(options, httpClientFactory, logger) +{ + private const string BaseUrl = "https://api.search.brave.com/res/v1/images/search"; + + public override string GetPurpose() => "Performs an image search and returns the top hit."; + + protected override string BuildUrl(string searchTerm) => + $"{BaseUrl}?q={Uri.EscapeDataString(searchTerm)}&count=1"; + + protected override SearchCommandResult ExtractFirstResult(JsonElement root) + { + if (!root.TryGetProperty("results", out var results)) return new NoResultsFoundResult(); + if (results.GetArrayLength() == 0) return new NoResultsFoundResult(); + + var first = results[0]; + var title = first.TryGetProperty("title", out var t) ? t.GetString() ?? string.Empty : string.Empty; + if (!first.TryGetProperty("properties", out var props)) return new NoResultsFoundResult(); + var url = props.TryGetProperty("url", out var u) ? u.GetString() ?? string.Empty : string.Empty; + return new SearchResult(title, url); + } +} diff --git a/Bottomly/Commands/Search/SearchCommand.cs b/Bottomly/Commands/Search/SearchCommand.cs new file mode 100644 index 0000000..5294767 --- /dev/null +++ b/Bottomly/Commands/Search/SearchCommand.cs @@ -0,0 +1,31 @@ +using System.Text.Json; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands.Search; + +public class SearchCommand( + IOptions options, + ILogger logger, + IHttpClientFactory httpClientFactory) : SearchCommandBase(options, httpClientFactory, logger) +{ + private const string BaseUrl = "https://api.search.brave.com/res/v1/web/search"; + + public override string GetPurpose() => "Performs a web search and returns the top hit."; + + protected override string BuildUrl(string searchTerm) => + $"{BaseUrl}?q={Uri.EscapeDataString(searchTerm)}&count=1"; + + protected override SearchCommandResult ExtractFirstResult(JsonElement root) + { + if (!root.TryGetProperty("web", out var web)) return new NoResultsFoundResult(); + if (!web.TryGetProperty("results", out var results)) return new NoResultsFoundResult(); + if (results.GetArrayLength() == 0) return new NoResultsFoundResult(); + + var first = results[0]; + var title = first.TryGetProperty("title", out var t) ? t.GetString() ?? string.Empty : string.Empty; + var url = first.TryGetProperty("url", out var u) ? u.GetString() ?? string.Empty : string.Empty; + return new SearchResult(title, url); + } +} diff --git a/Bottomly/Commands/Search/SearchCommandBase.cs b/Bottomly/Commands/Search/SearchCommandBase.cs new file mode 100644 index 0000000..9daa3ec --- /dev/null +++ b/Bottomly/Commands/Search/SearchCommandBase.cs @@ -0,0 +1,67 @@ +using System.Text.Json; +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands.Search; + +public abstract class SearchCommandBase( + IOptions options, + IHttpClientFactory httpClientFactory, + ILogger logger) + : ICommand +{ + private readonly string _apiKey = options.Value.BraveApiKey; + + public abstract string GetPurpose(); + + protected abstract string BuildUrl(string searchTerm); + protected abstract SearchCommandResult ExtractFirstResult(JsonElement root); + + public virtual async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) return new EmptySearchTermErrorResult(); + + try + { + var client = httpClientFactory.CreateClient(); + var request = new HttpRequestMessage(HttpMethod.Get, BuildUrl(searchTerm)); + request.Headers.Add("X-Subscription-Token", _apiKey); + request.Headers.Add("Accept", "application/json"); + + var response = await client.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + var errorMessage = await ExtractErrorMessageAsync(response); + logger?.LogError("Brave search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); + return new SearchApiErrorResult(errorMessage); + } + + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + return ExtractFirstResult(doc.RootElement); + } + catch (Exception e) + { + logger?.LogError(e, "Error executing search"); + return new SearchApiErrorResult(e.Message); + } + } + + private static async Task ExtractErrorMessageAsync(HttpResponseMessage response) + { + try + { + var body = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("message", out var msg)) + return msg.GetString()!; + } + catch (JsonException) + { + // swallow. + } + + return response.ReasonPhrase ?? "Unknown error"; + } +} diff --git a/Bottomly/Commands/Search/SearchCommandResult.cs b/Bottomly/Commands/Search/SearchCommandResult.cs new file mode 100644 index 0000000..0416291 --- /dev/null +++ b/Bottomly/Commands/Search/SearchCommandResult.cs @@ -0,0 +1,11 @@ +namespace Bottomly.Commands.Search; + +public abstract record SearchCommandResult; + +public record SearchResult(string Title, string Link) : SearchCommandResult; + +public record SearchApiErrorResult(string Error) : SearchCommandResult; + +public record NoResultsFoundResult : SearchCommandResult; + +public record EmptySearchTermErrorResult : SearchCommandResult; diff --git a/Bottomly/Commands/WikipediaSearchCommand.cs b/Bottomly/Commands/WikipediaSearchCommand.cs index eee8d29..827b7a6 100644 --- a/Bottomly/Commands/WikipediaSearchCommand.cs +++ b/Bottomly/Commands/WikipediaSearchCommand.cs @@ -8,18 +8,19 @@ public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICom { private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - public string GetPurpose() => "Performs a wikipedia search and returns the top hit."; + public string GetPurpose() + { + return "Performs a wikipedia search and returns the top hit."; + } public virtual async Task ExecuteAsync(string searchTerm) { - if (string.IsNullOrWhiteSpace(searchTerm)) - { - return null; - } + if (string.IsNullOrWhiteSpace(searchTerm)) 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(); @@ -30,10 +31,7 @@ public class WikipediaSearchCommand(IHttpClientFactory httpClientFactory) : ICom var titles = root[1]; var links = root[3]; - if (titles.GetArrayLength() == 0) - { - return null; - } + if (titles.GetArrayLength() == 0) return null; return new WikipediaResult(titles[0].GetString()!, links[0].GetString()!); } diff --git a/Bottomly/Configuration/BottomlyOptions.cs b/Bottomly/Configuration/BottomlyOptions.cs index 23949a0..4067894 100644 --- a/Bottomly/Configuration/BottomlyOptions.cs +++ b/Bottomly/Configuration/BottomlyOptions.cs @@ -13,4 +13,5 @@ public class BottomlyOptions public bool EnableLlm { get; set; } = false; public bool IsDebug => Environment != "live"; + public string BraveApiKey { get; set; } = string.Empty; } \ No newline at end of file diff --git a/Bottomly/Program.cs b/Bottomly/Program.cs index 31c44d6..b4c2f9c 100644 --- a/Bottomly/Program.cs +++ b/Bottomly/Program.cs @@ -1,9 +1,9 @@ -using Bottomly.Seed; using System.Reflection; using Bottomly.Commands; using Bottomly.Configuration; using Bottomly.LlmBot; using Bottomly.Repositories; +using Bottomly.Seed; using Bottomly.Slack; using Bottomly.Slack.MembershipEventHandlers; using Bottomly.Slack.MessageEventHandlers; @@ -45,6 +45,7 @@ opts.GiphyApiKey = builder.Configuration["bottomly_giphy_api_key"] ?? string.Empty; opts.Environment = builder.Configuration["bottomly_env"] ?? "live"; opts.GitHubToken = builder.Configuration["bottomly_github_token"] ?? string.Empty; + opts.BraveApiKey = builder.Configuration["bottomly_brave_api_key"] ?? string.Empty; opts.EnableLlm = builder.Configuration.GetValue("EnableLlm"); }); @@ -144,7 +145,8 @@ public static class HostBuilderExtensions { extension(HostApplicationBuilder builder) { - public void RegisterEventHandlers(Assembly assembly, Type[] exclude) => + public void RegisterEventHandlers(Assembly assembly, Type[] exclude) + { assembly.GetTypes() .Where(t => typeof(IMessageEventHandler).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) @@ -152,11 +154,14 @@ public void RegisterEventHandlers(Assembly assembly, Type[] exclude) => .Where(t => !exclude.Contains(t)) .ToList() .ForEach(t => builder.Services.AddSingleton(typeof(IMessageEventHandler), t)); + } - public void RegisterCommands(Assembly assembly) => + public void RegisterCommands(Assembly assembly) + { assembly.GetTypes() .Where(t => typeof(ICommand).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false }) .ToList() .ForEach(t => builder.Services.AddSingleton(t)); + } } } \ No newline at end of file diff --git a/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs b/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs similarity index 59% rename from Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs rename to Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs index e7610dc..c3ef1df 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs @@ -1,4 +1,5 @@ using Bottomly.Commands; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -6,25 +7,31 @@ namespace Bottomly.Slack.MessageEventHandlers; -public class GoogleImageHandler( - GoogleImageSearchCommand command, +public class ImageSearchHandler( + ImageSearchCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) + ILogger logger) : AbstractMessageEventHandler(broker, options, logger) { - public override string Name => "Google Image"; + public override string Name => "Image Search"; protected override ICommand Command => command; protected override string CommandSymbol => "gi"; - protected override string GetUsage() => CommandTrigger + ""; + + protected override string GetUsage() + { + return CommandTrigger + ""; + } protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var query = message.Text![CommandTrigger.Length..]; var result = await command.ExecuteAsync(query); - var response = result is null - ? $"No image results found for \"{query}\"" - : $"{result.Title} {result.Link}"; + var response = result switch + { + SearchResult success => $"{success.Title} {success.Link}", + _ => $"No image results found for \"{query}\"" + }; await SendMessageResponseAsync(response, message); } } \ No newline at end of file diff --git a/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs similarity index 56% rename from Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs rename to Bottomly/Slack/MessageEventHandlers/SearchHandler.cs index a9224ea..c0ae2a5 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs @@ -1,4 +1,5 @@ using Bottomly.Commands; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -6,25 +7,34 @@ namespace Bottomly.Slack.MessageEventHandlers; -public class GoogleHandler( - GoogleSearchCommand command, +public class SearchHandler( + SearchCommand command, ISlackMessageBroker broker, IOptions options, - ILogger logger) + ILogger logger) : AbstractMessageEventHandler(broker, options, logger) { - public override string Name => "Google"; + public override string Name => "Web Search"; protected override ICommand Command => command; protected override string CommandSymbol => "g"; - protected override string GetUsage() => CommandTrigger + ""; + + protected override string GetUsage() + { + return CommandTrigger + ""; + } protected override async Task InvokeHandlerLogicAsync(MessageEvent message) { var query = message.Text![CommandTrigger.Length..]; var result = await command.ExecuteAsync(query); - var response = result is null - ? $"No results found for \"{query}\"" - : $"{result.Title} {result.Link}"; + + var response = result switch + { + SearchResult success => $"{success.Title} {success.Link}", + EmptySearchTermErrorResult => $"No results found for \"{query}\"", + _ => "Left as an exercise for the reader." + }; + await SendMessageResponseAsync(response, message); } } \ No newline at end of file diff --git a/Bottomly/test.http b/Bottomly/test.http new file mode 100644 index 0000000..b83f8f3 --- /dev/null +++ b/Bottomly/test.http @@ -0,0 +1,13 @@ +GET https://customsearch.googleapis.com/customsearch/v1 + ?key=AIzaSyAG5Cqko4Dpk2wA1ROEZq4_aTCcmlzzw0c + &cx=001134813595381089514:0-9_th43q-0 + &q=einstein HTTP/1.1 +Accept: application/json + +### + +GET https://www.google.com/search?q=einstein + +### + +GET https://duckduckgo.com/html/?q=einstein \ No newline at end of file