Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the search service #351

Merged
merged 4 commits into from
Dec 18, 2023
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using Childrens_Social_Care_CPD;
using Childrens_Social_Care_CPD.Configuration;
using System.Threading;
using Microsoft.Extensions.Configuration;

namespace Childrens_Social_Care_CPD_Tests.Controllers;

Expand Down Expand Up @@ -63,7 +64,7 @@ public void SetUp()

_contentfulClient = Substitute.For<ICpdContentfulClient>();

_cookieController = new CookieController(_contentfulClient, new CookieHelper(new ApplicationConfiguration()))
_cookieController = new CookieController(_contentfulClient, new CookieHelper(new ApplicationConfiguration(Substitute.For<IConfiguration>())))
{
ControllerContext = controllerContext,
TempData = Substitute.For<ITempDataDictionary>()
Expand Down
3 changes: 3 additions & 0 deletions Childrens-Social-Care-CPD-Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
global using FluentAssertions;
global using NSubstitute;
global using NUnit.Framework;
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ public class MockApplicationConfiguration : IApplicationConfiguration
public string _featurePollingInterval = null;
public string _gitHash = null;
public string _googleTagManagerKey = null;
public string _searchApiKey = null;
public string _searchEndpoint = null;
public string _searchIndexName = null;

public IConfigurationSetting<string> AppInsightsConnectionString => new StringConfigSetting(() => _appInsightsConnectionString);
public IConfigurationSetting<string> AppVersion => new StringConfigSetting(() => _appVersion);
Expand All @@ -33,6 +36,9 @@ public class MockApplicationConfiguration : IApplicationConfiguration
public IConfigurationSetting<int> FeaturePollingInterval => new IntegerConfigSetting(() => _featurePollingInterval);
public IConfigurationSetting<string> GitHash => new StringConfigSetting(() => _gitHash);
public IConfigurationSetting<string> GoogleTagManagerKey => new StringConfigSetting(() => _googleTagManagerKey);
public IConfigurationSetting<string> SearchApiKey => new StringConfigSetting(() => _searchApiKey);
public IConfigurationSetting<string> SearchEndpoint => new StringConfigSetting(() => _searchEndpoint);
public IConfigurationSetting<string> SearchIndexName => new StringConfigSetting(() => _searchIndexName);

public void SetAllValid(string value = "foo")
{
Expand All @@ -50,5 +56,8 @@ public void SetAllValid(string value = "foo")
_featurePollingInterval = "0";
_gitHash = value;
_googleTagManagerKey = value;
_searchApiKey = value;
_searchEndpoint = value;
_searchIndexName = value;
}
}
229 changes: 229 additions & 0 deletions Childrens-Social-Care-CPD-Tests/Services/SearchServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
using Azure;
using Azure.Search.Documents;
using Azure.Search.Documents.Models;
using Childrens_Social_Care_CPD.Search;
using Childrens_Social_Care_CPD.Services;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace Childrens_Social_Care_CPD_Tests.Services;

public class SearchServiceTests
{
private SearchClient _searchClient;
private SearchService _sut;

[SetUp]
public void Setup()
{
_searchClient = Substitute.For<SearchClient>();
_sut = new SearchService(_searchClient);
}

[Test]
public async Task SearchResourcesAsync_Ignores_Empty_Filter()
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo");
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.Filter.Should().BeNull();
}

[Test]
public async Task SearchResourcesAsync_Highlights_The_Body_Field()
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo");
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.HighlightFields.Should().NotBeNull();
options.HighlightFields.Single().Should().Be("Body");
}

[Test]
public async Task SearchResourcesAsync_Returns_Facets()
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo");
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.Facets.Should().NotBeNull();
options.Facets.Single().Should().Be("Tags,count:100");
}

[Test]
public async Task SearchResourcesAsync_Default_Ordering_Is_By_Relevancy_Descending()
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo");
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.OrderBy.Should().NotBeNull();
options.OrderBy.Single().Should().Be("search.score() desc");
}

[Test]
public async Task SearchResourcesAsync_Uses_Simple_Query_Parser()
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo");
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.QueryType.Should().Be(SearchQueryType.Simple);
}

[Test]
public async Task SearchResourcesAsync_Turns_Search_Term_Into_Prefix_Query()
{
// arrange
var searchTerm = string.Empty;
var query = new KeywordSearchQuery("foo");
_searchClient.SearchAsync<CpdDocument>(Arg.Do<string>(x => searchTerm = x), Arg.Any<SearchOptions>()).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
searchTerm.Should().Be("foo*");
}

[Test]
public async Task SearchResourcesAsync_Requests_Total_Result_Count()
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo");
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.IncludeTotalCount.Should().Be(true);
}

[TestCase(-1, 0)]
[TestCase(0, 0)]
[TestCase(1, 0)]
[TestCase(2, 8)]
[TestCase(3, 16)]
[TestCase(99, 784)]
public async Task SearchResourcesAsync_Skips_By_PageSize(int page, int skip)
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery(string.Empty)
{
Page = page
};
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.Skip.Should().Be(skip);
}

[TestCase(-1, 8)]
[TestCase(0, 8)]
[TestCase(1, 8)]
[TestCase(7, 8)]
[TestCase(8, 8)]
[TestCase(9, 9)]
[TestCase(99, 99)]
public async Task SearchResourcesAsync_Uses_Correct_Page_Size(int requestedPageSize, int actualPageSize)
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo")
{
PageSize = requestedPageSize
};
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.Size.Should().Be(actualPageSize);
}

[Test]
public async Task SearchResourcesAsync_Filters_Should_Be_Logically_ANDed()
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo")
{
Filter = new Dictionary<string, IEnumerable<string>>()
{
{
"tag", new List<string>
{
"filter1",
"filter2",
}
}
}
};
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.Filter.Should().Be("tag/any(v: v eq 'filter1') and tag/any(v: v eq 'filter2')");
}

[TestCase(SortCategory.Created, SortDirection.Ascending, "CreatedAt asc")]
[TestCase(SortCategory.Created, SortDirection.Descending, "CreatedAt desc")]
[TestCase(SortCategory.Updated, SortDirection.Ascending, "UpdatedAt asc")]
[TestCase(SortCategory.Updated, SortDirection.Descending, "UpdatedAt desc")]
[TestCase(SortCategory.Relevancy, SortDirection.Ascending, "search.score() asc")]
[TestCase(SortCategory.Relevancy, SortDirection.Descending, "search.score() desc")]
public async Task SearchResourcesAsync_Constructs_OrderBy_Correctly(SortCategory sortCategory, SortDirection sortDirection, string expectedOrderBy)
{
// arrange
SearchOptions options = null;
var query = new KeywordSearchQuery("foo")
{
SortCategory = sortCategory,
SortDirection = sortDirection,
};
_searchClient.SearchAsync<CpdDocument>(Arg.Any<string>(), Arg.Do<SearchOptions>(x => options = x)).Returns(Substitute.For<Response<SearchResults<CpdDocument>>>());

// act
await _sut.SearchResourcesAsync(query);

// assert
options.OrderBy.Single().Should().Be(expectedOrderBy);
}
}
1 change: 1 addition & 0 deletions Childrens-Social-Care-CPD/Childrens-Social-Care-CPD.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Search.Documents" Version="11.5.1" />
<PackageReference Include="contentful.aspnetcore" Version="7.3.0" />
<PackageReference Include="GraphQL.Client" Version="6.0.1" />
<PackageReference Include="GraphQL.Client.Serializer.SystemTextJson" Version="6.0.1" />
Expand Down
66 changes: 35 additions & 31 deletions Childrens-Social-Care-CPD/Configuration/ApplicationConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,42 @@

public class ApplicationConfiguration : IApplicationConfiguration
{
private readonly IConfigurationSetting<string> _appInsightsConnectionString = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_INSTRUMENTATION_CONNECTIONSTRING"));
private readonly IConfigurationSetting<string> _appVersion = new StringConfigSetting(() => Environment.GetEnvironmentVariable("VCS-TAG"));
private readonly IConfigurationSetting<string> _azureEnvironment = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_AZURE_ENVIRONMENT"));
private readonly IConfigurationSetting<string> _clarityProjectId = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_CLARITY"));
private readonly IConfigurationSetting<string> _contentfulDeliveryApiKey = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_DELIVERY_KEY"));
private readonly IConfigurationSetting<string> _contentfulEnvironment = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_CONTENTFUL_ENVIRONMENT"));
private readonly IConfigurationSetting<string> _contentfulGraphqlConnectionString;
private readonly IConfigurationSetting<string> _contentfulPreviewHost = new StringConfigSetting(() => "preview.contentful.com");
private readonly IConfigurationSetting<string> _contentfulPreviewId = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_PREVIEW_KEY"));
private readonly IConfigurationSetting<string> _contentfulSpaceId = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_SPACE_ID"));
private readonly IConfigurationSetting<bool> _disableSecureCookies = new BooleanConfigSetting(() => Environment.GetEnvironmentVariable("CPD_DISABLE_SECURE_COOKIES"), false);
private readonly IConfigurationSetting<int> _featurePollingInterval = new IntegerConfigSetting(() => Environment.GetEnvironmentVariable("CPD_FEATURE_POLLING_INTERVAL"), 0);
private readonly IConfigurationSetting<string> _gitHash = new StringConfigSetting(() => Environment.GetEnvironmentVariable("VCS-REF"));
private readonly IConfigurationSetting<string> _googleTagManagerKey = new StringConfigSetting(() => Environment.GetEnvironmentVariable("CPD_GOOGLEANALYTICSTAG"));

public ApplicationConfiguration()
public ApplicationConfiguration(IConfiguration configuration)
{
_contentfulGraphqlConnectionString = new StringConfigSetting(() => $"https://graphql.contentful.com/content/v1/spaces/{ContentfulSpaceId.Value}/environments/{ContentfulEnvironment.Value}");
AppInsightsConnectionString = new StringConfigSetting(() => configuration["CPD_INSTRUMENTATION_CONNECTIONSTRING"]);
AppVersion = new StringConfigSetting(() => configuration["VCS-TAG"]);
AzureEnvironment = new StringConfigSetting(() => configuration["CPD_AZURE_ENVIRONMENT"]);
ClarityProjectId = new StringConfigSetting(() => configuration["CPD_CLARITY"]);
ContentfulDeliveryApiKey = new StringConfigSetting(() => configuration["CPD_DELIVERY_KEY"]);
ContentfulEnvironment = new StringConfigSetting(() => configuration["CPD_CONTENTFUL_ENVIRONMENT"]);
ContentfulGraphqlConnectionString = new StringConfigSetting(() => $"https://graphql.contentful.com/content/v1/spaces/{ContentfulSpaceId.Value}/environments/{ContentfulEnvironment.Value}");
ContentfulPreviewHost = new StringConfigSetting(() => "preview.contentful.com");
ContentfulPreviewId = new StringConfigSetting(() => configuration["CPD_PREVIEW_KEY"]);
ContentfulSpaceId = new StringConfigSetting(() => configuration["CPD_SPACE_ID"]);
DisableSecureCookies = new BooleanConfigSetting(() => configuration["CPD_DISABLE_SECURE_COOKIES"], false);
FeaturePollingInterval = new IntegerConfigSetting(() => configuration["CPD_FEATURE_POLLING_INTERVAL"], 0);
GitHash = new StringConfigSetting(() => configuration["VCS-REF"]);
GoogleTagManagerKey = new StringConfigSetting(() => configuration["CPD_GOOGLEANALYTICSTAG"]);
SearchApiKey = new StringConfigSetting(() => configuration["CPD_SEARCH_API_KEY"]);
SearchEndpoint = new StringConfigSetting(() => configuration["CPD_SEARCH_END_POINT"]);
SearchIndexName = new StringConfigSetting(() => configuration["CPD_SEARCH_INDEX_NAME"]);
}

public IConfigurationSetting<string> AppInsightsConnectionString => _appInsightsConnectionString;
public IConfigurationSetting<string> AppVersion => _appVersion;
public IConfigurationSetting<string> AzureEnvironment => _azureEnvironment;
public IConfigurationSetting<string> ClarityProjectId => _clarityProjectId;
public IConfigurationSetting<string> ContentfulDeliveryApiKey => _contentfulDeliveryApiKey;
public IConfigurationSetting<string> ContentfulEnvironment => _contentfulEnvironment;
public IConfigurationSetting<string> ContentfulGraphqlConnectionString => _contentfulGraphqlConnectionString;
public IConfigurationSetting<string> ContentfulPreviewHost => _contentfulPreviewHost;
public IConfigurationSetting<string> ContentfulPreviewId => _contentfulPreviewId;
public IConfigurationSetting<string> ContentfulSpaceId => _contentfulSpaceId;
public IConfigurationSetting<bool> DisableSecureCookies => _disableSecureCookies;
public IConfigurationSetting<int> FeaturePollingInterval => _featurePollingInterval;
public IConfigurationSetting<string> GitHash => _gitHash;
public IConfigurationSetting<string> GoogleTagManagerKey => _googleTagManagerKey;
public IConfigurationSetting<string> AppInsightsConnectionString { get; init; }
public IConfigurationSetting<string> AppVersion { get; init; }
public IConfigurationSetting<string> AzureEnvironment { get; init; }
public IConfigurationSetting<string> ClarityProjectId { get; init; }
public IConfigurationSetting<string> ContentfulDeliveryApiKey { get; init; }
public IConfigurationSetting<string> ContentfulEnvironment { get; init; }
public IConfigurationSetting<string> ContentfulGraphqlConnectionString { get; init; }
public IConfigurationSetting<string> ContentfulPreviewHost { get; init; }
public IConfigurationSetting<string> ContentfulPreviewId { get; init; }
public IConfigurationSetting<string> ContentfulSpaceId { get; init; }
public IConfigurationSetting<bool> DisableSecureCookies { get; init; }
public IConfigurationSetting<int> FeaturePollingInterval { get; init; }
public IConfigurationSetting<string> GitHash { get; init; }
public IConfigurationSetting<string> GoogleTagManagerKey { get; init; }
public IConfigurationSetting<string> SearchApiKey { get; init; }
public IConfigurationSetting<string> SearchEndpoint { get; init; }
public IConfigurationSetting<string> SearchIndexName { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,14 @@ public interface IApplicationConfiguration

[RequiredForEnvironment(ApplicationEnvironment.All, Hidden = false)]
IConfigurationSetting<string> GoogleTagManagerKey { get; }

[RequiredForEnvironment(ApplicationEnvironment.All, Hidden = false)]
IConfigurationSetting<string> SearchApiKey { get; }

[RequiredForEnvironment(ApplicationEnvironment.All, Hidden = false)]
IConfigurationSetting<string> SearchEndpoint { get; }

[RequiredForEnvironment(ApplicationEnvironment.All, Hidden = false, Obfuscate = false)]
IConfigurationSetting<string> SearchIndexName { get; }

}
12 changes: 12 additions & 0 deletions Childrens-Social-Care-CPD/Search/CpdDocument.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
namespace Childrens_Social_Care_CPD.Search;

public partial class CpdDocument
{
public string Id { get; set; }
public string Title { get; set; }
public string ContentType { get; set; }
public string Body { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public IEnumerable<string> Tags { get; set; }
}
Loading
Loading