Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: .NET Build and Test

on:
push:
branches: ["main"]
branches: [ "main" ]
pull_request:
branches: ["main"]
branches: [ "main" ]

permissions:
packages: write
Expand All @@ -29,6 +29,8 @@ jobs:

- name: Test
run: dotnet test bottomly.net.slnx --no-build --configuration Release --verbosity normal
env:
BOTTOMLY_BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }}

- name: Build Docker image
run: docker build -t bottomly .
Expand Down
11 changes: 5 additions & 6 deletions .github/workflows/push_to_live.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions Bottomly.Tests/Bottomly.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Meziantou.Extensions.Logging.Xunit" Version="1.0.25" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.5" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" />
Expand Down
20 changes: 0 additions & 20 deletions Bottomly.Tests/Commands/GoogleImageSearchCommandTests.cs

This file was deleted.

20 changes: 0 additions & 20 deletions Bottomly.Tests/Commands/GoogleSearchCommandTests.cs

This file was deleted.

74 changes: 74 additions & 0 deletions Bottomly.Tests/Commands/ImageSearchCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
using System.Net;
using Bottomly.Commands.Search;
using Bottomly.Configuration;
using Bottomly.Tests.Helpers;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Shouldly;

namespace Bottomly.Tests.Commands;

public class ImageSearchCommandTests
{
private static readonly IOptions<BottomlyOptions> Options =
Microsoft.Extensions.Options.Options.Create(new BottomlyOptions { BraveApiKey = "fake-key" });

private static ImageSearchCommand CreateCommand(string responseJson,
HttpStatusCode statusCode = HttpStatusCode.OK)
{
return new ImageSearchCommand(Options, TestHelpers.CreateHttpClientFactory(responseJson, statusCode),
NullLogger<ImageSearchCommand>.Instance);
}

[Fact]
public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult()
{
var command = new ImageSearchCommand(Options, new Mock<IHttpClientFactory>().Object,
NullLogger<ImageSearchCommand>.Instance);

var result = await command.ExecuteAsync("");

result.ShouldBeOfType<EmptySearchTermErrorResult>();
}

[Fact]
public async Task ExecuteAsync_ApiReturnsResults_ReturnsSearchResult()
{
const string json = """
{
"type": "images",
"results": [
{ "title": "A cat", "properties": { "url": "https://example.com/cat.jpg" } }
]
}
""";

var result = await CreateCommand(json).ExecuteAsync("cat");

var searchResult = result.ShouldBeOfType<SearchResult>();
searchResult.Title.ShouldBe("A cat");
searchResult.Link.ShouldBe("https://example.com/cat.jpg");
}

[Fact]
public async Task ExecuteAsync_ApiReturnsEmptyResults_ReturnsNoResultsFoundResult()
{
const string json = """{ "type": "images", "results": [] }""";

var result = await CreateCommand(json).ExecuteAsync("nothing");

result.ShouldBeOfType<NoResultsFoundResult>();
}

[Fact]
public async Task ExecuteAsync_ApiReturnsError_ReturnsSearchApiErrorResult()
{
const string errorJson = """{ "message": "Invalid subscription token" }""";

var result = await CreateCommand(errorJson, HttpStatusCode.Unauthorized).ExecuteAsync("something");

var errorResult = result.ShouldBeOfType<SearchApiErrorResult>();
errorResult.Error.ShouldBe("Invalid subscription token");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using Bottomly.Commands.Search;
using Bottomly.Configuration;
using Meziantou.Extensions.Logging.Xunit;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit.Abstractions;

namespace Bottomly.Tests.Commands.Integration;

/// <summary>
/// Integration tests that call the real Brave Search API with image search.
/// Credentials are resolved from the standard .NET configuration stack:
/// 1. User secrets stored against the main Bottomly app project (local dev —
/// run `dotnet user-secrets set "bottomly_brave_api_key" "..." --project Bottomly`)
/// 2. Environment variable BOTTOMLY_BRAVE_API_KEY
/// (CI — injected from GitHub repository secrets via the workflow env block)
/// Tests no-op silently when credentials are absent, so the suite stays green
/// for contributors without keys. When credentials are present but expired or
/// invalid the tests will fail, which is exactly the failure mode they exist to expose.
/// </summary>
public class ImageSearchCommandIntegrationTests
{
private static readonly IConfiguration Configuration = new ConfigurationBuilder()
.AddUserSecrets<SearchCommand>()
.AddEnvironmentVariables()
.Build();

private readonly ILogger<ImageSearchCommand> _logger;

public ImageSearchCommandIntegrationTests(ITestOutputHelper outputHelper)
{
_logger = XUnitLogger.CreateLogger<ImageSearchCommand>(outputHelper);
}

private static string? ApiKey => Configuration["bottomly_brave_api_key"];

private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey);

private ImageSearchCommand CreateCommand()
{
var factory = new DefaultHttpClientFactory();
return new ImageSearchCommand(Options.Create(new BottomlyOptions
{
BraveApiKey = ApiKey!
}), factory, _logger);
}

private sealed class DefaultHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}

[Fact]
public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult()
{
if (!CredentialsAvailable) return;

var result = await CreateCommand().ExecuteAsync("");

result.ShouldBeOfType<EmptySearchTermErrorResult>();
}

[Fact]
public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithLink()
{
if (!CredentialsAvailable) return;

var result = await CreateCommand().ExecuteAsync("GitHub");

result.ShouldBeOfType<SearchResult>();
var searchResult = (SearchResult)result;
searchResult.Title.ShouldNotBeNullOrEmpty();
searchResult.Link.ShouldNotBeNullOrEmpty();
searchResult.Link.ShouldStartWith("http");
}

[Fact]
public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult()
{
if (!CredentialsAvailable) return;

var result = await CreateCommand().ExecuteAsync("Wikipedia logo");

result.ShouldBeOfType<SearchResult>();
var searchResult = (SearchResult)result;
searchResult.Link.ShouldNotBeNullOrEmpty();
searchResult.Link.ShouldStartWith("http");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
using Bottomly.Commands.Search;
using Bottomly.Configuration;
using Meziantou.Extensions.Logging.Xunit;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit.Abstractions;

namespace Bottomly.Tests.Commands.Integration;

/// <summary>
/// Integration tests that call the real Brave Search API.
/// Credentials are resolved from the standard .NET configuration stack:
/// 1. User secrets stored against the main Bottomly app project (local dev —
/// run `dotnet user-secrets set "bottomly_brave_api_key" "..." --project Bottomly`)
/// 2. Environment variable BOTTOMLY_BRAVE_API_KEY
/// (CI — injected from GitHub repository secrets via the workflow env block)
/// Tests no-op silently when credentials are absent, so the suite stays green
/// for contributors without keys. When credentials are present but expired or
/// invalid the tests will fail, which is exactly the failure mode they exist to expose.
/// </summary>
public class SearchCommandIntegrationTests
{
private static readonly IConfiguration Configuration = new ConfigurationBuilder()
.AddUserSecrets<SearchCommand>()
.AddEnvironmentVariables()
.Build();

private readonly ILogger<SearchCommand> _logger;

public SearchCommandIntegrationTests(ITestOutputHelper outputHelper)
{
_logger = XUnitLogger.CreateLogger<SearchCommand>(outputHelper);
}

private static string? ApiKey => Configuration["bottomly_brave_api_key"];

private static bool CredentialsAvailable => !string.IsNullOrWhiteSpace(ApiKey);

private SearchCommand CreateCommand()
{
var factory = new DefaultHttpClientFactory();
return new SearchCommand(Options.Create(new BottomlyOptions
{
BraveApiKey = ApiKey!
}), _logger, factory);
}

private sealed class DefaultHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new();
}

[Fact]
public async Task ExecuteAsync_EmptyInput_ReturnsEmptySearchTermErrorResult()
{
if (!CredentialsAvailable) return; // credentials not configured — skip

var result = await CreateCommand().ExecuteAsync("");

result.ShouldBeOfType<EmptySearchTermErrorResult>();
}

[Fact]
public async Task ExecuteAsync_KnownSearchTerm_ReturnsResultWithLink()
{
if (!CredentialsAvailable) return; // credentials not configured — skip

var result = await CreateCommand().ExecuteAsync("GitHub");

result.ShouldBeOfType<SearchResult>();
var searchResult = (SearchResult)result;
searchResult.Title.ShouldNotBeNullOrEmpty();
searchResult.Link.ShouldNotBeNullOrEmpty();
searchResult.Link.ShouldStartWith("http");
}

[Fact]
public async Task ExecuteAsync_KnownSearchTerm_ReturnsRelevantResult()
{
if (!CredentialsAvailable) return; // credentials not configured — skip

var result = await CreateCommand().ExecuteAsync("Wikipedia");

result.ShouldBeOfType<SearchResult>();
var searchResult = (SearchResult)result;
searchResult.Link.ShouldContain("wikipedia");
}
}
Loading
Loading