Skip to content

EES-5874 Create Search Function App #5630

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

Merged
merged 2 commits into from
Mar 4, 2025
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
16 changes: 16 additions & 0 deletions azure-pipelines-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,22 @@ jobs:
artifactName: processor
targetPath: $(Build.ArtifactStagingDirectory)/processor

- task: DotNetCoreCLI@2
displayName: Package Search Function App
inputs:
command: publish
publishWebProjects: false
projects: '**/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.csproj'
arguments: --configuration $(BuildConfiguration) --output $(Build.ArtifactStagingDirectory)/searchFunctionApp
zipAfterPublish: True

- task: PublishPipelineArtifact@1
displayName: Publish Search Function App artifact
condition: and(succeeded(), eq(variables.IsBranchDeployable, true))
inputs:
artifactName: searchFunctionApp
targetPath: $(Build.ArtifactStagingDirectory)/searchFunctionApp

- job: Admin
pool: ees-ubuntu2204-xlarge
workspace:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -350,12 +350,12 @@ await TestApp.AddTestData<ContentDbContext>(

var latestPublishedReleaseVersion = oldRelease.Versions[1];

var oldReleaseCachedViewModel = new ReleaseCacheViewModel(id: latestPublishedReleaseVersion.Id);
var oldReleaseCachedViewModel = new ReleaseCacheViewModel(latestPublishedReleaseVersion.Id);
var oldReleaseCacheKey = new ReleaseCacheKey(
publicationSlug: publication.Slug,
releaseSlug: oldRelease.Slug);

var oldLatestReleaseCachedViewModel = new ReleaseCacheViewModel(id: latestPublishedReleaseVersion.Id);
var oldLatestReleaseCachedViewModel = new ReleaseCacheViewModel(latestPublishedReleaseVersion.Id);
var oldLatestReleaseCacheKey = new ReleaseCacheKey(
publicationSlug: publication.Slug);

Expand Down Expand Up @@ -484,12 +484,12 @@ await TestApp.AddTestData<ContentDbContext>(

var latestPublishedReleaseVersion = oldRelease.Versions[1];

var oldReleaseCachedViewModel = new ReleaseCacheViewModel(id: latestPublishedReleaseVersion.Id);
var oldReleaseCachedViewModel = new ReleaseCacheViewModel(latestPublishedReleaseVersion.Id);
var oldReleaseCacheKey = new ReleaseCacheKey(
publicationSlug: publication.Slug,
releaseSlug: oldRelease.Slug);

var oldLatestReleaseCachedViewModel = new ReleaseCacheViewModel(id: latestPublishedReleaseVersion.Id);
var oldLatestReleaseCachedViewModel = new ReleaseCacheViewModel(latestPublishedReleaseVersion.Id);
var oldLatestReleaseCacheKey = new ReleaseCacheKey(
publicationSlug: publication.Slug);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ private Task<Either<ActionResult, ReleaseViewModel>> GetReleaseViewModel(
);
});

private Task<Either<ActionResult, ReleaseSearchViewModel>> GetLatestReleaseSearchViewModel(
string publicationSlug) =>
private Task<Either<ActionResult, ReleaseSearchViewModel>> GetLatestReleaseSearchViewModel(string publicationSlug) =>
_publicationCacheService.GetPublication(publicationSlug)
.OnSuccessCombineWith(_ => _releaseCacheService.GetRelease(publicationSlug))
.OnSuccess(tuple =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients.AzureBlobStorage;
using Moq;
using Blob = GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients.AzureBlobStorage.Blob;

namespace GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Tests.Builders;

internal class AzureBlobStorageClientBuilder
{
private readonly Mock<IAzureBlobStorageClient> _mock = new(MockBehavior.Strict);

public AzureBlobStorageClientBuilder()
{
Assert = new(_mock);

_mock.Setup(mock => mock.UploadBlob(
It.IsAny<string>(),
It.IsAny<string>(),
It.IsAny<Blob>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
}

public IAzureBlobStorageClient Build() => _mock.Object;

public Asserter Assert { get; }
public class Asserter(Mock<IAzureBlobStorageClient> mock)
{
public void BlobWasUploaded(
string? containerName = null,
string? blobName = null,
Func<Blob, bool>? whereBlob = null)
{
mock.Verify(m => m.UploadBlob(
It.Is<string>(actualContainerName => containerName == null || actualContainerName == containerName),
It.Is<string>(actualBlobName => blobName == null || actualBlobName == blobName),
It.Is<Blob>(blob => whereBlob == null || whereBlob(blob)),
It.IsAny<CancellationToken>()), Times.Once);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients.ContentApi;
using Moq;

namespace GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Tests.Builders;

internal class ContentApiClientBuilder
{
private readonly Mock<IContentApiClient> _mock = new Mock<IContentApiClient>(MockBehavior.Strict);
private readonly ReleaseSearchViewModelBuilder _releaseSearchViewModelBuilder = new();
private ReleaseSearchViewModelDto? _releaseSearchViewModel;

public ContentApiClientBuilder()
{
Assert = new Asserter(_mock);

_mock
.Setup(m => m.GetPublicationLatestReleaseSearchViewModelAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(_releaseSearchViewModel ?? _releaseSearchViewModelBuilder.Build());
}

public IContentApiClient Build()
{
return _mock.Object;
}

public ContentApiClientBuilder WhereReleaseSearchViewModelIs(ReleaseSearchViewModelDto releaseSearchViewModel)
{
_releaseSearchViewModel = releaseSearchViewModel;
return this;
}

public ContentApiClientBuilder WhereReleaseSearchViewModelIs(Func<ReleaseSearchViewModelBuilder, ReleaseSearchViewModelBuilder> modifyReleaseSearchViewModel)
{
modifyReleaseSearchViewModel(_releaseSearchViewModelBuilder);
return this;
}

public Asserter Assert { get; }
public class Asserter(Mock<IContentApiClient> mock)
{
public void ContentWasLoadedFor(string publicationSlug)
{
mock.Verify(m => m.GetPublicationLatestReleaseSearchViewModelAsync(publicationSlug, It.IsAny<CancellationToken>()), Times.Once);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients.ContentApi;

namespace GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Tests.Builders;

public class ReleaseSearchViewModelBuilder
{
private string? _summary;
private string? _title;
private Guid? _releaseVersionId;
private Guid? _releaseId;

public ReleaseSearchViewModelDto Build() => new()
{
ReleaseId = _releaseId ?? new Guid("11223344-5566-7788-9900-123456789abc"),
ReleaseVersionId = _releaseVersionId ?? new Guid("12345678-1234-1234-1234-123456789abc"),
Published = new DateTimeOffset(2025, 02, 21, 09, 24, 01, TimeSpan.Zero),
PublicationTitle = _title ?? "Publication Title",
Summary = _summary ?? "This is a summary.",
Theme = "Theme",
Type = "Official Statistics",
TypeBoost = 10,
PublicationSlug = "publication-slug",
ReleaseSlug = "release-slug",
HtmlContent = "<p>This is some Html Content</p>",
};

public ReleaseSearchViewModelBuilder WithSummary(string summary)
{
_summary = summary;
return this;
}

public ReleaseSearchViewModelBuilder WithTitle(string title)
{
_title = title;
return this;
}

public ReleaseSearchViewModelBuilder WithReleaseVersionId(Guid releaseVersionId)
{
_releaseVersionId = releaseVersionId;
return this;
}

public ReleaseSearchViewModelBuilder WithReleaseId(Guid releaseId)
{
_releaseId = releaseId;
return this;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using Azure.Storage.Blobs;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients.AzureBlobStorage;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Exceptions;
using Blob = GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients.AzureBlobStorage.Blob;

namespace GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Tests.Clients;

public class AzureBlobStorageClientTests
{
private AzureBlobStorageClient GetSut(string connectionString)
{
var blobServiceClient = new BlobServiceClient(connectionString);
return new AzureBlobStorageClient(blobServiceClient);
}

public class IntegrationTests
{
/// <summary>
/// Integration Tests.
/// In order to run these:
/// - set StorageAccountName to the name of the Storage Account in Azure
/// - set StorageAccountAccessKey to one of its Access keys (found under Security + networking)
/// - unskip the test
/// </summary>
public class HiveITAzureAccount : AzureBlobStorageClientTests
{
private const string StorageAccountName = "-- azure storage account name here --";
private const string StorageAccountAccessKey = "-- azure storage account access key here --";

private const string IntegrationTestStorageAccountConnectionString = $"AccountName={StorageAccountName};AccountKey={StorageAccountAccessKey};";
private const string IntegrationTestContainerName = "integration-tests";

private AzureBlobStorageClient GetSut() => base.GetSut(IntegrationTestStorageAccountConnectionString);

[Fact(Skip = "This integration test creates a blob in an Azure Storage Account and retrieves it again.")]
public async Task CanUploadBlob()
{
// ARRANGE
var uniqueBlobName = Guid.NewGuid().ToString();
var sut = GetSut();
var blob = new Blob("This is a test", new Dictionary<string, string>
{
{"key1", "value1"},
{"key2", "value2"},
{"timestamp", DateTimeOffset.Now.ToString("u")}
});

// ACT
await sut.UploadBlob(IntegrationTestContainerName, uniqueBlobName, blob);

// ASSERT
var actual = await AzureBlobStorageIntegrationHelper.DownloadAsync(sut.BlobServiceClient, IntegrationTestContainerName, uniqueBlobName);
Assert.Equal(blob, actual);
await AzureBlobStorageIntegrationHelper.DeleteAsync(sut.BlobServiceClient, IntegrationTestContainerName, uniqueBlobName);
}

[Fact(Skip = "This integration test gets a non-existent blob from Azure Storage Account.")]
public async Task DownloadBlob_WhenBlobDoesNotExist_ThenThrows()
{
// ARRANGE
var uniqueBlobName = Guid.NewGuid().ToString();
var sut = GetSut();

// ACT
var actual = await Record.ExceptionAsync(() => AzureBlobStorageIntegrationHelper.DownloadAsync(sut.BlobServiceClient, IntegrationTestContainerName, uniqueBlobName));

// ASSERT
Assert.NotNull(actual);
var azureBlobStorageNotFoundException = Assert.IsType<AzureBlobStorageNotFoundException>(actual);
Assert.Equal(uniqueBlobName, azureBlobStorageNotFoundException.BlobName);
Assert.Equal(IntegrationTestContainerName, azureBlobStorageNotFoundException.ContainerName);
}

[Fact(Skip = "This integration test deletes a non-existent blob from Azure Storage Account.")]
public async Task DeleteBlob_WhenBlobDoesNotExist_ThenDoesNotThrow()
{
// ARRANGE
var uniqueBlobName = Guid.NewGuid().ToString();
var sut = GetSut();

// ACT
await AzureBlobStorageIntegrationHelper.DeleteAsync(sut.BlobServiceClient, IntegrationTestContainerName, uniqueBlobName);
}
}
}
}

public class AzureBlobStorageNotFoundException : AzureBlobStorageException
{
public AzureBlobStorageNotFoundException(string containerName, string blobName) : base(containerName, blobName, "Not found") { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Clients.AzureBlobStorage;
using GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Exceptions;

namespace GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Tests.Clients;

public class AzureBlobStorageIntegrationHelper
{
public static async Task<Blob> DownloadAsync(BlobServiceClient blobServiceClient, string containerName, string blobName, CancellationToken cancellationToken = default)
{
var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = blobContainerClient.GetBlobClient(blobName);

var existsResponse = await blobClient.ExistsAsync(cancellationToken);
if (existsResponse.Value == false)
{
throw new AzureBlobStorageNotFoundException(containerName, blobName);
}

Response<BlobDownloadInfo>? response;
try
{
response = await blobClient.DownloadAsync(cancellationToken);
if (!response.HasValue)
{
throw new AzureBlobStorageException(containerName, blobName, $"Response was empty.");
}
}
catch (Exception e)
{
throw new AzureBlobStorageException(containerName, blobName, e.Message);
}

using var streamReader = new StreamReader(response.Value.Content);
var content = await streamReader.ReadToEndAsync(cancellationToken);
var metadata = response.Value.Details.Metadata;
return new Blob(content, metadata);
}

public static async Task DeleteAsync(BlobServiceClient blobServiceClient, string containerName, string blobName, CancellationToken cancellationToken = default)
{
var blobContainerClient = blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = blobContainerClient.GetBlobClient(blobName);
var existsResponse = await blobClient.ExistsAsync(cancellationToken);
if (existsResponse.Value == false)
{
// If the blob is not found, treat that as success
return;
}

try
{
var response = await blobClient.DeleteAsync(DeleteSnapshotsOption.IncludeSnapshots, cancellationToken: cancellationToken);
if (response.IsError)
{
throw new Exception($"Blob \"{blobName}\" could not be deleted from container \"{containerName}\". {response.ReasonPhrase}({response.Status})");
}
}
catch (Exception e)
{
throw new Exception($"Blob \"{blobName}\" could not be deleted from container \"{containerName}\". {e.Message}");
}
}
}


Loading