Skip to content
Permalink

Comparing changes

This is a direct comparison between two commits made in this repository or its related repositories. View the default comparison for this range or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: dfe-analytical-services/explore-education-statistics
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 66fa645ff2748506710e0c8db8c294c4f668d5e6
Choose a base ref
..
head repository: dfe-analytical-services/explore-education-statistics
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 099d538499ce1ea08e778e7953301094c6bcacfb
Choose a head ref
Showing with 1,529 additions and 27 deletions.
  1. +16 −0 azure-pipelines-main.yml
  2. +4 −4 ...GovUk.Education.ExploreEducationStatistics.Admin.Tests/Controllers/Api/ReleasesControllerTests.cs
  3. +1 −2 src/GovUk.Education.ExploreEducationStatistics.Content.Api/Controllers/ReleaseController.cs
  4. +41 −0 ...oreEducationStatistics.Content.Search.FunctionApp.Tests/Builders/AzureBlobStorageClientBuilder.cs
  5. +47 −0 ...n.ExploreEducationStatistics.Content.Search.FunctionApp.Tests/Builders/ContentApiClientBuilder.cs
  6. +51 −0 ...oreEducationStatistics.Content.Search.FunctionApp.Tests/Builders/ReleaseSearchViewModelBuilder.cs
  7. +92 −0 ...xploreEducationStatistics.Content.Search.FunctionApp.Tests/Clients/AzureBlobStorageClientTests.cs
  8. +68 −0 ...EducationStatistics.Content.Search.FunctionApp.Tests/Clients/AzureBlobStorageIntegrationClient.cs
  9. +100 −0 ...tion.ExploreEducationStatistics.Content.Search.FunctionApp.Tests/Clients/ContentApiClientTests.cs
  10. +9 −0 ...EducationStatistics.Content.Search.FunctionApp.Tests/Extensions/ConfigurationBuilderExtensions.cs
  11. +109 −0 ...onStatistics.Content.Search.FunctionApp.Tests/Extensions/ReleaseSearchViewModelExtensionsTests.cs
  12. +28 −0 ...ctionApp.Tests/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Tests.csproj
  13. +199 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.Tests/ProgramTests.cs
  14. +111 −0 ...reEducationStatistics.Content.Search.FunctionApp.Tests/Services/SearchableDocumentCreatorTests.cs
  15. +21 −0 ...EducationStatistics.Content.Search.FunctionApp/Clients/AzureBlobStorage/AzureBlobStorageClient.cs
  16. +7 −0 ....Education.ExploreEducationStatistics.Content.Search.FunctionApp/Clients/AzureBlobStorage/Blob.cs
  17. +14 −0 ...ducationStatistics.Content.Search.FunctionApp/Clients/AzureBlobStorage/IAzureBlobStorageClient.cs
  18. +31 −0 ...tion.ExploreEducationStatistics.Content.Search.FunctionApp/Clients/ContentApi/ContentApiClient.cs
  19. +6 −0 ...ion.ExploreEducationStatistics.Content.Search.FunctionApp/Clients/ContentApi/IContentApiClient.cs
  20. +19 −0 ...oreEducationStatistics.Content.Search.FunctionApp/Clients/ContentApi/ReleaseSearchViewModelDto.cs
  21. +14 −0 ...ion.ExploreEducationStatistics.Content.Search.FunctionApp/Exceptions/AzureBlobStorageException.cs
  22. +14 −0 ...Uk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/Exceptions/StreamExtensions.cs
  23. +29 −0 ...tionStatistics.Content.Search.FunctionApp/Exceptions/UnableToCreateSearchableDocumentException.cs
  24. +62 −0 ...ducation.ExploreEducationStatistics.Content.Search.FunctionApp/Extensions/HostBuilderExtension.cs
  25. +12 −0 ...ovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/Extensions/HostExtensions.cs
  26. +71 −0 ...loreEducationStatistics.Content.Search.FunctionApp/Extensions/ReleaseSearchViewModelExtensions.cs
  27. +13 −0 ...n.ExploreEducationStatistics.Content.Search.FunctionApp/Extensions/ServiceCollectionExtensions.cs
  28. +33 −0 ...nctions/CreateSearchableReleaseDocuments/CreateSearchableReleaseDocumentInAzureStorageFunction.cs
  29. +3 −0 ...ent.Search.FunctionApp/Functions/CreateSearchableReleaseDocuments/Dtos/ReleasePublishedMessage.cs
  30. +8 −0 ...earch.FunctionApp/Functions/CreateSearchableReleaseDocuments/Dtos/SearchDocumentCreatedMessage.cs
  31. +47 −0 ...t.Search.FunctionApp/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp.csproj
  32. +22 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/Options/AppOptions.cs
  33. +18 −0 ...ovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/Options/ContentApiOptions.cs
  34. +6 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/Program.cs
  35. +4 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/Properties/AssemblyInfo.cs
  36. +9 −0 ...Uk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/Properties/launchSettings.json
  37. +6 −0 ...cs.Content.Search.FunctionApp/Services/CreatePublicationLatestReleaseSearchableDocumentRequest.cs
  38. +8 −0 ...s.Content.Search.FunctionApp/Services/CreatePublicationLatestReleaseSearchableDocumentResponse.cs
  39. +6 −0 ...tion.ExploreEducationStatistics.Content.Search.FunctionApp/Services/ISearchableDocumentCreator.cs
  40. +41 −0 ...ation.ExploreEducationStatistics.Content.Search.FunctionApp/Services/SearchableDocumentCreator.cs
  41. +17 −0 ...ovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/appsettings.Development.json
  42. +13 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/appsettings.json
  43. +21 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/host.json
  44. +13 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Search.FunctionApp/local.settings.json
  45. +1 −0 src/GovUk.Education.ExploreEducationStatistics.Content.Services.Tests/ReleaseServiceTests.cs
  46. +1 −1 src/GovUk.Education.ExploreEducationStatistics.Content.ViewModels/Extensions/StringExtensions.cs
  47. +6 −6 src/GovUk.Education.ExploreEducationStatistics.Content.ViewModels/ReleaseCacheViewModel.cs
  48. +10 −10 src/GovUk.Education.ExploreEducationStatistics.Content.ViewModels/ReleaseSearchViewModel.cs
  49. +2 −2 src/GovUk.Education.ExploreEducationStatistics.Data.Api.Tests/Services/PermalinkServiceTests.cs
  50. +1 −1 src/GovUk.Education.ExploreEducationStatistics.Data.Api/Requests/PermalinkCreateRequest.cs
  51. +1 −1 src/GovUk.Education.ExploreEducationStatistics.Data.Api/Services/PermalinkService.cs
  52. +35 −0 src/GovUk.Education.ExploreEducationStatistics.sln
  53. +8 −0 useful-scripts/start.ts
16 changes: 16 additions & 0 deletions azure-pipelines-main.yml
Original file line number Diff line number Diff line change
@@ -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:
Original file line number Diff line number Diff line change
@@ -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);

@@ -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);

Original file line number Diff line number Diff line change
@@ -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 =>
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