From 62d6a627bc22e868ad03af7d2447f4d1c2932a48 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 11:03:49 +0000 Subject: [PATCH 01/12] SAVEPOINT --- .github/workflows/dotnet.yml | 3 + Bottomly.Tests/Bottomly.Tests.csproj | 1 + .../Commands/GoogleSearchCommandTests.cs | 96 ++++++++++++++++++- .../GoogleSearchCommandIntegrationTests.cs | 89 +++++++++++++++++ .../WikipediaSearchCommandIntegrationTests.cs | 59 ++++++++++++ Bottomly.Tests/Helpers/TestHelpers.cs | 21 +++- .../Slack/EventHandlers/GoogleHandlerTests.cs | 8 +- Bottomly/Commands/GoogleSearchCommand.cs | 64 +++++++++---- Bottomly/Commands/WikipediaSearchCommand.cs | 16 ++-- .../MessageEventHandlers/GoogleHandler.cs | 17 +++- 10 files changed, 334 insertions(+), 40 deletions(-) create mode 100644 Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs create mode 100644 Bottomly.Tests/Commands/Integration/WikipediaSearchCommandIntegrationTests.cs diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 16dd15b..11ed56b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -29,6 +29,9 @@ jobs: - name: Test run: dotnet test bottomly.net.slnx --no-build --configuration Release --verbosity normal + env: + BOTTOMLY_GOOGLE_API_KEY: ${{ secrets.BOTTOMLY_GOOGLE_API_KEY }} + BOTTOMLY_GOOGLE_CSE_ID: ${{ secrets.BOTTOMLY_GOOGLE_CSE_ID }} - name: Build Docker image run: docker build -t bottomly . 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/GoogleSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs index e5b8761..cfe5fb1 100644 --- a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs @@ -1,20 +1,108 @@ using Bottomly.Commands; 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 GoogleSearchCommandTests { + private static readonly IOptions Options = + Microsoft.Extensions.Options.Options.Create(new BottomlyOptions + { + GoogleApiKey = "fake-key", + GoogleCseId = "fake-cse" + }); + + private static GoogleSearchCommand CreateCommand(string responseJson, + HttpStatusCode statusCode = HttpStatusCode.OK) => + new(Options, NullLogger.Instance, + TestHelpers.CreateGoogleHttpClientFactory(responseJson, statusCode)); + [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() + public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { - var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); - var command = new GoogleSearchCommand(options); + var command = new GoogleSearchCommand(Options, NullLogger.Instance); var result = await command.ExecuteAsync(""); - result.ShouldBeNull(); + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_WhitespaceInput_ReturnsEmptySearchTermErrorResult() + { + var command = new GoogleSearchCommand(Options, NullLogger.Instance); + + var result = await command.ExecuteAsync(" "); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResultWithTitleAndLink() + { + const string json = """ + { + "searchInformation": { "totalResults": "1" }, + "items": [{ "title": "DotNet", "link": "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_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundResult() + { + const string json = """{ "searchInformation": { "totalResults": "0" } }"""; + + var result = await CreateCommand(json).ExecuteAsync("anything"); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsNullItems_ReturnsNoResultsFoundResult() + { + const string json = """{ "searchInformation": { "totalResults": "1" } }"""; + + var result = await CreateCommand(json).ExecuteAsync("anything"); + + result.ShouldBeOfType(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() + { + const string errorJson = """ + { + "error": { + "code": 403, + "message": "API key expired", + "errors": [{ "domain": "global", "reason": "forbidden", "message": "API key expired" }] + } + } + """; + + var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("anything"); + + var errorResult = result.ShouldBeOfType(); + errorResult.Error.ShouldNotBeNullOrEmpty(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsServerError_ReturnsGoogleApiErrorResult() + { + var result = await CreateCommand("{}", HttpStatusCode.InternalServerError).ExecuteAsync("anything"); + + result.ShouldBeOfType(); } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs new file mode 100644 index 0000000..ac9abe6 --- /dev/null +++ b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs @@ -0,0 +1,89 @@ +using Bottomly.Commands; +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 Google Custom 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_google_api_key" "..." --project Bottomly`) +/// 2. Environment variables BOTTOMLY_GOOGLE_API_KEY / BOTTOMLY_GOOGLE_CSE_ID +/// (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 GoogleSearchCommandIntegrationTests +{ + private static readonly IConfiguration Configuration = new ConfigurationBuilder() + // User secrets stored against the main Bottomly app assembly's UserSecretsId + .AddUserSecrets() + // Environment variables override user secrets (used in CI) + .AddEnvironmentVariables() + .Build(); + + private readonly ILogger _logger; + + public GoogleSearchCommandIntegrationTests(ITestOutputHelper outputHelper) + { + _logger = XUnitLogger.CreateLogger(outputHelper); + } + + private static string? ApiKey => Configuration["bottomly_google_api_key"]; + private static string? CseId => Configuration["bottomly_google_cse_id"]; + + private static bool CredentialsAvailable => + !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(CseId); + + private GoogleSearchCommand CreateCommand() + { + return new GoogleSearchCommand(Options.Create(new BottomlyOptions + { + GoogleApiKey = ApiKey!, + GoogleCseId = CseId! + }), _logger); + } + + [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 = (GoogleSearchResult)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 = (GoogleSearchResult)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/Helpers/TestHelpers.cs b/Bottomly.Tests/Helpers/TestHelpers.cs index 3579b90..3ab35b5 100644 --- a/Bottomly.Tests/Helpers/TestHelpers.cs +++ b/Bottomly.Tests/Helpers/TestHelpers.cs @@ -1,7 +1,9 @@ using System.Net; using Bottomly.Configuration; +using Google.Apis.Http; using Microsoft.Extensions.Options; using Moq; +using MsHttpClientFactory = System.Net.Http.IHttpClientFactory; namespace Bottomly.Tests.Helpers; @@ -12,15 +14,24 @@ internal static class TestHelpers public static IOptions CreateOptions(string prefix = TestPrefix) => Options.Create(new BottomlyOptions { Prefix = prefix }); - public static IHttpClientFactory CreateHttpClientFactory(string responseContent, + 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; } + + /// + /// Creates a that returns a fake HTTP response, + /// allowing unit tests to exercise without + /// hitting the real Google API. + /// + public static Google.Apis.Http.IHttpClientFactory CreateGoogleHttpClientFactory( + string responseContent, HttpStatusCode statusCode = HttpStatusCode.OK) => + new FakeGoogleHttpClientFactory(new FakeHttpMessageHandler(responseContent, statusCode)); } internal class FakeHttpMessageHandler(string content, HttpStatusCode statusCode = HttpStatusCode.OK) @@ -33,4 +44,10 @@ protected override Task SendAsync(HttpRequestMessage reques StatusCode = statusCode, Content = new StringContent(content) }); +} + +internal class FakeGoogleHttpClientFactory(HttpMessageHandler handler) : Google.Apis.Http.IHttpClientFactory +{ + public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => + new(new ConfigurableMessageHandler(handler)); } \ No newline at end of file diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs index c6b6741..54309b1 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs @@ -18,7 +18,7 @@ public class GoogleHandlerTests public GoogleHandlerTests() { var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options); + _mockCommand = new Mock(options, NullLogger.Instance); _handler = new GoogleHandler(_mockCommand.Object, _mockBroker.Object, options, NullLogger.Instance); } @@ -37,7 +37,7 @@ public void CanHandle_InvalidEvent_ReturnsFalse() => [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")); @@ -56,9 +56,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")); diff --git a/Bottomly/Commands/GoogleSearchCommand.cs b/Bottomly/Commands/GoogleSearchCommand.cs index 56e440e..f3b1c92 100644 --- a/Bottomly/Commands/GoogleSearchCommand.cs +++ b/Bottomly/Commands/GoogleSearchCommand.cs @@ -1,45 +1,75 @@ using Bottomly.Configuration; using Google.Apis.CustomSearchAPI.v1; using Google.Apis.Services; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Bottomly.Commands; -public record GoogleSearchResult(string Title, string Link); +public abstract record GoogleCommandResult; + +public record GoogleSearchResult(string Title, string Link) : GoogleCommandResult; + +public record GoogleApiErrorResult(string Error) : GoogleCommandResult; + +public record NoResultsFoundResult : GoogleCommandResult; + +public record EmptySearchTermErrorResult : GoogleCommandResult; public class GoogleSearchCommand : ICommand { private readonly string _cseId; + private readonly ILogger _logger; private readonly CustomSearchAPIService _service; - public GoogleSearchCommand(IOptions options) + public GoogleSearchCommand(IOptions options, ILogger logger) { _cseId = options.Value.GoogleCseId; _service = new CustomSearchAPIService( new BaseClientService.Initializer { ApiKey = options.Value.GoogleApiKey }); + _logger = logger; } - public string GetPurpose() => "Performs a google search and returns the top hit."; + internal GoogleSearchCommand(IOptions options, ILogger logger, + Google.Apis.Http.IHttpClientFactory httpClientFactory) + { + _cseId = options.Value.GoogleCseId; + _service = new CustomSearchAPIService( + new BaseClientService.Initializer + { + ApiKey = options.Value.GoogleApiKey, + HttpClientFactory = httpClientFactory + }); + _logger = logger; + } - public virtual async Task ExecuteAsync(string searchTerm) + public string GetPurpose() { - if (string.IsNullOrWhiteSpace(searchTerm)) + return "Performs a google search and returns the top hit."; + } + + public virtual async Task ExecuteAsync(string searchTerm) + { + if (string.IsNullOrWhiteSpace(searchTerm)) return new EmptySearchTermErrorResult(); + + try { - return null; - } + var request = _service.Cse.List(); + request.Q = searchTerm; + request.Cx = _cseId; + request.Num = 1; - 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 new NoResultsFoundResult(); - var result = await request.ExecuteAsync(); - if (result.SearchInformation?.TotalResults == "0" || result.Items is null || !result.Items.Any()) + var top = result.Items[0]; + return new GoogleSearchResult(top.Title, top.Link); + } + catch (Exception e) { - return null; + _logger.LogError(e, "Error executing Google search"); + return new GoogleApiErrorResult(e.Message); } - - var top = result.Items[0]; - return new GoogleSearchResult(top.Title, top.Link); } } \ No newline at end of file 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/Slack/MessageEventHandlers/GoogleHandler.cs b/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs index a9224ea..2aff6fd 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs @@ -16,15 +16,24 @@ public class GoogleHandler( public override string Name => "Google"; 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 + { + GoogleSearchResult 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 From 726119d5934f10991eeef408efa2b3e798bb7e7a Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 11:24:04 +0000 Subject: [PATCH 02/12] Replace Google.Apis.CustomSearchAPI.v1 with raw HttpClient calls The project has 'Custom Search API' enabled in GCP but not 'Custom Search JSON API', which is what the NuGet library (Google.Apis.CustomSearchAPI.v1) requires, causing HTTP 403 errors at runtime. Replace the NuGet library with direct HttpClient calls to the Custom Search API REST endpoint, following the same IHttpClientFactory + System.Text.Json pattern used by other commands (Giphy, Wikipedia, etc.). Changes: - Remove Google.Apis.CustomSearchAPI.v1 package reference - Rewrite GoogleSearchCommand: inject IHttpClientFactory, build URL manually, parse JSON response with System.Text.Json, extract error.message on failure - Rewrite GoogleImageSearchCommand: same approach, add &searchType=image, add IHttpClientFactory constructor param and try-catch error handling - Remove FakeGoogleHttpClientFactory from TestHelpers (no longer needed) - Update GoogleSearchCommandTests to use TestHelpers.CreateHttpClientFactory - Expand GoogleImageSearchCommandTests with result/no-result/error cases - Update handler tests to supply IHttpClientFactory mock arg - Update integration test factory method to use DefaultHttpClientFactory All 210 unit tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/GoogleImageSearchCommandTests.cs | 49 +++++++++- .../Commands/GoogleSearchCommandTests.cs | 10 ++- .../GoogleSearchCommandIntegrationTests.cs | 8 +- Bottomly.Tests/Helpers/TestHelpers.cs | 19 +--- .../Slack/EventHandlers/GoogleHandlerTests.cs | 3 +- .../EventHandlers/GoogleImageHandlerTests.cs | 2 +- Bottomly/Bottomly.csproj | 1 - Bottomly/Commands/GoogleImageSearchCommand.cs | 56 ++++++------ Bottomly/Commands/GoogleSearchCommand.cs | 89 ++++++++++--------- 9 files changed, 145 insertions(+), 92 deletions(-) diff --git a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs index 6b23fe4..b6757cb 100644 --- a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs @@ -1,20 +1,65 @@ using Bottomly.Commands; using Bottomly.Configuration; +using Bottomly.Tests.Helpers; using Microsoft.Extensions.Options; +using Moq; using Shouldly; +using System.Net; namespace Bottomly.Tests.Commands; public class GoogleImageSearchCommandTests { + private static readonly IOptions Options = + Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); + [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsNull() { - var options = Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); - var command = new GoogleImageSearchCommand(options); + var command = new GoogleImageSearchCommand(Options, new Mock().Object); var result = await command.ExecuteAsync(""); result.ShouldBeNull(); } + + [Fact] + public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResult() + { + const string json = """ + { + "searchInformation": { "totalResults": "1" }, + "items": [{ "title": "A cat", "link": "https://example.com/cat.jpg" }] + } + """; + var command = new GoogleImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(json)); + + var result = await command.ExecuteAsync("cat"); + + result.ShouldNotBeNull(); + result!.Title.ShouldBe("A cat"); + result.Link.ShouldBe("https://example.com/cat.jpg"); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNull() + { + const string json = """{ "searchInformation": { "totalResults": "0" } }"""; + var command = new GoogleImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(json)); + + var result = await command.ExecuteAsync("nothing"); + + result.ShouldBeNull(); + } + + [Fact] + public async Task ExecuteAsync_ApiReturnsError_ReturnsNull() + { + var command = new GoogleImageSearchCommand(Options, + TestHelpers.CreateHttpClientFactory("{}", HttpStatusCode.Forbidden)); + + var result = await command.ExecuteAsync("something"); + + result.ShouldBeNull(); + } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs index cfe5fb1..5690052 100644 --- a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs @@ -20,12 +20,13 @@ public class GoogleSearchCommandTests private static GoogleSearchCommand CreateCommand(string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK) => new(Options, NullLogger.Instance, - TestHelpers.CreateGoogleHttpClientFactory(responseJson, statusCode)); + TestHelpers.CreateHttpClientFactory(responseJson, statusCode)); [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { - var command = new GoogleSearchCommand(Options, NullLogger.Instance); + var command = new GoogleSearchCommand(Options, NullLogger.Instance, + TestHelpers.CreateHttpClientFactory(string.Empty)); var result = await command.ExecuteAsync(""); @@ -35,7 +36,8 @@ public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() [Fact] public async Task ExecuteAsync_WhitespaceInput_ReturnsEmptySearchTermErrorResult() { - var command = new GoogleSearchCommand(Options, NullLogger.Instance); + var command = new GoogleSearchCommand(Options, NullLogger.Instance, + TestHelpers.CreateHttpClientFactory(string.Empty)); var result = await command.ExecuteAsync(" "); @@ -95,7 +97,7 @@ public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("anything"); var errorResult = result.ShouldBeOfType(); - errorResult.Error.ShouldNotBeNullOrEmpty(); + errorResult.Error.ShouldBe("API key expired"); } [Fact] diff --git a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs index ac9abe6..4b0309b 100644 --- a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs @@ -44,11 +44,17 @@ public GoogleSearchCommandIntegrationTests(ITestOutputHelper outputHelper) private GoogleSearchCommand CreateCommand() { + var factory = new DefaultHttpClientFactory(); return new GoogleSearchCommand(Options.Create(new BottomlyOptions { GoogleApiKey = ApiKey!, GoogleCseId = CseId! - }), _logger); + }), _logger, factory); + } + + private sealed class DefaultHttpClientFactory : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(); } [Fact] diff --git a/Bottomly.Tests/Helpers/TestHelpers.cs b/Bottomly.Tests/Helpers/TestHelpers.cs index 3ab35b5..d562e7a 100644 --- a/Bottomly.Tests/Helpers/TestHelpers.cs +++ b/Bottomly.Tests/Helpers/TestHelpers.cs @@ -1,6 +1,5 @@ using System.Net; using Bottomly.Configuration; -using Google.Apis.Http; using Microsoft.Extensions.Options; using Moq; using MsHttpClientFactory = System.Net.Http.IHttpClientFactory; @@ -14,6 +13,10 @@ internal static class TestHelpers 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. + /// public static MsHttpClientFactory CreateHttpClientFactory(string responseContent, HttpStatusCode statusCode = HttpStatusCode.OK) { @@ -24,14 +27,6 @@ public static MsHttpClientFactory CreateHttpClientFactory(string responseContent return factory.Object; } - /// - /// Creates a that returns a fake HTTP response, - /// allowing unit tests to exercise without - /// hitting the real Google API. - /// - public static Google.Apis.Http.IHttpClientFactory CreateGoogleHttpClientFactory( - string responseContent, HttpStatusCode statusCode = HttpStatusCode.OK) => - new FakeGoogleHttpClientFactory(new FakeHttpMessageHandler(responseContent, statusCode)); } internal class FakeHttpMessageHandler(string content, HttpStatusCode statusCode = HttpStatusCode.OK) @@ -44,10 +39,4 @@ protected override Task SendAsync(HttpRequestMessage reques StatusCode = statusCode, Content = new StringContent(content) }); -} - -internal class FakeGoogleHttpClientFactory(HttpMessageHandler handler) : Google.Apis.Http.IHttpClientFactory -{ - public ConfigurableHttpClient CreateHttpClient(CreateHttpClientArgs args) => - new(new ConfigurableMessageHandler(handler)); } \ No newline at end of file diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs index 54309b1..c32ab78 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs @@ -18,7 +18,8 @@ public class GoogleHandlerTests public GoogleHandlerTests() { var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options, NullLogger.Instance); + _mockCommand = new Mock(options, NullLogger.Instance, + new Mock().Object); _handler = new GoogleHandler(_mockCommand.Object, _mockBroker.Object, options, NullLogger.Instance); } diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs index 0727841..9a197dc 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs @@ -18,7 +18,7 @@ public class GoogleImageHandlerTests public GoogleImageHandlerTests() { var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options); + _mockCommand = new Mock(options, new Mock().Object); _handler = new GoogleImageHandler(_mockCommand.Object, _mockBroker.Object, options, NullLogger.Instance); } 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 index 51fa44a..3321106 100644 --- a/Bottomly/Commands/GoogleImageSearchCommand.cs +++ b/Bottomly/Commands/GoogleImageSearchCommand.cs @@ -1,44 +1,48 @@ +using System.Text.Json; using Bottomly.Configuration; -using Google.Apis.CustomSearchAPI.v1; -using Google.Apis.Services; using Microsoft.Extensions.Options; namespace Bottomly.Commands; -public class GoogleImageSearchCommand : ICommand +public class GoogleImageSearchCommand(IOptions options, IHttpClientFactory httpClientFactory) : 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 }); - } + private const string BaseUrl = "https://customsearch.googleapis.com/customsearch/v1"; + private readonly string _apiKey = options.Value.GoogleApiKey; + private readonly string _cseId = options.Value.GoogleCseId; public string GetPurpose() => "Performs a google image search and returns the top hit."; public virtual async Task ExecuteAsync(string searchTerm) { - if (string.IsNullOrWhiteSpace(searchTerm)) + if (string.IsNullOrWhiteSpace(searchTerm)) return null; + + try { - return null; - } + var client = httpClientFactory.CreateClient(); + var url = $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1&searchType=image"; + var response = await client.GetAsync(url); - var request = _service.Cse.List(); - request.Q = searchTerm; - request.Cx = _cseId; - request.Num = 1; - request.SearchType = CseResource.ListRequest.SearchTypeEnum.Image; + if (!response.IsSuccessStatusCode) return null; - var result = await request.ExecuteAsync(); - if (result.SearchInformation?.TotalResults == "0" || result.Items is null || !result.Items.Any()) + using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + var root = doc.RootElement; + + if (root.TryGetProperty("searchInformation", out var info) && + info.TryGetProperty("totalResults", out var total) && + total.GetString() == "0") + return null; + + if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) + return null; + + var first = items[0]; + var title = first.GetProperty("title").GetString() ?? string.Empty; + var link = first.GetProperty("link").GetString() ?? string.Empty; + return new GoogleSearchResult(title, link); + } + catch (Exception) { 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 index f3b1c92..05c791d 100644 --- a/Bottomly/Commands/GoogleSearchCommand.cs +++ b/Bottomly/Commands/GoogleSearchCommand.cs @@ -1,6 +1,5 @@ +using System.Text.Json; using Bottomly.Configuration; -using Google.Apis.CustomSearchAPI.v1; -using Google.Apis.Services; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,37 +15,16 @@ public record NoResultsFoundResult : GoogleCommandResult; public record EmptySearchTermErrorResult : GoogleCommandResult; -public class GoogleSearchCommand : ICommand +public class GoogleSearchCommand( + IOptions options, + ILogger logger, + IHttpClientFactory httpClientFactory) : ICommand { - private readonly string _cseId; - private readonly ILogger _logger; - private readonly CustomSearchAPIService _service; + private const string BaseUrl = "https://customsearch.googleapis.com/customsearch/v1"; + private readonly string _apiKey = options.Value.GoogleApiKey; + private readonly string _cseId = options.Value.GoogleCseId; - public GoogleSearchCommand(IOptions options, ILogger logger) - { - _cseId = options.Value.GoogleCseId; - _service = new CustomSearchAPIService( - new BaseClientService.Initializer { ApiKey = options.Value.GoogleApiKey }); - _logger = logger; - } - - internal GoogleSearchCommand(IOptions options, ILogger logger, - Google.Apis.Http.IHttpClientFactory httpClientFactory) - { - _cseId = options.Value.GoogleCseId; - _service = new CustomSearchAPIService( - new BaseClientService.Initializer - { - ApiKey = options.Value.GoogleApiKey, - HttpClientFactory = httpClientFactory - }); - _logger = logger; - } - - public string GetPurpose() - { - return "Performs a google search and returns the top hit."; - } + public string GetPurpose() => "Performs a google search and returns the top hit."; public virtual async Task ExecuteAsync(string searchTerm) { @@ -54,22 +32,51 @@ public virtual async Task ExecuteAsync(string searchTerm) try { - var request = _service.Cse.List(); - request.Q = searchTerm; - request.Cx = _cseId; - request.Num = 1; + var client = httpClientFactory.CreateClient(); + var url = $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1"; + var response = await client.GetAsync(url); + var body = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + { + var errorMessage = ExtractErrorMessage(body) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; + logger.LogError("Google search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); + return new GoogleApiErrorResult(errorMessage); + } + + using var doc = JsonDocument.Parse(body); + var root = doc.RootElement; - var result = await request.ExecuteAsync(); - if (result.SearchInformation?.TotalResults == "0" || result.Items is null || !result.Items.Any()) + if (root.TryGetProperty("searchInformation", out var info) && + info.TryGetProperty("totalResults", out var total) && + total.GetString() == "0") return new NoResultsFoundResult(); - var top = result.Items[0]; - return new GoogleSearchResult(top.Title, top.Link); + if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) + return new NoResultsFoundResult(); + + var first = items[0]; + var title = first.GetProperty("title").GetString() ?? string.Empty; + var link = first.GetProperty("link").GetString() ?? string.Empty; + return new GoogleSearchResult(title, link); } catch (Exception e) { - _logger.LogError(e, "Error executing Google search"); + logger.LogError(e, "Error executing Google search"); return new GoogleApiErrorResult(e.Message); } } -} \ No newline at end of file + + private static string? ExtractErrorMessage(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var error) && + error.TryGetProperty("message", out var msg)) + return msg.GetString(); + } + catch (JsonException) { } + return null; + } +} From d7f475ea2877ea15ffe9e032c045f7448fce4ace Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 11:26:58 +0000 Subject: [PATCH 03/12] Align GoogleImageSearchCommand result pattern with GoogleSearchCommand Replace nullable GoogleSearchResult? return type with GoogleCommandResult discriminated union, matching the pattern used by GoogleSearchCommand. - GoogleImageSearchCommand.ExecuteAsync now returns Task returning EmptySearchTermErrorResult, NoResultsFoundResult, GoogleApiErrorResult, or GoogleSearchResult as appropriate - GoogleImageHandler updated to use the same switch expression pattern - GoogleImageHandlerTests updated: null-based setups replaced with typed results, NullResult test renamed to NoResultsFound - GoogleImageSearchCommandTests updated to assert typed result records Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/GoogleImageSearchCommandTests.cs | 41 +++++++++++-------- .../EventHandlers/GoogleImageHandlerTests.cs | 6 +-- Bottomly/Commands/GoogleImageSearchCommand.cs | 34 +++++++++++---- .../GoogleImageHandler.cs | 9 ++-- 4 files changed, 60 insertions(+), 30 deletions(-) diff --git a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs index b6757cb..272fcf4 100644 --- a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs @@ -13,14 +13,18 @@ public class GoogleImageSearchCommandTests private static readonly IOptions Options = Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); + private static GoogleImageSearchCommand CreateCommand(string responseJson, + HttpStatusCode statusCode = HttpStatusCode.OK) => + new(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode)); + [Fact] - public async Task ExecuteAsync_EmptyInput_ReturnsNull() + public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { var command = new GoogleImageSearchCommand(Options, new Mock().Object); var result = await command.ExecuteAsync(""); - result.ShouldBeNull(); + result.ShouldBeOfType(); } [Fact] @@ -32,34 +36,39 @@ public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResult() "items": [{ "title": "A cat", "link": "https://example.com/cat.jpg" }] } """; - var command = new GoogleImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(json)); - var result = await command.ExecuteAsync("cat"); + var result = await CreateCommand(json).ExecuteAsync("cat"); - result.ShouldNotBeNull(); - result!.Title.ShouldBe("A cat"); - result.Link.ShouldBe("https://example.com/cat.jpg"); + var searchResult = result.ShouldBeOfType(); + searchResult.Title.ShouldBe("A cat"); + searchResult.Link.ShouldBe("https://example.com/cat.jpg"); } [Fact] - public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNull() + public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundResult() { const string json = """{ "searchInformation": { "totalResults": "0" } }"""; - var command = new GoogleImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(json)); - var result = await command.ExecuteAsync("nothing"); + var result = await CreateCommand(json).ExecuteAsync("nothing"); - result.ShouldBeNull(); + result.ShouldBeOfType(); } [Fact] - public async Task ExecuteAsync_ApiReturnsError_ReturnsNull() + public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() { - var command = new GoogleImageSearchCommand(Options, - TestHelpers.CreateHttpClientFactory("{}", HttpStatusCode.Forbidden)); + const string errorJson = """ + { + "error": { + "code": 403, + "message": "API key expired" + } + } + """; - var result = await command.ExecuteAsync("something"); + var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("something"); - result.ShouldBeNull(); + var errorResult = result.ShouldBeOfType(); + errorResult.Error.ShouldBe("API key expired"); } } \ No newline at end of file diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs index 9a197dc..7ae081f 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs @@ -36,7 +36,7 @@ public void CanHandle_InvalidEvent_ReturnsFalse() => [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")); @@ -55,9 +55,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")); diff --git a/Bottomly/Commands/GoogleImageSearchCommand.cs b/Bottomly/Commands/GoogleImageSearchCommand.cs index 3321106..6dd3ab6 100644 --- a/Bottomly/Commands/GoogleImageSearchCommand.cs +++ b/Bottomly/Commands/GoogleImageSearchCommand.cs @@ -12,37 +12,55 @@ public class GoogleImageSearchCommand(IOptions options, IHttpCl public string GetPurpose() => "Performs a google image 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; + if (string.IsNullOrWhiteSpace(searchTerm)) return new EmptySearchTermErrorResult(); try { var client = httpClientFactory.CreateClient(); var url = $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1&searchType=image"; var response = await client.GetAsync(url); + var body = await response.Content.ReadAsStringAsync(); - if (!response.IsSuccessStatusCode) return null; + if (!response.IsSuccessStatusCode) + { + var errorMessage = ExtractErrorMessage(body) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; + return new GoogleApiErrorResult(errorMessage); + } - using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + using var doc = JsonDocument.Parse(body); var root = doc.RootElement; if (root.TryGetProperty("searchInformation", out var info) && info.TryGetProperty("totalResults", out var total) && total.GetString() == "0") - return null; + return new NoResultsFoundResult(); if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) - return null; + return new NoResultsFoundResult(); var first = items[0]; var title = first.GetProperty("title").GetString() ?? string.Empty; var link = first.GetProperty("link").GetString() ?? string.Empty; return new GoogleSearchResult(title, link); } - catch (Exception) + catch (Exception e) { - return null; + return new GoogleApiErrorResult(e.Message); } } + + private static string? ExtractErrorMessage(string body) + { + try + { + using var doc = JsonDocument.Parse(body); + if (doc.RootElement.TryGetProperty("error", out var error) && + error.TryGetProperty("message", out var msg)) + return msg.GetString(); + } + catch (JsonException) { } + return null; + } } diff --git a/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs b/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs index e7610dc..216e6a4 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs @@ -22,9 +22,12 @@ 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 + { + GoogleSearchResult success => $"{success.Title} {success.Link}", + EmptySearchTermErrorResult => $"No image results found for \"{query}\"", + _ => $"No image results found for \"{query}\"" + }; await SendMessageResponseAsync(response, message); } } \ No newline at end of file From 8e1098056f6f6dc140360cdf5316a009525ab12d Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 11:31:13 +0000 Subject: [PATCH 04/12] Refactor Google commands into Bottomly.Commands.Google namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared logic into GoogleCommandBase and move all Google-related types into a Bottomly/Commands/Google/ subfolder. New structure: - GoogleCommandResult.cs — discriminated union result types - GoogleCommandBase.cs — shared HTTP call, JSON parsing, error handling - GoogleSearchCommand.cs — sets ExtraQueryParams to null (text search) - GoogleImageSearchCommand.cs — sets ExtraQueryParams to &searchType=image GoogleCommandBase accepts an optional ILogger? so GoogleSearchCommand can pass its typed logger while GoogleImageSearchCommand needs none. Old GoogleSearchCommand.cs and GoogleImageSearchCommand.cs deleted. Handlers and test files updated to use Bottomly.Commands.Google namespace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/GoogleImageSearchCommandTests.cs | 2 +- .../Commands/GoogleSearchCommandTests.cs | 2 +- .../GoogleSearchCommandIntegrationTests.cs | 2 +- .../Slack/EventHandlers/GoogleHandlerTests.cs | 2 +- .../EventHandlers/GoogleImageHandlerTests.cs | 2 +- .../GoogleCommandBase.cs} | 44 ++++++------- .../Commands/Google/GoogleCommandResult.cs | 11 ++++ .../Google/GoogleImageSearchCommand.cs | 13 ++++ .../Commands/Google/GoogleSearchCommand.cs | 15 +++++ Bottomly/Commands/GoogleImageSearchCommand.cs | 66 ------------------- .../MessageEventHandlers/GoogleHandler.cs | 1 + .../GoogleImageHandler.cs | 2 +- 12 files changed, 68 insertions(+), 94 deletions(-) rename Bottomly/Commands/{GoogleSearchCommand.cs => Google/GoogleCommandBase.cs} (67%) create mode 100644 Bottomly/Commands/Google/GoogleCommandResult.cs create mode 100644 Bottomly/Commands/Google/GoogleImageSearchCommand.cs create mode 100644 Bottomly/Commands/Google/GoogleSearchCommand.cs delete mode 100644 Bottomly/Commands/GoogleImageSearchCommand.cs diff --git a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs index 272fcf4..8e3b617 100644 --- a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands; +using Bottomly.Commands.Google; using Bottomly.Configuration; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Options; diff --git a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs index 5690052..fa5c53e 100644 --- a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands; +using Bottomly.Commands.Google; using Bottomly.Configuration; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; diff --git a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs index 4b0309b..d08161e 100644 --- a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands; +using Bottomly.Commands.Google; using Bottomly.Configuration; using Meziantou.Extensions.Logging.Xunit; using Microsoft.Extensions.Configuration; diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs index c32ab78..7f3f617 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands; +using Bottomly.Commands.Google; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs index 7ae081f..c4b4589 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands; +using Bottomly.Commands.Google; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; diff --git a/Bottomly/Commands/GoogleSearchCommand.cs b/Bottomly/Commands/Google/GoogleCommandBase.cs similarity index 67% rename from Bottomly/Commands/GoogleSearchCommand.cs rename to Bottomly/Commands/Google/GoogleCommandBase.cs index 05c791d..290ba12 100644 --- a/Bottomly/Commands/GoogleSearchCommand.cs +++ b/Bottomly/Commands/Google/GoogleCommandBase.cs @@ -3,28 +3,28 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Bottomly.Commands; +namespace Bottomly.Commands.Google; -public abstract record GoogleCommandResult; - -public record GoogleSearchResult(string Title, string Link) : GoogleCommandResult; - -public record GoogleApiErrorResult(string Error) : GoogleCommandResult; - -public record NoResultsFoundResult : GoogleCommandResult; - -public record EmptySearchTermErrorResult : GoogleCommandResult; - -public class GoogleSearchCommand( - IOptions options, - ILogger logger, - IHttpClientFactory httpClientFactory) : ICommand +public abstract class GoogleCommandBase : ICommand { private const string BaseUrl = "https://customsearch.googleapis.com/customsearch/v1"; - private readonly string _apiKey = options.Value.GoogleApiKey; - private readonly string _cseId = options.Value.GoogleCseId; + private readonly string _apiKey; + private readonly string _cseId; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger? _logger; + + protected GoogleCommandBase(IOptions options, IHttpClientFactory httpClientFactory, + ILogger? logger = null) + { + _apiKey = options.Value.GoogleApiKey; + _cseId = options.Value.GoogleCseId; + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public abstract string GetPurpose(); - public string GetPurpose() => "Performs a google search and returns the top hit."; + protected abstract string? ExtraQueryParams { get; } public virtual async Task ExecuteAsync(string searchTerm) { @@ -32,15 +32,15 @@ public virtual async Task ExecuteAsync(string searchTerm) try { - var client = httpClientFactory.CreateClient(); - var url = $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1"; + var client = _httpClientFactory.CreateClient(); + var url = $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; var response = await client.GetAsync(url); var body = await response.Content.ReadAsStringAsync(); if (!response.IsSuccessStatusCode) { var errorMessage = ExtractErrorMessage(body) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; - logger.LogError("Google search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); + _logger?.LogError("Google search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); return new GoogleApiErrorResult(errorMessage); } @@ -62,7 +62,7 @@ public virtual async Task ExecuteAsync(string searchTerm) } catch (Exception e) { - logger.LogError(e, "Error executing Google search"); + _logger?.LogError(e, "Error executing Google search"); return new GoogleApiErrorResult(e.Message); } } diff --git a/Bottomly/Commands/Google/GoogleCommandResult.cs b/Bottomly/Commands/Google/GoogleCommandResult.cs new file mode 100644 index 0000000..3447298 --- /dev/null +++ b/Bottomly/Commands/Google/GoogleCommandResult.cs @@ -0,0 +1,11 @@ +namespace Bottomly.Commands.Google; + +public abstract record GoogleCommandResult; + +public record GoogleSearchResult(string Title, string Link) : GoogleCommandResult; + +public record GoogleApiErrorResult(string Error) : GoogleCommandResult; + +public record NoResultsFoundResult : GoogleCommandResult; + +public record EmptySearchTermErrorResult : GoogleCommandResult; diff --git a/Bottomly/Commands/Google/GoogleImageSearchCommand.cs b/Bottomly/Commands/Google/GoogleImageSearchCommand.cs new file mode 100644 index 0000000..5fd5979 --- /dev/null +++ b/Bottomly/Commands/Google/GoogleImageSearchCommand.cs @@ -0,0 +1,13 @@ +using Bottomly.Configuration; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands.Google; + +public class GoogleImageSearchCommand( + IOptions options, + IHttpClientFactory httpClientFactory) : GoogleCommandBase(options, httpClientFactory) +{ + protected override string? ExtraQueryParams => "&searchType=image"; + + public override string GetPurpose() => "Performs a google image search and returns the top hit."; +} diff --git a/Bottomly/Commands/Google/GoogleSearchCommand.cs b/Bottomly/Commands/Google/GoogleSearchCommand.cs new file mode 100644 index 0000000..4b7da97 --- /dev/null +++ b/Bottomly/Commands/Google/GoogleSearchCommand.cs @@ -0,0 +1,15 @@ +using Bottomly.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Bottomly.Commands.Google; + +public class GoogleSearchCommand( + IOptions options, + ILogger logger, + IHttpClientFactory httpClientFactory) : GoogleCommandBase(options, httpClientFactory, logger) +{ + protected override string? ExtraQueryParams => null; + + public override string GetPurpose() => "Performs a google search and returns the top hit."; +} diff --git a/Bottomly/Commands/GoogleImageSearchCommand.cs b/Bottomly/Commands/GoogleImageSearchCommand.cs deleted file mode 100644 index 6dd3ab6..0000000 --- a/Bottomly/Commands/GoogleImageSearchCommand.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Text.Json; -using Bottomly.Configuration; -using Microsoft.Extensions.Options; - -namespace Bottomly.Commands; - -public class GoogleImageSearchCommand(IOptions options, IHttpClientFactory httpClientFactory) : ICommand -{ - private const string BaseUrl = "https://customsearch.googleapis.com/customsearch/v1"; - private readonly string _apiKey = options.Value.GoogleApiKey; - private readonly string _cseId = options.Value.GoogleCseId; - - 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 new EmptySearchTermErrorResult(); - - try - { - var client = httpClientFactory.CreateClient(); - var url = $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1&searchType=image"; - var response = await client.GetAsync(url); - var body = await response.Content.ReadAsStringAsync(); - - if (!response.IsSuccessStatusCode) - { - var errorMessage = ExtractErrorMessage(body) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; - return new GoogleApiErrorResult(errorMessage); - } - - using var doc = JsonDocument.Parse(body); - var root = doc.RootElement; - - if (root.TryGetProperty("searchInformation", out var info) && - info.TryGetProperty("totalResults", out var total) && - total.GetString() == "0") - return new NoResultsFoundResult(); - - if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) - return new NoResultsFoundResult(); - - var first = items[0]; - var title = first.GetProperty("title").GetString() ?? string.Empty; - var link = first.GetProperty("link").GetString() ?? string.Empty; - return new GoogleSearchResult(title, link); - } - catch (Exception e) - { - return new GoogleApiErrorResult(e.Message); - } - } - - private static string? ExtractErrorMessage(string body) - { - try - { - using var doc = JsonDocument.Parse(body); - if (doc.RootElement.TryGetProperty("error", out var error) && - error.TryGetProperty("message", out var msg)) - return msg.GetString(); - } - catch (JsonException) { } - return null; - } -} diff --git a/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs b/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs index 2aff6fd..8289c57 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs @@ -1,4 +1,5 @@ using Bottomly.Commands; +using Bottomly.Commands.Google; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; diff --git a/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs b/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs index 216e6a4..617282b 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs @@ -1,4 +1,5 @@ using Bottomly.Commands; +using Bottomly.Commands.Google; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -25,7 +26,6 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) var response = result switch { GoogleSearchResult success => $"{success.Title} {success.Link}", - EmptySearchTermErrorResult => $"No image results found for \"{query}\"", _ => $"No image results found for \"{query}\"" }; await SendMessageResponseAsync(response, message); From c805ddc4b02fbba30611a7a986486387445bb272 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 11:32:50 +0000 Subject: [PATCH 05/12] Make ExtraQueryParams virtual with empty string default Replaces abstract string? with virtual string returning string.Empty, so the property can never be null. GoogleSearchCommand drops the redundant override; GoogleImageSearchCommand keeps its override. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Bottomly/Commands/Google/GoogleCommandBase.cs | 2 +- Bottomly/Commands/Google/GoogleImageSearchCommand.cs | 2 +- Bottomly/Commands/Google/GoogleSearchCommand.cs | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Bottomly/Commands/Google/GoogleCommandBase.cs b/Bottomly/Commands/Google/GoogleCommandBase.cs index 290ba12..3d1102d 100644 --- a/Bottomly/Commands/Google/GoogleCommandBase.cs +++ b/Bottomly/Commands/Google/GoogleCommandBase.cs @@ -24,7 +24,7 @@ protected GoogleCommandBase(IOptions options, IHttpClientFactor public abstract string GetPurpose(); - protected abstract string? ExtraQueryParams { get; } + protected virtual string ExtraQueryParams => string.Empty; public virtual async Task ExecuteAsync(string searchTerm) { diff --git a/Bottomly/Commands/Google/GoogleImageSearchCommand.cs b/Bottomly/Commands/Google/GoogleImageSearchCommand.cs index 5fd5979..6c230f7 100644 --- a/Bottomly/Commands/Google/GoogleImageSearchCommand.cs +++ b/Bottomly/Commands/Google/GoogleImageSearchCommand.cs @@ -7,7 +7,7 @@ public class GoogleImageSearchCommand( IOptions options, IHttpClientFactory httpClientFactory) : GoogleCommandBase(options, httpClientFactory) { - protected override string? ExtraQueryParams => "&searchType=image"; + protected override string ExtraQueryParams => "&searchType=image"; public override string GetPurpose() => "Performs a google image search and returns the top hit."; } diff --git a/Bottomly/Commands/Google/GoogleSearchCommand.cs b/Bottomly/Commands/Google/GoogleSearchCommand.cs index 4b7da97..b91be21 100644 --- a/Bottomly/Commands/Google/GoogleSearchCommand.cs +++ b/Bottomly/Commands/Google/GoogleSearchCommand.cs @@ -9,7 +9,5 @@ public class GoogleSearchCommand( ILogger logger, IHttpClientFactory httpClientFactory) : GoogleCommandBase(options, httpClientFactory, logger) { - protected override string? ExtraQueryParams => null; - public override string GetPurpose() => "Performs a google search and returns the top hit."; } From 5521f57c16882efaacc49629262ad58ab2a6474e Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 11:49:09 +0000 Subject: [PATCH 06/12] SAVEPOINT --- ...oogleImageSearchCommandIntegrationTests.cs | 94 +++++++++++++++++++ .../GoogleSearchCommandIntegrationTests.cs | 2 +- .../Commands/GoogleImageSearchCommandTests.cs | 37 ++++---- .../EventHandlers/GoogleImageHandlerTests.cs | 18 +++- Bottomly/Commands/Google/GoogleCommandBase.cs | 70 +++++++------- .../Google/GoogleImageSearchCommand.cs | 11 ++- 6 files changed, 172 insertions(+), 60 deletions(-) create mode 100644 Bottomly.Tests/Commands/Google/GoogleImageSearchCommandIntegrationTests.cs rename Bottomly.Tests/Commands/{Integration => Google}/GoogleSearchCommandIntegrationTests.cs (98%) diff --git a/Bottomly.Tests/Commands/Google/GoogleImageSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Google/GoogleImageSearchCommandIntegrationTests.cs new file mode 100644 index 0000000..5f0e25b --- /dev/null +++ b/Bottomly.Tests/Commands/Google/GoogleImageSearchCommandIntegrationTests.cs @@ -0,0 +1,94 @@ +using Bottomly.Commands.Google; +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.Google; + +/// +/// Integration tests that call the real Google Custom 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_google_api_key" "..." --project Bottomly`) +/// 2. Environment variables BOTTOMLY_GOOGLE_API_KEY / BOTTOMLY_GOOGLE_CSE_ID +/// (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 GoogleImageSearchCommandIntegrationTests +{ + private static readonly IConfiguration Configuration = new ConfigurationBuilder() + .AddUserSecrets() + .AddEnvironmentVariables() + .Build(); + + private readonly ILogger _logger; + + public GoogleImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) + { + _logger = XUnitLogger.CreateLogger(outputHelper); + } + + private static string? ApiKey => Configuration["bottomly_google_api_key"]; + private static string? CseId => Configuration["bottomly_google_cse_id"]; + + private static bool CredentialsAvailable => + !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(CseId); + + private GoogleImageSearchCommand CreateCommand() + { + var factory = new DefaultHttpClientFactory(); + return new GoogleImageSearchCommand(Options.Create(new BottomlyOptions + { + GoogleApiKey = ApiKey!, + GoogleCseId = CseId! + }), 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 = (GoogleSearchResult)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 = (GoogleSearchResult)result; + searchResult.Link.ShouldNotBeNullOrEmpty(); + searchResult.Link.ShouldStartWith("http"); + } +} diff --git a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Google/GoogleSearchCommandIntegrationTests.cs similarity index 98% rename from Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs rename to Bottomly.Tests/Commands/Google/GoogleSearchCommandIntegrationTests.cs index d08161e..84fcc56 100644 --- a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Google/GoogleSearchCommandIntegrationTests.cs @@ -7,7 +7,7 @@ using Shouldly; using Xunit.Abstractions; -namespace Bottomly.Tests.Commands.Integration; +namespace Bottomly.Tests.Commands.Google; /// /// Integration tests that call the real Google Custom Search API. diff --git a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs index 8e3b617..25a73b5 100644 --- a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs @@ -1,10 +1,11 @@ +using System.Net; using Bottomly.Commands.Google; using Bottomly.Configuration; using Bottomly.Tests.Helpers; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using Shouldly; -using System.Net; namespace Bottomly.Tests.Commands; @@ -14,13 +15,17 @@ public class GoogleImageSearchCommandTests Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); private static GoogleImageSearchCommand CreateCommand(string responseJson, - HttpStatusCode statusCode = HttpStatusCode.OK) => - new(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode)); + HttpStatusCode statusCode = HttpStatusCode.OK) + { + return new GoogleImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode), + NullLogger.Instance); + } [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { - var command = new GoogleImageSearchCommand(Options, new Mock().Object); + var command = new GoogleImageSearchCommand(Options, new Mock().Object, + NullLogger.Instance); var result = await command.ExecuteAsync(""); @@ -31,11 +36,11 @@ public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResult() { const string json = """ - { - "searchInformation": { "totalResults": "1" }, - "items": [{ "title": "A cat", "link": "https://example.com/cat.jpg" }] - } - """; + { + "searchInformation": { "totalResults": "1" }, + "items": [{ "title": "A cat", "link": "https://example.com/cat.jpg" }] + } + """; var result = await CreateCommand(json).ExecuteAsync("cat"); @@ -58,13 +63,13 @@ public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundR public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() { const string errorJson = """ - { - "error": { - "code": 403, - "message": "API key expired" - } - } - """; + { + "error": { + "code": 403, + "message": "API key expired" + } + } + """; var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("something"); diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs index c4b4589..a1d6cf5 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs @@ -18,20 +18,28 @@ public class GoogleImageHandlerTests public GoogleImageHandlerTests() { var options = TestHelpers.CreateOptions(); - _mockCommand = new Mock(options, new Mock().Object); + _mockCommand = new Mock(options, new Mock().Object, + NullLogger.Instance); _handler = new GoogleImageHandler(_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() diff --git a/Bottomly/Commands/Google/GoogleCommandBase.cs b/Bottomly/Commands/Google/GoogleCommandBase.cs index 3d1102d..f105ef4 100644 --- a/Bottomly/Commands/Google/GoogleCommandBase.cs +++ b/Bottomly/Commands/Google/GoogleCommandBase.cs @@ -5,55 +5,42 @@ namespace Bottomly.Commands.Google; -public abstract class GoogleCommandBase : ICommand +public abstract class GoogleCommandBase( + IOptions options, + IHttpClientFactory httpClientFactory, + ILogger logger) + : ICommand { private const string BaseUrl = "https://customsearch.googleapis.com/customsearch/v1"; - private readonly string _apiKey; - private readonly string _cseId; - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger? _logger; + private readonly string _apiKey = options.Value.GoogleApiKey; + private readonly string _cseId = options.Value.GoogleCseId; - protected GoogleCommandBase(IOptions options, IHttpClientFactory httpClientFactory, - ILogger? logger = null) - { - _apiKey = options.Value.GoogleApiKey; - _cseId = options.Value.GoogleCseId; - _httpClientFactory = httpClientFactory; - _logger = logger; - } + protected virtual string ExtraQueryParams => string.Empty; public abstract string GetPurpose(); - protected virtual string ExtraQueryParams => string.Empty; - public virtual async Task ExecuteAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) return new EmptySearchTermErrorResult(); try { - var client = _httpClientFactory.CreateClient(); - var url = $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; + var client = httpClientFactory.CreateClient(); + var url = + $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; var response = await client.GetAsync(url); - var body = await response.Content.ReadAsStringAsync(); - if (!response.IsSuccessStatusCode) { - var errorMessage = ExtractErrorMessage(body) ?? $"HTTP {(int)response.StatusCode}: {response.ReasonPhrase}"; - _logger?.LogError("Google search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); + var errorMessage = ExtractErrorMessage(response); + logger?.LogError("Google search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); return new GoogleApiErrorResult(errorMessage); } + var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var root = doc.RootElement; - if (root.TryGetProperty("searchInformation", out var info) && - info.TryGetProperty("totalResults", out var total) && - total.GetString() == "0") - return new NoResultsFoundResult(); - - if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0) - return new NoResultsFoundResult(); + if (!TryGetSearchResults(root, out var items)) return new NoResultsFoundResult(); var first = items[0]; var title = first.GetProperty("title").GetString() ?? string.Empty; @@ -62,21 +49,34 @@ public virtual async Task ExecuteAsync(string searchTerm) } catch (Exception e) { - _logger?.LogError(e, "Error executing Google search"); + logger?.LogError(e, "Error executing Google search"); return new GoogleApiErrorResult(e.Message); } } - private static string? ExtractErrorMessage(string body) + private static bool TryGetSearchResults(JsonElement root, out JsonElement results) + { + results = default; + return !(root.TryGetProperty("searchInformation", out var info) && + info.TryGetProperty("totalResults", out var total) && + total.GetString() == "0") && root.TryGetProperty("items", out results) && + results.GetArrayLength() > 0; + } + + private static string ExtractErrorMessage(HttpResponseMessage response) { try { - using var doc = JsonDocument.Parse(body); + using var doc = JsonDocument.Parse(response.Content.ReadAsStringAsync().Result); if (doc.RootElement.TryGetProperty("error", out var error) && error.TryGetProperty("message", out var msg)) - return msg.GetString(); + return msg.GetString()!; + } + catch (JsonException) + { + // swallow. } - catch (JsonException) { } - return null; + + return response.ReasonPhrase ?? "Unknown error"; } -} +} \ No newline at end of file diff --git a/Bottomly/Commands/Google/GoogleImageSearchCommand.cs b/Bottomly/Commands/Google/GoogleImageSearchCommand.cs index 6c230f7..c9ae660 100644 --- a/Bottomly/Commands/Google/GoogleImageSearchCommand.cs +++ b/Bottomly/Commands/Google/GoogleImageSearchCommand.cs @@ -1,13 +1,18 @@ using Bottomly.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Bottomly.Commands.Google; public class GoogleImageSearchCommand( IOptions options, - IHttpClientFactory httpClientFactory) : GoogleCommandBase(options, httpClientFactory) + IHttpClientFactory httpClientFactory, + ILogger logger) : GoogleCommandBase(options, httpClientFactory, logger) { protected override string ExtraQueryParams => "&searchType=image"; - public override string GetPurpose() => "Performs a google image search and returns the top hit."; -} + public override string GetPurpose() + { + return "Performs a google image search and returns the top hit."; + } +} \ No newline at end of file From 9b4e8fd737b7704e3833d962493022b1e02ff278 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 11:50:53 +0000 Subject: [PATCH 07/12] SAVEPOINT --- .../GoogleImageSearchCommandIntegrationTests.cs | 2 +- .../GoogleSearchCommandIntegrationTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename Bottomly.Tests/Commands/{Google => Integration}/GoogleImageSearchCommandIntegrationTests.cs (98%) rename Bottomly.Tests/Commands/{Google => Integration}/GoogleSearchCommandIntegrationTests.cs (98%) diff --git a/Bottomly.Tests/Commands/Google/GoogleImageSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/GoogleImageSearchCommandIntegrationTests.cs similarity index 98% rename from Bottomly.Tests/Commands/Google/GoogleImageSearchCommandIntegrationTests.cs rename to Bottomly.Tests/Commands/Integration/GoogleImageSearchCommandIntegrationTests.cs index 5f0e25b..a919376 100644 --- a/Bottomly.Tests/Commands/Google/GoogleImageSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/GoogleImageSearchCommandIntegrationTests.cs @@ -7,7 +7,7 @@ using Shouldly; using Xunit.Abstractions; -namespace Bottomly.Tests.Commands.Google; +namespace Bottomly.Tests.Commands.Integration; /// /// Integration tests that call the real Google Custom Search API with image search. diff --git a/Bottomly.Tests/Commands/Google/GoogleSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs similarity index 98% rename from Bottomly.Tests/Commands/Google/GoogleSearchCommandIntegrationTests.cs rename to Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs index 84fcc56..d08161e 100644 --- a/Bottomly.Tests/Commands/Google/GoogleSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs @@ -7,7 +7,7 @@ using Shouldly; using Xunit.Abstractions; -namespace Bottomly.Tests.Commands.Google; +namespace Bottomly.Tests.Commands.Integration; /// /// Integration tests that call the real Google Custom Search API. From b523152cbb07609a844a08028948431ff8bd0a68 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 12:44:45 +0000 Subject: [PATCH 08/12] SAVEPOINT --- Bottomly/Commands/Google/GoogleCommandBase.cs | 4 +++- Bottomly/test.http | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 Bottomly/test.http diff --git a/Bottomly/Commands/Google/GoogleCommandBase.cs b/Bottomly/Commands/Google/GoogleCommandBase.cs index f105ef4..b6714d2 100644 --- a/Bottomly/Commands/Google/GoogleCommandBase.cs +++ b/Bottomly/Commands/Google/GoogleCommandBase.cs @@ -27,7 +27,9 @@ public virtual async Task ExecuteAsync(string searchTerm) { var client = httpClientFactory.CreateClient(); var url = - $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; + $"{BaseUrl}?key={_apiKey}" + + $"&cx={_cseId}" + + $"&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; var response = await client.GetAsync(url); if (!response.IsSuccessStatusCode) { diff --git a/Bottomly/test.http b/Bottomly/test.http new file mode 100644 index 0000000..8c80fed --- /dev/null +++ b/Bottomly/test.http @@ -0,0 +1,9 @@ +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 \ No newline at end of file From 5e9d961511b8615a93ec98bca36055edddbb95c7 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 12:51:18 +0000 Subject: [PATCH 09/12] corrects secret names in workflow --- .github/workflows/dotnet.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 11ed56b..9315075 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 @@ -30,8 +30,8 @@ jobs: - name: Test run: dotnet test bottomly.net.slnx --no-build --configuration Release --verbosity normal env: - BOTTOMLY_GOOGLE_API_KEY: ${{ secrets.BOTTOMLY_GOOGLE_API_KEY }} - BOTTOMLY_GOOGLE_CSE_ID: ${{ secrets.BOTTOMLY_GOOGLE_CSE_ID }} + BOTTOMLY_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + BOTTOMLY_GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }} - name: Build Docker image run: docker build -t bottomly . From cee278dc590ceb5869fc7a6dc895cdb6e6b770c5 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 15:37:50 +0000 Subject: [PATCH 10/12] SAVEPOINT --- ...andTests.cs => ImageSearchCommandTests.cs} | 22 ++++++------ ... => ImageSearchCommandIntegrationTests.cs} | 24 ++++++------- ...ts.cs => SearchCommandIntegrationTests.cs} | 24 ++++++------- ...hCommandTests.cs => SearchCommandTests.cs} | 24 ++++++------- ...lerTests.cs => ImageSearchHandlerTests.cs} | 22 ++++++------ ...eHandlerTests.cs => SearchHandlerTests.cs} | 35 +++++++++++-------- .../Commands/Google/GoogleCommandResult.cs | 11 ------ .../ImageSearchCommand.cs} | 8 ++--- .../SearchCommand.cs} | 8 ++--- .../SearchCommandBase.cs} | 18 +++++----- .../Commands/Search/SearchCommandResult.cs | 11 ++++++ ...eImageHandler.cs => ImageSearchHandler.cs} | 18 ++++++---- .../{GoogleHandler.cs => SearchHandler.cs} | 12 +++---- Bottomly/test.http | 6 +++- 14 files changed, 128 insertions(+), 115 deletions(-) rename Bottomly.Tests/Commands/{GoogleImageSearchCommandTests.cs => ImageSearchCommandTests.cs} (73%) rename Bottomly.Tests/Commands/Integration/{GoogleImageSearchCommandIntegrationTests.cs => ImageSearchCommandIntegrationTests.cs} (79%) rename Bottomly.Tests/Commands/Integration/{GoogleSearchCommandIntegrationTests.cs => SearchCommandIntegrationTests.cs} (81%) rename Bottomly.Tests/Commands/{GoogleSearchCommandTests.cs => SearchCommandTests.cs} (79%) rename Bottomly.Tests/Slack/EventHandlers/{GoogleImageHandlerTests.cs => ImageSearchHandlerTests.cs} (76%) rename Bottomly.Tests/Slack/EventHandlers/{GoogleHandlerTests.cs => SearchHandlerTests.cs} (69%) delete mode 100644 Bottomly/Commands/Google/GoogleCommandResult.cs rename Bottomly/Commands/{Google/GoogleImageSearchCommand.cs => Search/ImageSearchCommand.cs} (68%) rename Bottomly/Commands/{Google/GoogleSearchCommand.cs => Search/SearchCommand.cs} (61%) rename Bottomly/Commands/{Google/GoogleCommandBase.cs => Search/SearchCommandBase.cs} (84%) create mode 100644 Bottomly/Commands/Search/SearchCommandResult.cs rename Bottomly/Slack/MessageEventHandlers/{GoogleImageHandler.cs => ImageSearchHandler.cs} (69%) rename Bottomly/Slack/MessageEventHandlers/{GoogleHandler.cs => SearchHandler.cs} (79%) diff --git a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs similarity index 73% rename from Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs rename to Bottomly.Tests/Commands/ImageSearchCommandTests.cs index 25a73b5..9d11ae4 100644 --- a/Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs @@ -1,5 +1,5 @@ using System.Net; -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; @@ -9,23 +9,23 @@ namespace Bottomly.Tests.Commands; -public class GoogleImageSearchCommandTests +public class ImageSearchCommandTests { private static readonly IOptions Options = Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); - private static GoogleImageSearchCommand CreateCommand(string responseJson, + private static ImageSearchCommand CreateCommand(string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK) { - return new GoogleImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode), - NullLogger.Instance); + return new ImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode), + NullLogger.Instance); } [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { - var command = new GoogleImageSearchCommand(Options, new Mock().Object, - NullLogger.Instance); + var command = new ImageSearchCommand(Options, new Mock().Object, + NullLogger.Instance); var result = await command.ExecuteAsync(""); @@ -33,7 +33,7 @@ public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() } [Fact] - public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResult() + public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResult() { const string json = """ { @@ -44,7 +44,7 @@ public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResult() var result = await CreateCommand(json).ExecuteAsync("cat"); - var searchResult = result.ShouldBeOfType(); + var searchResult = result.ShouldBeOfType(); searchResult.Title.ShouldBe("A cat"); searchResult.Link.ShouldBe("https://example.com/cat.jpg"); } @@ -60,7 +60,7 @@ public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundR } [Fact] - public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() + public async Task ExecuteAsync_ApiReturnsError_ReturnsSearchApiErrorResult() { const string errorJson = """ { @@ -73,7 +73,7 @@ public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("something"); - var errorResult = result.ShouldBeOfType(); + var errorResult = result.ShouldBeOfType(); errorResult.Error.ShouldBe("API key expired"); } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/Integration/GoogleImageSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs similarity index 79% rename from Bottomly.Tests/Commands/Integration/GoogleImageSearchCommandIntegrationTests.cs rename to Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs index a919376..3a9c68c 100644 --- a/Bottomly.Tests/Commands/Integration/GoogleImageSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Meziantou.Extensions.Logging.Xunit; using Microsoft.Extensions.Configuration; @@ -20,18 +20,18 @@ namespace Bottomly.Tests.Commands.Integration; /// 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 GoogleImageSearchCommandIntegrationTests +public class ImageSearchCommandIntegrationTests { private static readonly IConfiguration Configuration = new ConfigurationBuilder() - .AddUserSecrets() + .AddUserSecrets() .AddEnvironmentVariables() .Build(); - private readonly ILogger _logger; + private readonly ILogger _logger; - public GoogleImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) + public ImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) { - _logger = XUnitLogger.CreateLogger(outputHelper); + _logger = XUnitLogger.CreateLogger(outputHelper); } private static string? ApiKey => Configuration["bottomly_google_api_key"]; @@ -40,10 +40,10 @@ public GoogleImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(CseId); - private GoogleImageSearchCommand CreateCommand() + private ImageSearchCommand CreateCommand() { var factory = new DefaultHttpClientFactory(); - return new GoogleImageSearchCommand(Options.Create(new BottomlyOptions + return new ImageSearchCommand(Options.Create(new BottomlyOptions { GoogleApiKey = ApiKey!, GoogleCseId = CseId! @@ -72,8 +72,8 @@ public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithLink() var result = await CreateCommand().ExecuteAsync("GitHub"); - result.ShouldBeOfType(); - var searchResult = (GoogleSearchResult)result; + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; searchResult.Title.ShouldNotBeNullOrEmpty(); searchResult.Link.ShouldNotBeNullOrEmpty(); searchResult.Link.ShouldStartWith("http"); @@ -86,8 +86,8 @@ public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult() var result = await CreateCommand().ExecuteAsync("Wikipedia logo"); - result.ShouldBeOfType(); - var searchResult = (GoogleSearchResult)result; + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; searchResult.Link.ShouldNotBeNullOrEmpty(); searchResult.Link.ShouldStartWith("http"); } diff --git a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs similarity index 81% rename from Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs rename to Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs index d08161e..cc815b3 100644 --- a/Bottomly.Tests/Commands/Integration/GoogleSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Meziantou.Extensions.Logging.Xunit; using Microsoft.Extensions.Configuration; @@ -20,20 +20,20 @@ namespace Bottomly.Tests.Commands.Integration; /// 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 GoogleSearchCommandIntegrationTests +public class SearchCommandIntegrationTests { private static readonly IConfiguration Configuration = new ConfigurationBuilder() // User secrets stored against the main Bottomly app assembly's UserSecretsId - .AddUserSecrets() + .AddUserSecrets() // Environment variables override user secrets (used in CI) .AddEnvironmentVariables() .Build(); - private readonly ILogger _logger; + private readonly ILogger _logger; - public GoogleSearchCommandIntegrationTests(ITestOutputHelper outputHelper) + public SearchCommandIntegrationTests(ITestOutputHelper outputHelper) { - _logger = XUnitLogger.CreateLogger(outputHelper); + _logger = XUnitLogger.CreateLogger(outputHelper); } private static string? ApiKey => Configuration["bottomly_google_api_key"]; @@ -42,10 +42,10 @@ public GoogleSearchCommandIntegrationTests(ITestOutputHelper outputHelper) private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(CseId); - private GoogleSearchCommand CreateCommand() + private SearchCommand CreateCommand() { var factory = new DefaultHttpClientFactory(); - return new GoogleSearchCommand(Options.Create(new BottomlyOptions + return new SearchCommand(Options.Create(new BottomlyOptions { GoogleApiKey = ApiKey!, GoogleCseId = CseId! @@ -74,8 +74,8 @@ public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithLink() var result = await CreateCommand().ExecuteAsync("GitHub"); - result.ShouldBeOfType(); - var searchResult = (GoogleSearchResult)result; + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; searchResult.Title.ShouldNotBeNullOrEmpty(); searchResult.Link.ShouldNotBeNullOrEmpty(); searchResult.Link.ShouldStartWith("http"); @@ -88,8 +88,8 @@ public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult() var result = await CreateCommand().ExecuteAsync("Wikipedia"); - result.ShouldBeOfType(); - var searchResult = (GoogleSearchResult)result; + result.ShouldBeOfType(); + var searchResult = (SearchResult)result; searchResult.Link.ShouldContain("wikipedia"); } } \ No newline at end of file diff --git a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs b/Bottomly.Tests/Commands/SearchCommandTests.cs similarity index 79% rename from Bottomly.Tests/Commands/GoogleSearchCommandTests.cs rename to Bottomly.Tests/Commands/SearchCommandTests.cs index fa5c53e..3dd9cf5 100644 --- a/Bottomly.Tests/Commands/GoogleSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/SearchCommandTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Bottomly.Tests.Helpers; using Microsoft.Extensions.Logging.Abstractions; @@ -8,7 +8,7 @@ namespace Bottomly.Tests.Commands; -public class GoogleSearchCommandTests +public class SearchCommandTests { private static readonly IOptions Options = Microsoft.Extensions.Options.Options.Create(new BottomlyOptions @@ -17,15 +17,15 @@ public class GoogleSearchCommandTests GoogleCseId = "fake-cse" }); - private static GoogleSearchCommand CreateCommand(string responseJson, + private static SearchCommand CreateCommand(string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK) => - new(Options, NullLogger.Instance, + new(Options, NullLogger.Instance, TestHelpers.CreateHttpClientFactory(responseJson, statusCode)); [Fact] public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() { - var command = new GoogleSearchCommand(Options, NullLogger.Instance, + var command = new SearchCommand(Options, NullLogger.Instance, TestHelpers.CreateHttpClientFactory(string.Empty)); var result = await command.ExecuteAsync(""); @@ -36,7 +36,7 @@ public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult() [Fact] public async Task ExecuteAsync_WhitespaceInput_ReturnsEmptySearchTermErrorResult() { - var command = new GoogleSearchCommand(Options, NullLogger.Instance, + var command = new SearchCommand(Options, NullLogger.Instance, TestHelpers.CreateHttpClientFactory(string.Empty)); var result = await command.ExecuteAsync(" "); @@ -45,7 +45,7 @@ public async Task ExecuteAsync_WhitespaceInput_ReturnsEmptySearchTermErrorResult } [Fact] - public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResultWithTitleAndLink() + public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResultWithTitleAndLink() { const string json = """ { @@ -56,7 +56,7 @@ public async Task ExecuteAsync_ApiReturnsResults_ReturnsGoogleSearchResultWithTi var result = await CreateCommand(json).ExecuteAsync("dotnet"); - var searchResult = result.ShouldBeOfType(); + var searchResult = result.ShouldBeOfType(); searchResult.Title.ShouldBe("DotNet"); searchResult.Link.ShouldBe("https://dotnet.microsoft.com"); } @@ -82,7 +82,7 @@ public async Task ExecuteAsync_ApiReturnsNullItems_ReturnsNoResultsFoundResult() } [Fact] - public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() + public async Task ExecuteAsync_ApiReturnsError_ReturnsSearchApiErrorResult() { const string errorJson = """ { @@ -96,15 +96,15 @@ public async Task ExecuteAsync_ApiReturnsError_ReturnsGoogleApiErrorResult() var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("anything"); - var errorResult = result.ShouldBeOfType(); + var errorResult = result.ShouldBeOfType(); errorResult.Error.ShouldBe("API key expired"); } [Fact] - public async Task ExecuteAsync_ApiReturnsServerError_ReturnsGoogleApiErrorResult() + public async Task ExecuteAsync_ApiReturnsServerError_ReturnsSearchApiErrorResult() { var result = await CreateCommand("{}", HttpStatusCode.InternalServerError).ExecuteAsync("anything"); - result.ShouldBeOfType(); + result.ShouldBeOfType(); } } \ No newline at end of file diff --git a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs similarity index 76% rename from Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs rename to Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs index a1d6cf5..7383431 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleImageHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/ImageSearchHandlerTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; @@ -9,19 +9,19 @@ 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, new Mock().Object, - NullLogger.Instance); - _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) @@ -55,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")); @@ -77,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 69% rename from Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs rename to Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs index 7f3f617..a73f15a 100644 --- a/Bottomly.Tests/Slack/EventHandlers/GoogleHandlerTests.cs +++ b/Bottomly.Tests/Slack/EventHandlers/SearchHandlerTests.cs @@ -1,4 +1,4 @@ -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Slack; using Bottomly.Slack.MessageEventHandlers; using Bottomly.Tests.Helpers; @@ -9,31 +9,37 @@ 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, NullLogger.Instance, + _mockCommand = new Mock(options, NullLogger.Instance, new Mock().Object); - _handler = new GoogleHandler(_mockCommand.Object, _mockBroker.Object, options, - NullLogger.Instance); + _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() @@ -49,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")); @@ -71,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/Commands/Google/GoogleCommandResult.cs b/Bottomly/Commands/Google/GoogleCommandResult.cs deleted file mode 100644 index 3447298..0000000 --- a/Bottomly/Commands/Google/GoogleCommandResult.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Bottomly.Commands.Google; - -public abstract record GoogleCommandResult; - -public record GoogleSearchResult(string Title, string Link) : GoogleCommandResult; - -public record GoogleApiErrorResult(string Error) : GoogleCommandResult; - -public record NoResultsFoundResult : GoogleCommandResult; - -public record EmptySearchTermErrorResult : GoogleCommandResult; diff --git a/Bottomly/Commands/Google/GoogleImageSearchCommand.cs b/Bottomly/Commands/Search/ImageSearchCommand.cs similarity index 68% rename from Bottomly/Commands/Google/GoogleImageSearchCommand.cs rename to Bottomly/Commands/Search/ImageSearchCommand.cs index c9ae660..2791e20 100644 --- a/Bottomly/Commands/Google/GoogleImageSearchCommand.cs +++ b/Bottomly/Commands/Search/ImageSearchCommand.cs @@ -2,12 +2,12 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Bottomly.Commands.Google; +namespace Bottomly.Commands.Search; -public class GoogleImageSearchCommand( +public class ImageSearchCommand( IOptions options, IHttpClientFactory httpClientFactory, - ILogger logger) : GoogleCommandBase(options, httpClientFactory, logger) + ILogger logger) : SearchCommandBase(options, httpClientFactory, logger) { protected override string ExtraQueryParams => "&searchType=image"; @@ -15,4 +15,4 @@ public override string GetPurpose() { return "Performs a google image search and returns the top hit."; } -} \ No newline at end of file +} diff --git a/Bottomly/Commands/Google/GoogleSearchCommand.cs b/Bottomly/Commands/Search/SearchCommand.cs similarity index 61% rename from Bottomly/Commands/Google/GoogleSearchCommand.cs rename to Bottomly/Commands/Search/SearchCommand.cs index b91be21..4a27915 100644 --- a/Bottomly/Commands/Google/GoogleSearchCommand.cs +++ b/Bottomly/Commands/Search/SearchCommand.cs @@ -2,12 +2,12 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Bottomly.Commands.Google; +namespace Bottomly.Commands.Search; -public class GoogleSearchCommand( +public class SearchCommand( IOptions options, - ILogger logger, - IHttpClientFactory httpClientFactory) : GoogleCommandBase(options, httpClientFactory, logger) + ILogger logger, + IHttpClientFactory httpClientFactory) : SearchCommandBase(options, httpClientFactory, logger) { public override string GetPurpose() => "Performs a google search and returns the top hit."; } diff --git a/Bottomly/Commands/Google/GoogleCommandBase.cs b/Bottomly/Commands/Search/SearchCommandBase.cs similarity index 84% rename from Bottomly/Commands/Google/GoogleCommandBase.cs rename to Bottomly/Commands/Search/SearchCommandBase.cs index b6714d2..d6c4d77 100644 --- a/Bottomly/Commands/Google/GoogleCommandBase.cs +++ b/Bottomly/Commands/Search/SearchCommandBase.cs @@ -3,9 +3,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -namespace Bottomly.Commands.Google; +namespace Bottomly.Commands.Search; -public abstract class GoogleCommandBase( +public abstract class SearchCommandBase( IOptions options, IHttpClientFactory httpClientFactory, ILogger logger) @@ -19,7 +19,7 @@ public abstract class GoogleCommandBase( public abstract string GetPurpose(); - public virtual async Task ExecuteAsync(string searchTerm) + public virtual async Task ExecuteAsync(string searchTerm) { if (string.IsNullOrWhiteSpace(searchTerm)) return new EmptySearchTermErrorResult(); @@ -27,15 +27,13 @@ public virtual async Task ExecuteAsync(string searchTerm) { var client = httpClientFactory.CreateClient(); var url = - $"{BaseUrl}?key={_apiKey}" + - $"&cx={_cseId}" + - $"&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; + $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; var response = await client.GetAsync(url); if (!response.IsSuccessStatusCode) { var errorMessage = ExtractErrorMessage(response); logger?.LogError("Google search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); - return new GoogleApiErrorResult(errorMessage); + return new SearchApiErrorResult(errorMessage); } var body = await response.Content.ReadAsStringAsync(); @@ -47,12 +45,12 @@ public virtual async Task ExecuteAsync(string searchTerm) var first = items[0]; var title = first.GetProperty("title").GetString() ?? string.Empty; var link = first.GetProperty("link").GetString() ?? string.Empty; - return new GoogleSearchResult(title, link); + return new SearchResult(title, link); } catch (Exception e) { logger?.LogError(e, "Error executing Google search"); - return new GoogleApiErrorResult(e.Message); + return new SearchApiErrorResult(e.Message); } } @@ -81,4 +79,4 @@ private static string ExtractErrorMessage(HttpResponseMessage response) return response.ReasonPhrase ?? "Unknown error"; } -} \ No newline at end of file +} 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/Slack/MessageEventHandlers/GoogleImageHandler.cs b/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs similarity index 69% rename from Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs rename to Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs index 617282b..c3ef1df 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleImageHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/ImageSearchHandler.cs @@ -1,5 +1,5 @@ using Bottomly.Commands; -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -7,17 +7,21 @@ 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) { @@ -25,7 +29,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) var result = await command.ExecuteAsync(query); var response = result switch { - GoogleSearchResult success => $"{success.Title} {success.Link}", + SearchResult success => $"{success.Title} {success.Link}", _ => $"No image results found for \"{query}\"" }; await SendMessageResponseAsync(response, message); diff --git a/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs similarity index 79% rename from Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs rename to Bottomly/Slack/MessageEventHandlers/SearchHandler.cs index 8289c57..c0ae2a5 100644 --- a/Bottomly/Slack/MessageEventHandlers/GoogleHandler.cs +++ b/Bottomly/Slack/MessageEventHandlers/SearchHandler.cs @@ -1,5 +1,5 @@ using Bottomly.Commands; -using Bottomly.Commands.Google; +using Bottomly.Commands.Search; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -7,14 +7,14 @@ 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"; @@ -30,7 +30,7 @@ protected override async Task InvokeHandlerLogicAsync(MessageEvent message) var response = result switch { - GoogleSearchResult success => $"{success.Title} {success.Link}", + SearchResult success => $"{success.Title} {success.Link}", EmptySearchTermErrorResult => $"No results found for \"{query}\"", _ => "Left as an exercise for the reader." }; diff --git a/Bottomly/test.http b/Bottomly/test.http index 8c80fed..b83f8f3 100644 --- a/Bottomly/test.http +++ b/Bottomly/test.http @@ -6,4 +6,8 @@ Accept: application/json ### -GET https://www.google.com/search?q=einstein \ No newline at end of file +GET https://www.google.com/search?q=einstein + +### + +GET https://duckduckgo.com/html/?q=einstein \ No newline at end of file From 2a4dca300649f48ff1fe1f5399da45df1dda1fc9 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 15:51:49 +0000 Subject: [PATCH 11/12] SAVEPOINT --- .../Commands/ImageSearchCommandTests.cs | 25 ++++------ .../ImageSearchCommandIntegrationTests.cs | 15 +++--- .../SearchCommandIntegrationTests.cs | 17 +++---- Bottomly.Tests/Commands/SearchCommandTests.cs | 31 +++++------- .../Commands/Search/ImageSearchCommand.cs | 19 +++++-- Bottomly/Commands/Search/SearchCommand.cs | 20 +++++++- Bottomly/Commands/Search/SearchCommandBase.cs | 49 +++++++------------ Bottomly/Configuration/BottomlyOptions.cs | 1 + Bottomly/Program.cs | 11 +++-- 9 files changed, 95 insertions(+), 93 deletions(-) diff --git a/Bottomly.Tests/Commands/ImageSearchCommandTests.cs b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs index 9d11ae4..20708b8 100644 --- a/Bottomly.Tests/Commands/ImageSearchCommandTests.cs +++ b/Bottomly.Tests/Commands/ImageSearchCommandTests.cs @@ -12,7 +12,7 @@ namespace Bottomly.Tests.Commands; public class ImageSearchCommandTests { private static readonly IOptions Options = - Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { GoogleApiKey = "key", GoogleCseId = "cse" }); + Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { BraveApiKey = "fake-key" }); private static ImageSearchCommand CreateCommand(string responseJson, HttpStatusCode statusCode = HttpStatusCode.OK) @@ -37,8 +37,10 @@ public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResult() { const string json = """ { - "searchInformation": { "totalResults": "1" }, - "items": [{ "title": "A cat", "link": "https://example.com/cat.jpg" }] + "type": "images", + "results": [ + { "title": "A cat", "properties": { "url": "https://example.com/cat.jpg" } } + ] } """; @@ -50,9 +52,9 @@ public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResult() } [Fact] - public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundResult() + public async Task ExecuteAsync_ApiReturnsEmptyResults_ReturnsNoResultsFoundResult() { - const string json = """{ "searchInformation": { "totalResults": "0" } }"""; + const string json = """{ "type": "images", "results": [] }"""; var result = await CreateCommand(json).ExecuteAsync("nothing"); @@ -62,18 +64,11 @@ public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundR [Fact] public async Task ExecuteAsync_ApiReturnsError_ReturnsSearchApiErrorResult() { - const string errorJson = """ - { - "error": { - "code": 403, - "message": "API key expired" - } - } - """; + const string errorJson = """{ "message": "Invalid subscription token" }"""; - var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("something"); + var result = await CreateCommand(errorJson, HttpStatusCode.Unauthorized).ExecuteAsync("something"); var errorResult = result.ShouldBeOfType(); - errorResult.Error.ShouldBe("API key expired"); + 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 index 3a9c68c..4c8417a 100644 --- a/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/ImageSearchCommandIntegrationTests.cs @@ -10,11 +10,11 @@ namespace Bottomly.Tests.Commands.Integration; /// -/// Integration tests that call the real Google Custom Search API with image search. +/// 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_google_api_key" "..." --project Bottomly`) -/// 2. Environment variables BOTTOMLY_GOOGLE_API_KEY / BOTTOMLY_GOOGLE_CSE_ID +/// 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 @@ -34,19 +34,16 @@ public ImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper) _logger = XUnitLogger.CreateLogger(outputHelper); } - private static string? ApiKey => Configuration["bottomly_google_api_key"]; - private static string? CseId => Configuration["bottomly_google_cse_id"]; + private static string? ApiKey => Configuration["bottomly_brave_api_key"]; - private static bool CredentialsAvailable => - !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(CseId); + private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey); private ImageSearchCommand CreateCommand() { var factory = new DefaultHttpClientFactory(); return new ImageSearchCommand(Options.Create(new BottomlyOptions { - GoogleApiKey = ApiKey!, - GoogleCseId = CseId! + BraveApiKey = ApiKey! }), factory, _logger); } diff --git a/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs index cc815b3..4ae44ab 100644 --- a/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs +++ b/Bottomly.Tests/Commands/Integration/SearchCommandIntegrationTests.cs @@ -10,11 +10,11 @@ namespace Bottomly.Tests.Commands.Integration; /// -/// Integration tests that call the real Google Custom Search API. +/// 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_google_api_key" "..." --project Bottomly`) -/// 2. Environment variables BOTTOMLY_GOOGLE_API_KEY / BOTTOMLY_GOOGLE_CSE_ID +/// 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 @@ -23,9 +23,7 @@ namespace Bottomly.Tests.Commands.Integration; public class SearchCommandIntegrationTests { private static readonly IConfiguration Configuration = new ConfigurationBuilder() - // User secrets stored against the main Bottomly app assembly's UserSecretsId .AddUserSecrets() - // Environment variables override user secrets (used in CI) .AddEnvironmentVariables() .Build(); @@ -36,19 +34,16 @@ public SearchCommandIntegrationTests(ITestOutputHelper outputHelper) _logger = XUnitLogger.CreateLogger(outputHelper); } - private static string? ApiKey => Configuration["bottomly_google_api_key"]; - private static string? CseId => Configuration["bottomly_google_cse_id"]; + private static string? ApiKey => Configuration["bottomly_brave_api_key"]; - private static bool CredentialsAvailable => - !string.IsNullOrWhiteSpace(ApiKey) && !string.IsNullOrWhiteSpace(CseId); + private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey); private SearchCommand CreateCommand() { var factory = new DefaultHttpClientFactory(); return new SearchCommand(Options.Create(new BottomlyOptions { - GoogleApiKey = ApiKey!, - GoogleCseId = CseId! + BraveApiKey = ApiKey! }), _logger, factory); } diff --git a/Bottomly.Tests/Commands/SearchCommandTests.cs b/Bottomly.Tests/Commands/SearchCommandTests.cs index 3dd9cf5..f28a1b4 100644 --- a/Bottomly.Tests/Commands/SearchCommandTests.cs +++ b/Bottomly.Tests/Commands/SearchCommandTests.cs @@ -13,8 +13,7 @@ public class SearchCommandTests private static readonly IOptions Options = Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { - GoogleApiKey = "fake-key", - GoogleCseId = "fake-cse" + BraveApiKey = "fake-key" }); private static SearchCommand CreateCommand(string responseJson, @@ -49,8 +48,10 @@ public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResultWithTitleAnd { const string json = """ { - "searchInformation": { "totalResults": "1" }, - "items": [{ "title": "DotNet", "link": "https://dotnet.microsoft.com" }] + "type": "search", + "web": { + "results": [{ "title": "DotNet", "url": "https://dotnet.microsoft.com" }] + } } """; @@ -62,9 +63,9 @@ public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResultWithTitleAnd } [Fact] - public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundResult() + public async Task ExecuteAsync_ApiReturnsEmptyResults_ReturnsNoResultsFoundResult() { - const string json = """{ "searchInformation": { "totalResults": "0" } }"""; + const string json = """{ "type": "search", "web": { "results": [] } }"""; var result = await CreateCommand(json).ExecuteAsync("anything"); @@ -72,9 +73,9 @@ public async Task ExecuteAsync_ApiReturnsZeroTotalResults_ReturnsNoResultsFoundR } [Fact] - public async Task ExecuteAsync_ApiReturnsNullItems_ReturnsNoResultsFoundResult() + public async Task ExecuteAsync_ApiReturnsNoWebProperty_ReturnsNoResultsFoundResult() { - const string json = """{ "searchInformation": { "totalResults": "1" } }"""; + const string json = """{ "type": "search" }"""; var result = await CreateCommand(json).ExecuteAsync("anything"); @@ -84,20 +85,12 @@ public async Task ExecuteAsync_ApiReturnsNullItems_ReturnsNoResultsFoundResult() [Fact] public async Task ExecuteAsync_ApiReturnsError_ReturnsSearchApiErrorResult() { - const string errorJson = """ - { - "error": { - "code": 403, - "message": "API key expired", - "errors": [{ "domain": "global", "reason": "forbidden", "message": "API key expired" }] - } - } - """; + const string errorJson = """{ "message": "Invalid subscription token" }"""; - var result = await CreateCommand(errorJson, HttpStatusCode.Forbidden).ExecuteAsync("anything"); + var result = await CreateCommand(errorJson, HttpStatusCode.Unauthorized).ExecuteAsync("anything"); var errorResult = result.ShouldBeOfType(); - errorResult.Error.ShouldBe("API key expired"); + errorResult.Error.ShouldBe("Invalid subscription token"); } [Fact] diff --git a/Bottomly/Commands/Search/ImageSearchCommand.cs b/Bottomly/Commands/Search/ImageSearchCommand.cs index 2791e20..ec7356e 100644 --- a/Bottomly/Commands/Search/ImageSearchCommand.cs +++ b/Bottomly/Commands/Search/ImageSearchCommand.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -9,10 +10,22 @@ public class ImageSearchCommand( IHttpClientFactory httpClientFactory, ILogger logger) : SearchCommandBase(options, httpClientFactory, logger) { - protected override string ExtraQueryParams => "&searchType=image"; + private const string BaseUrl = "https://api.search.brave.com/res/v1/images/search"; - public override string GetPurpose() + 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) { - return "Performs a google image search and returns the top hit."; + 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 index 4a27915..5294767 100644 --- a/Bottomly/Commands/Search/SearchCommand.cs +++ b/Bottomly/Commands/Search/SearchCommand.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Bottomly.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -9,5 +10,22 @@ public class SearchCommand( ILogger logger, IHttpClientFactory httpClientFactory) : SearchCommandBase(options, httpClientFactory, logger) { - public override string GetPurpose() => "Performs a google search and returns the top hit."; + 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 index d6c4d77..9daa3ec 100644 --- a/Bottomly/Commands/Search/SearchCommandBase.cs +++ b/Bottomly/Commands/Search/SearchCommandBase.cs @@ -11,14 +11,13 @@ public abstract class SearchCommandBase( ILogger logger) : ICommand { - private const string BaseUrl = "https://customsearch.googleapis.com/customsearch/v1"; - private readonly string _apiKey = options.Value.GoogleApiKey; - private readonly string _cseId = options.Value.GoogleCseId; - - protected virtual string ExtraQueryParams => string.Empty; + 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(); @@ -26,50 +25,36 @@ public virtual async Task ExecuteAsync(string searchTerm) try { var client = httpClientFactory.CreateClient(); - var url = - $"{BaseUrl}?key={_apiKey}&cx={_cseId}&q={Uri.EscapeDataString(searchTerm)}&num=1{ExtraQueryParams}"; - var response = await client.GetAsync(url); + 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 = ExtractErrorMessage(response); - logger?.LogError("Google search API error {StatusCode}: {Message}", response.StatusCode, errorMessage); + 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); - var root = doc.RootElement; - - if (!TryGetSearchResults(root, out var items)) return new NoResultsFoundResult(); - - var first = items[0]; - var title = first.GetProperty("title").GetString() ?? string.Empty; - var link = first.GetProperty("link").GetString() ?? string.Empty; - return new SearchResult(title, link); + return ExtractFirstResult(doc.RootElement); } catch (Exception e) { - logger?.LogError(e, "Error executing Google search"); + logger?.LogError(e, "Error executing search"); return new SearchApiErrorResult(e.Message); } } - private static bool TryGetSearchResults(JsonElement root, out JsonElement results) - { - results = default; - return !(root.TryGetProperty("searchInformation", out var info) && - info.TryGetProperty("totalResults", out var total) && - total.GetString() == "0") && root.TryGetProperty("items", out results) && - results.GetArrayLength() > 0; - } - - private static string ExtractErrorMessage(HttpResponseMessage response) + private static async Task ExtractErrorMessageAsync(HttpResponseMessage response) { try { - using var doc = JsonDocument.Parse(response.Content.ReadAsStringAsync().Result); - if (doc.RootElement.TryGetProperty("error", out var error) && - error.TryGetProperty("message", out var msg)) + 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) 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 From 7948ff0d3173a6e96b995f59064c4a263ed0ebe3 Mon Sep 17 00:00:00 2001 From: "Owen.Morgan-Jones" Date: Wed, 18 Mar 2026 15:53:59 +0000 Subject: [PATCH 12/12] Updates workflows to include new Brave API key --- .github/workflows/dotnet.yml | 3 +-- .github/workflows/push_to_live.yml | 11 +++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 9315075..1fd89ff 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -30,8 +30,7 @@ jobs: - name: Test run: dotnet test bottomly.net.slnx --no-build --configuration Release --verbosity normal env: - BOTTOMLY_GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} - BOTTOMLY_GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }} + 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 }}