diff --git a/.github/workflows/deploy-image.yml b/.github/workflows/deploy-image.yml index 84b32cd64..ccb7d527b 100644 --- a/.github/workflows/deploy-image.yml +++ b/.github/workflows/deploy-image.yml @@ -117,13 +117,40 @@ jobs: --output none &> /dev/null + flush-redis-cache: + runs-on: ubuntu-22.04 + name: Flush Redis Cache on ${{ inputs.environment }} + environment: ${{ inputs.environment }} + needs: [upgrade-database, pull-image-from-gcr-and-publish-to-acr, deploy-image] + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Azure CLI Login + uses: ./.github/actions/azure-login + with: + az_tenant_id: ${{ secrets.AZ_TENANT_ID }} + az_subscription_id: ${{ secrets.AZ_SUBSCRIPTION_ID }} + az_client_id: ${{ secrets.AZ_CLIENT_ID }} + az_client_secret: ${{ secrets.AZ_CLIENT_SECRET }} + + - name: Flush Redis Cache + uses: azure/CLI@v1 + id: azure + with: + azcliversion: 2.67.0 + inlineScript: | + az redis flush --name ${{ secrets.AZ_ENVIRONMENT }}${{ secrets.DFE_PROJECT_NAME }} --resource-group ${{ secrets.AZ_ENVIRONMENT }}${{ secrets.DFE_PROJECT_NAME }} --yes + run-e2e-testing: runs-on: ubuntu-22.04 name: Clear Database & Run E2E Tests on ${{ inputs.environment }} if: ${{ inputs.environment == 'tst' || inputs.environment == 'staging' }} environment: ${{ inputs.environment }} needs: - [upgrade-database, pull-image-from-gcr-and-publish-to-acr, deploy-image] + [upgrade-database, pull-image-from-gcr-and-publish-to-acr, deploy-image, flush-redis-cache] env: az_keyvault_name: ${{ secrets.AZ_ENVIRONMENT }}${{ secrets.DFE_PROJECT_NAME }}-kv az_keyvault_database_connectionstring_name: ${{ secrets.AZ_KEYVAULT_DATABASE_CONNECTIONSTRING_NAME }} diff --git a/src/Dfe.PlanTech.Application/Caching/Interfaces/ICmsCache.cs b/src/Dfe.PlanTech.Application/Caching/Interfaces/ICmsCache.cs index c8b78157d..ffb287431 100644 --- a/src/Dfe.PlanTech.Application/Caching/Interfaces/ICmsCache.cs +++ b/src/Dfe.PlanTech.Application/Caching/Interfaces/ICmsCache.cs @@ -10,6 +10,7 @@ public interface ICmsCache : IDistributedCache /// Then removes the dependency array itself /// /// Id of component to invalidate dependencies of + /// Name of the content type of the component being invalidated /// - Task InvalidateCacheAsync(string contentComponentId); + Task InvalidateCacheAsync(string contentComponentId, string contentType); } diff --git a/src/Dfe.PlanTech.Application/Content/Queries/GetEntityFromContentfulQuery.cs b/src/Dfe.PlanTech.Application/Content/Queries/GetEntityFromContentfulQuery.cs index 09eaef93c..208bd7f64 100644 --- a/src/Dfe.PlanTech.Application/Content/Queries/GetEntityFromContentfulQuery.cs +++ b/src/Dfe.PlanTech.Application/Content/Queries/GetEntityFromContentfulQuery.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Core; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Domain.Content.Interfaces; @@ -16,12 +15,10 @@ public class GetEntityFromContentfulQuery : ContentRetriever, IGetEntityFromCont public const string ExceptionMessageEntityContentful = "Error fetching Entity from Contentful"; private readonly ILogger _logger; - private readonly ICmsCache _cache; - public GetEntityFromContentfulQuery(ILogger logger, IContentRepository repository, ICmsCache cache) : base(repository) + public GetEntityFromContentfulQuery(ILogger logger, IContentRepository repository) : base(repository) { _logger = logger; - _cache = cache; } public async Task GetEntityById(string contentId, CancellationToken cancellationToken = default) @@ -29,8 +26,7 @@ public GetEntityFromContentfulQuery(ILogger logger { try { - return await _cache.GetOrCreateAsync($"Entity:{contentId}", - () => repository.GetEntityById(contentId, cancellationToken: cancellationToken)); + return await repository.GetEntityById(contentId, cancellationToken: cancellationToken); } catch (Exception ex) { diff --git a/src/Dfe.PlanTech.Application/Content/Queries/GetNavigationQuery.cs b/src/Dfe.PlanTech.Application/Content/Queries/GetNavigationQuery.cs index c3d1e6cd7..f0ed667b5 100644 --- a/src/Dfe.PlanTech.Application/Content/Queries/GetNavigationQuery.cs +++ b/src/Dfe.PlanTech.Application/Content/Queries/GetNavigationQuery.cs @@ -1,5 +1,3 @@ - -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Core; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Domain.Content.Interfaces; @@ -16,20 +14,17 @@ public class GetNavigationQuery : ContentRetriever, IGetNavigationQuery public const string ExceptionMessageContentful = "Error getting navigation links from Contentful"; private readonly ILogger _logger; - private readonly ICmsCache _cache; - public GetNavigationQuery(ILogger logger, IContentRepository repository, ICmsCache cache) : base(repository) + public GetNavigationQuery(ILogger logger, IContentRepository repository) : base(repository) { _logger = logger; - _cache = cache; } public async Task> GetNavigationLinks(CancellationToken cancellationToken = default) { try { - var navigationLinks = await _cache.GetOrCreateAsync("NavigationLinks", () => repository.GetEntities(cancellationToken)) ?? []; - return navigationLinks; + return await repository.GetEntities(cancellationToken); } catch (Exception ex) { @@ -42,7 +37,7 @@ public async Task> GetNavigationLinks(CancellationT { try { - return await _cache.GetOrCreateAsync($"NavigationLink:{contentId}", () => repository.GetEntityById(contentId, cancellationToken: cancellationToken)); + return await repository.GetEntityById(contentId, cancellationToken: cancellationToken); } catch (Exception ex) { diff --git a/src/Dfe.PlanTech.Application/Content/Queries/GetPageQuery.cs b/src/Dfe.PlanTech.Application/Content/Queries/GetPageQuery.cs index 922bc7d46..c854fdc55 100644 --- a/src/Dfe.PlanTech.Application/Content/Queries/GetPageQuery.cs +++ b/src/Dfe.PlanTech.Application/Content/Queries/GetPageQuery.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Exceptions; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Application.Persistence.Models; @@ -12,14 +11,12 @@ namespace Dfe.PlanTech.Application.Content.Queries; public class GetPageQuery : IGetPageQuery { - private readonly ICmsCache _cache; private readonly IContentRepository _repository; private readonly ILogger _logger; private readonly GetPageFromContentfulOptions _options; - public GetPageQuery(ICmsCache cache, IContentRepository repository, ILogger logger, GetPageFromContentfulOptions options) + public GetPageQuery(IContentRepository repository, ILogger logger, GetPageFromContentfulOptions options) { - _cache = cache; _repository = repository; _logger = logger; _options = options; @@ -52,8 +49,7 @@ public GetPageQuery(ICmsCache cache, IContentRepository repository, ILogger - _repository.GetEntityById(pageId, cancellationToken: cancellationToken)); + return await _repository.GetEntityById(pageId, cancellationToken: cancellationToken); } catch (Exception ex) { @@ -67,7 +63,7 @@ public GetPageQuery(ICmsCache cache, IContentRepository repository, ILogger _repository.GetEntities(options, cancellationToken)) ?? []; + var pages = await _repository.GetEntities(options, cancellationToken); var page = pages.FirstOrDefault(); diff --git a/src/Dfe.PlanTech.Application/Content/Queries/GetSubTopicRecommendationQuery.cs b/src/Dfe.PlanTech.Application/Content/Queries/GetSubTopicRecommendationQuery.cs index c4929b277..4f2b921b5 100644 --- a/src/Dfe.PlanTech.Application/Content/Queries/GetSubTopicRecommendationQuery.cs +++ b/src/Dfe.PlanTech.Application/Content/Queries/GetSubTopicRecommendationQuery.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Application.Persistence.Models; using Dfe.PlanTech.Domain.Content.Interfaces; @@ -11,14 +10,12 @@ namespace Dfe.PlanTech.Application.Content.Queries; public class GetSubTopicRecommendationQuery(IContentRepository repository, - ILogger logger, - ICmsCache cache) : IGetSubTopicRecommendationQuery + ILogger logger) : IGetSubTopicRecommendationQuery { public async Task GetSubTopicRecommendation(string subtopicId, CancellationToken cancellationToken = default) { var options = CreateGetEntityOptions(subtopicId); - var subTopicRecommendations = await cache.GetOrCreateAsync($"SubtopicRecommendation:{subtopicId}", - () => repository.GetEntities(options, cancellationToken)) ?? []; + var subTopicRecommendations = await repository.GetEntities(options, cancellationToken); var subtopicRecommendation = subTopicRecommendations.FirstOrDefault(); @@ -35,8 +32,7 @@ public class GetSubTopicRecommendationQuery(IContentRepository repository, var options = CreateGetEntityOptions(subtopicId, 2); options.Select = ["fields.intros", "sys"]; - var subtopicRecommendations = await cache.GetOrCreateAsync($"RecommendationViewDto:{subtopicId}", - () => repository.GetEntities(options, cancellationToken)) ?? []; + var subtopicRecommendations = await repository.GetEntities(options, cancellationToken); var subtopicRecommendation = subtopicRecommendations.FirstOrDefault(); diff --git a/src/Dfe.PlanTech.Application/Persistence/Commands/WebhookMessageProcessor.cs b/src/Dfe.PlanTech.Application/Persistence/Commands/WebhookMessageProcessor.cs index c8d729789..73c3917cb 100644 --- a/src/Dfe.PlanTech.Application/Persistence/Commands/WebhookMessageProcessor.cs +++ b/src/Dfe.PlanTech.Application/Persistence/Commands/WebhookMessageProcessor.cs @@ -18,7 +18,7 @@ public async Task ProcessMessage(string subject, string body, { var payload = MapMessageToPayload(body); - await cache.InvalidateCacheAsync(payload.Sys.Id); + await cache.InvalidateCacheAsync(payload.Sys.Id, payload.ContentType); return new ServiceBusSuccessResult(); } catch (Exception ex) when (ex is JsonException) diff --git a/src/Dfe.PlanTech.Application/Persistence/Interfaces/IContentRepository.cs b/src/Dfe.PlanTech.Application/Persistence/Interfaces/IContentRepository.cs index f12b155df..1b0b9c8c0 100644 --- a/src/Dfe.PlanTech.Application/Persistence/Interfaces/IContentRepository.cs +++ b/src/Dfe.PlanTech.Application/Persistence/Interfaces/IContentRepository.cs @@ -1,3 +1,4 @@ +using Dfe.PlanTech.Application.Persistence.Models; using Dfe.PlanTech.Domain.Persistence.Interfaces; namespace Dfe.PlanTech.Application.Persistence.Interfaces; @@ -18,14 +19,12 @@ public interface IContentRepository Task GetEntityById(string id, int include = 2, CancellationToken cancellationToken = default); /// - /// Get all entities of the specified type. + /// Get options to use for fetching an Entity by Id /// - /// - /// Additional filtere - /// - /// + /// + /// /// - Task> GetEntities(string entityTypeId, IGetEntitiesOptions? options, CancellationToken cancellationToken = default); + GetEntitiesOptions GetEntityByIdOptions(string id, int include = 2); /// /// Get all entities of the specified type, using the name of the generic parameter's type as the entity type id (to lower case). diff --git a/src/Dfe.PlanTech.Application/Persistence/Models/GetEntitiesOptions.cs b/src/Dfe.PlanTech.Application/Persistence/Models/GetEntitiesOptions.cs index 04a0320e6..b6665e8d6 100644 --- a/src/Dfe.PlanTech.Application/Persistence/Models/GetEntitiesOptions.cs +++ b/src/Dfe.PlanTech.Application/Persistence/Models/GetEntitiesOptions.cs @@ -1,4 +1,6 @@ +using System.Text; using Dfe.PlanTech.Domain.Persistence.Interfaces; +using Dfe.PlanTech.Infrastructure.Application.Models; namespace Dfe.PlanTech.Application.Persistence.Models; @@ -29,4 +31,35 @@ public GetEntitiesOptions() public IEnumerable? Queries { get; init; } public int Include { get; init; } = 2; + + public string SerializeToRedisFormat() + { + var builder = new StringBuilder(); + + builder.Append(":Include="); + builder.Append(Include); + + if (Select != null && Select.Any()) + builder.Append($":Select=[{string.Join(",", Select)}]"); + + if (Queries != null && Queries.Any()) + { + builder.Append(":Queries=["); + foreach (var query in Queries) + { + builder.Append(query.Field); + + if (query is ContentQueryEquals queryEquals) + builder.Append($"={queryEquals.Value}"); + else if (query is ContentQueryIncludes queryIncludes) + builder.Append($"=[{string.Join(',', queryIncludes.Value)}]"); + + builder.Append(','); + } + builder.Length--; + builder.Append(']'); + } + + return builder.ToString(); + } } diff --git a/src/Dfe.PlanTech.Application/Questionnaire/Queries/GetSectionQuery.cs b/src/Dfe.PlanTech.Application/Questionnaire/Queries/GetSectionQuery.cs index 9099cf03c..f3a167ddd 100644 --- a/src/Dfe.PlanTech.Application/Questionnaire/Queries/GetSectionQuery.cs +++ b/src/Dfe.PlanTech.Application/Questionnaire/Queries/GetSectionQuery.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Core; using Dfe.PlanTech.Application.Exceptions; using Dfe.PlanTech.Application.Persistence.Interfaces; @@ -12,11 +11,9 @@ namespace Dfe.PlanTech.Application.Questionnaire.Queries; public class GetSectionQuery : ContentRetriever, IGetSectionQuery { public const string SlugFieldPath = "fields.interstitialPage.fields.slug"; - private readonly ICmsCache _cache; - public GetSectionQuery(IContentRepository repository, ICmsCache cache) : base(repository) + public GetSectionQuery(IContentRepository repository) : base(repository) { - _cache = cache; } public async Task GetSectionBySlug(string sectionSlug, CancellationToken cancellationToken = default) @@ -40,7 +37,7 @@ public GetSectionQuery(IContentRepository repository, ICmsCache cache) : base(re try { - var sections = await _cache.GetOrCreateAsync($"Section:{sectionSlug}", () => repository.GetEntities
(options, cancellationToken)) ?? []; + var sections = await repository.GetEntities
(options, cancellationToken); return sections.FirstOrDefault(); } catch (Exception ex) @@ -56,13 +53,9 @@ public GetSectionQuery(IContentRepository repository, ICmsCache cache) : base(re { try { - var sections = await _cache.GetOrCreateAsync("Sections", async () => - { - var options = new GetEntitiesOptions(include: 3); - var sections = await repository.GetEntities
(options, cancellationToken); - return sections.Select(MapSection); - }) ?? []; - return sections; + var options = new GetEntitiesOptions(include: 3); + var sections = await repository.GetEntities
(options, cancellationToken); + return sections.Select(MapSection); } catch (Exception ex) { diff --git a/src/Dfe.PlanTech.Domain/Extensions/StringHelpers.cs b/src/Dfe.PlanTech.Domain/Extensions/StringHelpers.cs index 1db229ed1..7f4df562f 100644 --- a/src/Dfe.PlanTech.Domain/Extensions/StringHelpers.cs +++ b/src/Dfe.PlanTech.Domain/Extensions/StringHelpers.cs @@ -21,4 +21,6 @@ private static string ReplaceWhitespaceCharacters(this string text, string repla private static string ReplaceNonSlugCharacters(this string text, string replacement) => MatchNonAlphaNumericExceptHyphensPattern().Replace(text, replacement); + + public static string FirstCharToLower(this string input) => char.ToLower(input[0]) + input[1..]; } diff --git a/src/Dfe.PlanTech.Domain/Persistence/Interfaces/IGetEntitiesOptions.cs b/src/Dfe.PlanTech.Domain/Persistence/Interfaces/IGetEntitiesOptions.cs index 0291bc9d3..063679467 100644 --- a/src/Dfe.PlanTech.Domain/Persistence/Interfaces/IGetEntitiesOptions.cs +++ b/src/Dfe.PlanTech.Domain/Persistence/Interfaces/IGetEntitiesOptions.cs @@ -19,4 +19,6 @@ public interface IGetEntitiesOptions /// If null, return all /// public IEnumerable? Select { get; set; } + + public string SerializeToRedisFormat(); } diff --git a/src/Dfe.PlanTech.Infrastructure.Contentful/Helpers/ContentfulSetup.cs b/src/Dfe.PlanTech.Infrastructure.Contentful/Helpers/ContentfulSetup.cs index 59b60dd47..75f966484 100644 --- a/src/Dfe.PlanTech.Infrastructure.Contentful/Helpers/ContentfulSetup.cs +++ b/src/Dfe.PlanTech.Infrastructure.Contentful/Helpers/ContentfulSetup.cs @@ -34,7 +34,8 @@ public static IServiceCollection SetupContentfulClient(this IServiceCollection s services.AddTransient(); services.AddScoped(); - services.AddScoped(); + services.AddKeyedScoped("contentfulRepository"); + services.AddScoped(); services.SetupRichTextRenderer(); diff --git a/src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/CachedContentfulRepository.cs b/src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/CachedContentfulRepository.cs new file mode 100644 index 000000000..7f682a8df --- /dev/null +++ b/src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/CachedContentfulRepository.cs @@ -0,0 +1,63 @@ +using Dfe.PlanTech.Application.Caching.Interfaces; +using Dfe.PlanTech.Application.Persistence.Interfaces; +using Dfe.PlanTech.Application.Persistence.Models; +using Dfe.PlanTech.Domain.Persistence.Interfaces; +using Dfe.PlanTech.Infrastructure.Contentful.Helpers; +using Microsoft.Extensions.DependencyInjection; + +namespace Dfe.PlanTech.Infrastructure.Contentful.Persistence; + +/// +/// Encapsulates ContentfulClient functionality, whilst abstracting through the IEntityRepository interface +/// +/// +public class CachedContentfulRepository : IContentRepository +{ + private readonly IContentRepository _contentRepository; + private readonly ICmsCache _cache; + + public CachedContentfulRepository([FromKeyedServices("contentfulRepository")] IContentRepository contentRepository, ICmsCache cache) + { + _contentRepository = contentRepository ?? throw new ArgumentNullException(nameof(contentRepository)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + } + + public async Task> GetEntities(CancellationToken cancellationToken = default) + { + string contentType = GetContentTypeName(); + var key = $"{contentType}s"; + + return await _cache.GetOrCreateAsync(key, async () => await _contentRepository.GetEntities(cancellationToken)) ?? []; + } + + public async Task> GetEntities(IGetEntitiesOptions options, CancellationToken cancellationToken = default) + { + var contentType = GetContentTypeName(); + var jsonOptions = options.SerializeToRedisFormat(); + var key = $"{contentType}{jsonOptions}"; + + return await _cache.GetOrCreateAsync(key, async () => await _contentRepository.GetEntities(options, cancellationToken)) ?? []; + } + + public async Task GetEntityById(string id, int include = 2, CancellationToken cancellationToken = default) + { + var options = GetEntityByIdOptions(id, include); + var entities = (await GetEntities(options, cancellationToken)).ToList(); + + if (entities.Count > 1) + throw new GetEntitiesException($"Found more than 1 entity with id {id}"); + + return entities.FirstOrDefault(); + } + + public GetEntitiesOptions GetEntityByIdOptions(string id, int include = 2) + { + return _contentRepository.GetEntityByIdOptions(id, include); + } + + private static string GetContentTypeName() + { + var name = typeof(TEntity).Name; + return name == "ContentSupportPage" ? name : name.FirstCharToLower(); + } +} diff --git a/src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/ContentfulRepository.cs b/src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/ContentfulRepository.cs index 12aa5627c..5a443131b 100644 --- a/src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/ContentfulRepository.cs +++ b/src/Dfe.PlanTech.Infrastructure.Contentful/Persistence/ContentfulRepository.cs @@ -56,12 +56,23 @@ private static string CreateErrorString(ContentfulError error) } public async Task> GetEntities(CancellationToken cancellationToken = default) - => await GetEntities(LowerCaseFirstLetter(typeof(TEntity).Name), null, cancellationToken); + => await GetEntities(GetContentTypeName(), null, cancellationToken); public async Task> GetEntities(IGetEntitiesOptions options, CancellationToken cancellationToken = default) - => await GetEntities(LowerCaseFirstLetter(typeof(TEntity).Name), options, cancellationToken); + => await GetEntities(GetContentTypeName(), options, cancellationToken); public async Task GetEntityById(string id, int include = 2, CancellationToken cancellationToken = default) + { + var options = GetEntityByIdOptions(id, include); + var entities = (await GetEntities(options, cancellationToken)).ToList(); + + if (entities.Count > 1) + throw new GetEntitiesException($"Found more than 1 entity with id {id}"); + + return entities.FirstOrDefault(); + } + + public GetEntitiesOptions GetEntityByIdOptions(string id, int include = 2) { if (string.IsNullOrEmpty(id)) throw new ArgumentNullException(nameof(id)); @@ -70,29 +81,16 @@ public async Task> GetEntities(IGetEntitiesOptions //option doesn't seem to have any effect there - it only seems to return the main parent entry //with links to children. This was proving rather useless, so I have used the "GetEntries" option here //instead. - var options = new GetEntitiesOptions(include, new[] { + return new GetEntitiesOptions(include, new[] { new ContentQueryEquals(){ Field = "sys.id", Value = id - }}); - - var entities = (await GetEntities(options, cancellationToken)).ToList(); - - if (entities.Count > 1) - { - throw new GetEntitiesException($"Found more than 1 entity with id {id}"); - } - - return entities.FirstOrDefault(); + }}); } - private static string LowerCaseFirstLetter(string input) + private static string GetContentTypeName() { - if (input == "ContentSupportPage") - return input; - - char[] array = input.ToCharArray(); - array[0] = char.ToLower(array[0]); - return new string(array); + var name = typeof(TEntity).Name; + return name == "ContentSupportPage" ? name : name.FirstCharToLower(); } } diff --git a/src/Dfe.PlanTech.Infrastructure.Redis/IRedisDependencyManager.cs b/src/Dfe.PlanTech.Infrastructure.Redis/IRedisDependencyManager.cs index 009b1ae01..ed516e12d 100644 --- a/src/Dfe.PlanTech.Infrastructure.Redis/IRedisDependencyManager.cs +++ b/src/Dfe.PlanTech.Infrastructure.Redis/IRedisDependencyManager.cs @@ -7,6 +7,11 @@ namespace Dfe.PlanTech.Infrastructure.Redis; ///
public interface IRedisDependencyManager { + /// + /// Key to use for dependencies of a content fetch that returns nothing + /// + string EmptyCollectionDependencyKey { get; } + /// /// Find and set dependencies for a given /// diff --git a/src/Dfe.PlanTech.Infrastructure.Redis/RedisCache.cs b/src/Dfe.PlanTech.Infrastructure.Redis/RedisCache.cs index db81907d0..0bae6a695 100644 --- a/src/Dfe.PlanTech.Infrastructure.Redis/RedisCache.cs +++ b/src/Dfe.PlanTech.Infrastructure.Redis/RedisCache.cs @@ -253,16 +253,26 @@ private static CacheResult CreateCacheResult(RedisValue redisResult) } /// - public Task InvalidateCacheAsync(string contentComponentId) + public Task InvalidateCacheAsync(string contentComponentId, string contentType) => _backgroundTaskService.QueueBackgroundWorkItemAsync(async (cancellationToken) => { var key = _dependencyManager.GetDependencyKey(contentComponentId); - var dependencies = await GetSetMembersAsync(key); - foreach (var item in dependencies) - { - await RemoveAsync(item); - } + await RemoveDependenciesAsync(key); - await SetRemoveItemsAsync(key, dependencies); + // Invalidate all empty collections + await RemoveDependenciesAsync(_dependencyManager.EmptyCollectionDependencyKey); + + // Invalidate collection of the content type if there is one + await RemoveAsync($"{contentType}s"); }); + + private async Task RemoveDependenciesAsync(string dependencyKey) + { + var dependencies = (await GetSetMembersAsync(dependencyKey)).ToList(); + foreach (var item in dependencies) + { + await RemoveAsync(item); + } + await SetRemoveItemsAsync(dependencyKey, dependencies); + } } diff --git a/src/Dfe.PlanTech.Infrastructure.Redis/RedisDependencyManager.cs b/src/Dfe.PlanTech.Infrastructure.Redis/RedisDependencyManager.cs index 84b2f9b07..e360484b4 100644 --- a/src/Dfe.PlanTech.Infrastructure.Redis/RedisDependencyManager.cs +++ b/src/Dfe.PlanTech.Infrastructure.Redis/RedisDependencyManager.cs @@ -8,6 +8,8 @@ namespace Dfe.PlanTech.Infrastructure.Redis; /// To add dependency set operations to a queue for background processing public class RedisDependencyManager(IBackgroundTaskQueue backgroundTaskQueue) : IRedisDependencyManager { + public string EmptyCollectionDependencyKey => "Missing"; + /// public Task RegisterDependenciesAsync(IDatabase database, string key, T value, CancellationToken cancellationToken = default) => backgroundTaskQueue.QueueBackgroundWorkItemAsync((cancellationToken) => GetAndSetDependencies(database, key, value)); @@ -26,6 +28,11 @@ private async Task GetAndSetDependencies(IDatabase database, string key, T va { var batch = database.CreateBatch(); var tasks = GetDependencies(value).Select(dependency => batch.SetAddAsync(GetDependencyKey(dependency), key, CommandFlags.FireAndForget)).ToArray(); + if (tasks.Length == 0) + { + // If the value has no dependencies (is empty) it should be invalidated when new content comes in + tasks = tasks.Append(batch.SetAddAsync(EmptyCollectionDependencyKey, key, CommandFlags.FireAndForget)).ToArray(); + } batch.Execute(); await Task.WhenAll(tasks); } diff --git a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetEntityFromContentfulQueryTests.cs b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetEntityFromContentfulQueryTests.cs index 148b77488..25407f03b 100644 --- a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetEntityFromContentfulQueryTests.cs +++ b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetEntityFromContentfulQueryTests.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Content.Queries; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Domain.Content.Models; @@ -13,20 +12,13 @@ public class GetEntityFromContentfulQueryTests { private readonly IContentRepository _contentRepository = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); - private readonly ICmsCache _cache = Substitute.For(); private readonly GetEntityFromContentfulQuery _getEntityFromContentfulQuery; private readonly Question _firstQuestion = new() { Sys = new SystemDetails { Id = "question-1" } }; public GetEntityFromContentfulQueryTests() { - _getEntityFromContentfulQuery = new GetEntityFromContentfulQuery(_logger, _contentRepository, _cache); - _cache.GetOrCreateAsync(Arg.Any(), Arg.Any>>()) - .Returns(callInfo => - { - var func = callInfo.ArgAt>>(1); - return func(); - }); + _getEntityFromContentfulQuery = new GetEntityFromContentfulQuery(_logger, _contentRepository); } diff --git a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetNavigationQueryTests.cs b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetNavigationQueryTests.cs index 053f3bbe8..ab9d2cb26 100644 --- a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetNavigationQueryTests.cs +++ b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetNavigationQueryTests.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Content.Queries; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Domain.Content.Models; @@ -11,7 +10,6 @@ namespace Dfe.PlanTech.Application.UnitTests.Content.Queries; public class GetNavigationQueryTests { private readonly IContentRepository _contentRepository = Substitute.For(); - private readonly ICmsCache _cache = Substitute.For(); private readonly NavigationLink _contentfulLink = new NavigationLink { @@ -20,28 +18,11 @@ public class GetNavigationQueryTests }; private readonly IList _contentfulLinks; - private readonly ILogger _logger = Substitute.For>(); public GetNavigationQueryTests() { _contentfulLinks = new List { _contentfulLink }; - - _cache.GetOrCreateAsync(Arg.Any(), Arg.Any?>>>()) - .Returns(callInfo => - { - var func = callInfo.ArgAt?>>>(1); - return func(); - }); - - _cache.GetOrCreateAsync( - Arg.Any(), - Arg.Any>>()) - .Returns(callInfo => - { - var func = callInfo.ArgAt>>(1); - return func(); - }); } [Fact] @@ -49,7 +30,7 @@ public async Task Should_Retrieve_Nav_Links_From_Contentful() { _contentRepository.GetEntities(CancellationToken.None).Returns(_contentfulLinks); - GetNavigationQuery navQuery = new(_logger, _contentRepository, _cache); + GetNavigationQuery navQuery = new(_logger, _contentRepository); var result = await navQuery.GetNavigationLinks(); @@ -61,7 +42,7 @@ public async Task Should_LogError_When_Contentful_Exception() { _contentRepository.GetEntities(CancellationToken.None).Throws(_ => new Exception("Contentful error")); - GetNavigationQuery navQuery = new(_logger, _contentRepository, _cache); + GetNavigationQuery navQuery = new(_logger, _contentRepository); var result = await navQuery.GetNavigationLinks(); @@ -75,7 +56,7 @@ public async Task Should_LogError_When_Contentful_Exception() public async Task Should_Retrieve_Nav_Link_By_Id_When_Exists() { _contentRepository.GetEntityById(Arg.Any(), Arg.Any(), Arg.Any()).Returns(_contentfulLink); - var navQuery = new GetNavigationQuery(_logger, _contentRepository, _cache); + var navQuery = new GetNavigationQuery(_logger, _contentRepository); var result = await navQuery.GetLinkById("contentId"); Assert.NotNull(result); @@ -88,7 +69,7 @@ public async Task Should_Return_Null_When_Nav_Link_Does_Not_Exist() { _contentRepository.GetEntityById(Arg.Any(), Arg.Any(), cancellationToken: CancellationToken.None).Returns((NavigationLink?)null); - var navQuery = new GetNavigationQuery(_logger, _contentRepository, _cache); + var navQuery = new GetNavigationQuery(_logger, _contentRepository); var result = await navQuery.GetLinkById("NonExistentId"); diff --git a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetPageQueryTests.cs b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetPageQueryTests.cs index bc9c7e0c8..0ccc03ed4 100644 --- a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetPageQueryTests.cs +++ b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetPageQueryTests.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Content.Queries; using Dfe.PlanTech.Application.Exceptions; using Dfe.PlanTech.Application.Persistence.Interfaces; @@ -21,7 +20,6 @@ public class GetPageQueryTests private readonly IContentRepository _repoSubstitute = Substitute.For(); private readonly ILogger _logger = Substitute.For>(); - private readonly ICmsCache _cache = Substitute.For(); private readonly List _pages = new() { new Page(){ @@ -56,18 +54,6 @@ public class GetPageQueryTests public GetPageQueryTests() { SetupRepository(); - _cache.GetOrCreateAsync(Arg.Any(), Arg.Any?>>>()) - .Returns(callInfo => - { - var func = callInfo.ArgAt?>>>(1); - return func(); - }); - _cache.GetOrCreateAsync(Arg.Any(), Arg.Any>>()) - .Returns(callInfo => - { - var func = callInfo.ArgAt>>(1); - return func(); - }); } private void SetupRepository() @@ -98,7 +84,7 @@ private void SetupRepository() } private GetPageQuery CreateGetPageQuery() - => new(_cache, _repoSubstitute, _logger, new GetPageFromContentfulOptions() { Include = 4 }); + => new(_repoSubstitute, _logger, new GetPageFromContentfulOptions() { Include = 4 }); [Fact] diff --git a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetSubTopicRecommendationQueryTests.cs b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetSubTopicRecommendationQueryTests.cs index 1bbbe5023..2f3dd430a 100644 --- a/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetSubTopicRecommendationQueryTests.cs +++ b/tests/Dfe.PlanTech.Application.UnitTests/Content/Queries/GetSubTopicRecommendationQueryTests.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Content.Queries; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Application.Persistence.Models; @@ -23,7 +22,6 @@ public class GetSubTopicRecommendationQueryTests private readonly List _subtopicRecommendations = []; private readonly ILogger _logger = Substitute.For>(); - private readonly ICmsCache _cache = Substitute.For(); public GetSubTopicRecommendationQueryTests() { @@ -135,13 +133,7 @@ public GetSubTopicRecommendationQueryTests() _subtopicRecommendations.Add(_subtopicRecommendationOne); _subtopicRecommendations.Add(_subtopicRecommendationTwo); - _query = new(_repoSubstitute, _logger, _cache); - _cache.GetOrCreateAsync(Arg.Any(), Arg.Any?>>>()) - .Returns(callInfo => - { - var func = callInfo.ArgAt?>>>(1); - return func(); - }); + _query = new(_repoSubstitute, _logger); } [Fact] diff --git a/tests/Dfe.PlanTech.Application.UnitTests/Persistence/Commands/WebhookMessageProcessorTests.cs b/tests/Dfe.PlanTech.Application.UnitTests/Persistence/Commands/WebhookMessageProcessorTests.cs index 39f4e9cc8..f33fba1db 100644 --- a/tests/Dfe.PlanTech.Application.UnitTests/Persistence/Commands/WebhookMessageProcessorTests.cs +++ b/tests/Dfe.PlanTech.Application.UnitTests/Persistence/Commands/WebhookMessageProcessorTests.cs @@ -42,7 +42,7 @@ public async Task ProcessMessage_Should_Execute_Successfully() var result = await _webhookMessageProcessor.ProcessMessage(subject, QuestionJsonBody, "message id", CancellationToken.None); Assert.IsType(result); - await _cache.Received(1).InvalidateCacheAsync(QuestionId); + await _cache.Received(1).InvalidateCacheAsync(QuestionId, "question"); } [Fact] diff --git a/tests/Dfe.PlanTech.Application.UnitTests/Persistence/GetEntitiesOptionsTests.cs b/tests/Dfe.PlanTech.Application.UnitTests/Persistence/GetEntitiesOptionsTests.cs index 4b1603c0f..8acfa9c9b 100644 --- a/tests/Dfe.PlanTech.Application.UnitTests/Persistence/GetEntitiesOptionsTests.cs +++ b/tests/Dfe.PlanTech.Application.UnitTests/Persistence/GetEntitiesOptionsTests.cs @@ -6,6 +6,42 @@ namespace Dfe.PlanTech.Application.UnitTests.Persistence; public class GetEntitiesOptionsTests { + private readonly Dictionary _testData; + + public GetEntitiesOptionsTests() + { + _testData = new Dictionary + { + { "Empty", new GetEntitiesOptions() }, + { "Include", new GetEntitiesOptions(include: 4) }, + { + "Select", new GetEntitiesOptions() + { + Select = ["field.intros", "field.sys"] + } + }, + { + "Query", new GetEntitiesOptions(queries: + [ + new ContentQueryEquals() { Field = "slug", Value = "/" }, + new ContentQueryEquals() { Field = "id", Value = "1234" }, + new ContentQueryIncludes() { Field = "toinclude", Value = ["value1", "value2"] } + ]) + }, + { + "Combined", new GetEntitiesOptions() + { + Select = ["field.intros", "field.sys"], + Queries = + [ + new ContentQueryEquals() { Field = "slug", Value = "/test" }, + ], + Include = 6 + } + } + }; + } + [Fact] public void Should_Set_Include() { @@ -39,4 +75,17 @@ public void Should_Have_Default_Values() Assert.Null(options.Queries); } + [Theory] + [InlineData("Empty", ":Include=2")] + [InlineData("Select", ":Include=2:Select=[field.intros,field.sys]")] + [InlineData("Include", ":Include=4")] + [InlineData("Query", ":Include=2:Queries=[slug=/,id=1234,toinclude=[value1,value2]]")] + [InlineData("Combined", ":Include=6:Select=[field.intros,field.sys]:Queries=[slug=/test]")] + public void Should_Serialise_Options_Into_Suitable_Redis_Format(string testDataKey, string expectedValue) + { + var testData = _testData[testDataKey]; + var serialized = testData.SerializeToRedisFormat(); + Assert.Equal(expectedValue, serialized); + } + } diff --git a/tests/Dfe.PlanTech.Application.UnitTests/Questionnaire/Queries/GetSectionQueryTests.cs b/tests/Dfe.PlanTech.Application.UnitTests/Questionnaire/Queries/GetSectionQueryTests.cs index bee621373..bcaf808e7 100644 --- a/tests/Dfe.PlanTech.Application.UnitTests/Questionnaire/Queries/GetSectionQueryTests.cs +++ b/tests/Dfe.PlanTech.Application.UnitTests/Questionnaire/Queries/GetSectionQueryTests.cs @@ -1,4 +1,3 @@ -using Dfe.PlanTech.Application.Caching.Interfaces; using Dfe.PlanTech.Application.Exceptions; using Dfe.PlanTech.Application.Persistence.Interfaces; using Dfe.PlanTech.Application.Persistence.Models; @@ -75,17 +74,6 @@ public class GetSectionQueryTests }; private readonly Section[] _sections = new[] { FirstSection, SecondSection, ThirdSection }; - private readonly ICmsCache _cache = Substitute.For(); - - public GetSectionQueryTests() - { - _cache.GetOrCreateAsync(Arg.Any(), Arg.Any?>>>()) - .Returns(callInfo => - { - var func = callInfo.ArgAt?>>>(1); - return func(); - }); - } [Fact] public async Task GetSectionBySlug_Returns_Section_From_Repository() @@ -108,7 +96,7 @@ public async Task GetSectionBySlug_Returns_Section_From_Repository() return _sections.Where(section => section.InterstitialPage?.Slug == slugQuery.Value); }); - var getSectionQuery = new GetSectionQuery(repository, _cache); + var getSectionQuery = new GetSectionQuery(repository); await getSectionQuery.GetSectionBySlug(sectionSlug, cancellationToken); Assert.Equal(FirstSection.InterstitialPage.Slug, sectionSlug); @@ -129,7 +117,7 @@ public async Task GetSectionBySlug_ThrowsExceptionOnRepositoryError() .When(repo => repo.GetEntities
(Arg.Any(), cancellationToken)) .Throw(new Exception("Dummy Exception")); - var getSectionQuery = new GetSectionQuery(repository, _cache); + var getSectionQuery = new GetSectionQuery(repository); await Assert.ThrowsAsync( async () => await getSectionQuery.GetSectionBySlug(sectionSlug, cancellationToken) @@ -144,7 +132,7 @@ public async Task GetAllSections_ThrowsExceptionOnRepositoryError() .When(repo => repo.GetEntities
(Arg.Any(), Arg.Any())) .Throw(new Exception("Dummy Exception")); - var getSectionQuery = new GetSectionQuery(repository, _cache); + var getSectionQuery = new GetSectionQuery(repository); await Assert.ThrowsAsync( async () => await getSectionQuery.GetAllSections(CancellationToken.None) @@ -158,7 +146,7 @@ public async Task GetAllSections_Should_Omit_NextQuestion_Answers() repository.GetEntities
(Arg.Any(), Arg.Any()) .Returns(_ => _sections); - var getSectionQuery = new GetSectionQuery(repository, _cache); + var getSectionQuery = new GetSectionQuery(repository); var sections = (await getSectionQuery.GetAllSections()).ToList(); Assert.Equal(_sections.Length, sections.Count); diff --git a/tests/Dfe.PlanTech.Infrastructure.Contentful.UnitTests/Persistence/CachedContentfulRepositoryTests.cs b/tests/Dfe.PlanTech.Infrastructure.Contentful.UnitTests/Persistence/CachedContentfulRepositoryTests.cs new file mode 100644 index 000000000..39bc0bbc2 --- /dev/null +++ b/tests/Dfe.PlanTech.Infrastructure.Contentful.UnitTests/Persistence/CachedContentfulRepositoryTests.cs @@ -0,0 +1,83 @@ +using Dfe.PlanTech.Application.Caching.Interfaces; +using Dfe.PlanTech.Application.Persistence.Interfaces; +using Dfe.PlanTech.Application.Persistence.Models; +using Dfe.PlanTech.Domain.Questionnaire.Models; +using Dfe.PlanTech.Infrastructure.Application.Models; +using Dfe.PlanTech.Infrastructure.Contentful.Persistence; +using NSubstitute; + +namespace Dfe.PlanTech.Infrastructure.Contentful.UnitTests.Persistence +{ + public class CachedContentfulRepositoryTests + { + private readonly IContentRepository _contentRepository = Substitute.For(); + private readonly ICmsCache _cache = Substitute.For(); + private readonly IContentRepository _cachedContentRepository; + + public CachedContentfulRepositoryTests() + { + _cachedContentRepository = new CachedContentfulRepository(_contentRepository, _cache); + _cache.GetOrCreateAsync(Arg.Any(), Arg.Any>>()) + .Returns(callInfo => + { + var func = callInfo.ArgAt>>(1); + return func(); + }); + _cache.GetOrCreateAsync(Arg.Any(), Arg.Any?>>>()) + .Returns(callInfo => + { + var func = callInfo.ArgAt?>>>(1); + return func(); + }); + _contentRepository + .GetEntityByIdOptions(Arg.Any(), Arg.Any()) + .Returns(callinfo => + { + var id = callinfo.ArgAt(0); + var include = callinfo.ArgAt(1); + return new GetEntitiesOptions(include, [ + new ContentQueryEquals() + { + Field = "sys.id", + Value = id + } + ]); + }); + } + + [Fact] + public async Task Should_Cache_GetEntities_Without_Options() + { + await _cachedContentRepository.GetEntities(); + await _cache.Received(1).GetOrCreateAsync(Arg.Any(), Arg.Any>>>()); + await _contentRepository.Received(1).GetEntities(); + } + + [Fact] + public async Task Should_Cache_GetEntities_With_Options() + { + var options = new GetEntitiesOptions(include: 3); + await _cachedContentRepository.GetEntities(options); + await _cache.Received(1).GetOrCreateAsync(Arg.Any(), Arg.Any>>>()); + await _contentRepository.Received(1).GetEntities(options); + } + + [Fact] + public async Task Should_Cache_GetEntityById() + { + var id = "test-id"; + await _cachedContentRepository.GetEntityById(id); + await _cache.Received(1).GetOrCreateAsync(Arg.Any(), Arg.Any>>>()); + await _contentRepository.Received(1).GetEntities( + Arg.Is(arg => ValidateGetEntitiesOptions(arg, id))); + } + + private static bool ValidateGetEntitiesOptions(GetEntitiesOptions arg, string id) + { + var first = arg.Queries?.FirstOrDefault(); + return first is ContentQueryEquals queryEquals && + queryEquals.Field == "sys.id" && + queryEquals.Value == id; + } + } +} diff --git a/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTestHelpers.cs b/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTestHelpers.cs index 4140d0777..62f237aad 100644 --- a/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTestHelpers.cs +++ b/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTestHelpers.cs @@ -28,4 +28,5 @@ public static class RedisCacheTestHelpers public static Answer FirstAnswer => _firstAnswer; public static Answer SecondAnswer => _secondAnswer; public static Question Question => _question; + public static List EmptyQuestionCollection => []; } diff --git a/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTests.cs b/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTests.cs index df7aba6d9..1b0ba4fae 100644 --- a/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTests.cs +++ b/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisCacheTests.cs @@ -12,6 +12,7 @@ public class RedisCacheTests : RedisCacheTestsBase private readonly IRedisConnectionManager _connectionManager = Substitute.For(); private readonly IRedisDependencyManager _dependencyManager = Substitute.For(); private readonly RedisCache _cache; + private readonly string _missingKey = "Missing"; public RedisCacheTests() { @@ -23,6 +24,12 @@ public RedisCacheTests() ); _connectionManager.GetDatabaseAsync(Arg.Any()).Returns(Database); + _dependencyManager.EmptyCollectionDependencyKey.Returns(_missingKey); + _dependencyManager.GetDependencyKey(Arg.Any()).Returns(callinfo => + { + var arg = callinfo.ArgAt(0); + return $"Dependency:{arg}"; + }); } [Fact] @@ -183,10 +190,13 @@ public async Task InvalidateCacheAsync_RemovesAllDependencies() { var firstAnswerKey = _dependencyManager.GetDependencyKey(RedisCacheTestHelpers.FirstAnswer.Sys.Id); var keys = new List { "one", "two", "three" }; + var missingKeys = new List { "four" }; var dependencies = keys.Select(dep => new RedisValue(dep)).ToArray(); + var missingDependencies = missingKeys.Select(dep => new RedisValue(dep)).ToArray(); Database.SetMembersAsync(firstAnswerKey, Arg.Any()).Returns(dependencies); - await _cache.InvalidateCacheAsync(RedisCacheTestHelpers.FirstAnswer.Sys.Id); + Database.SetMembersAsync(_missingKey, Arg.Any()).Returns(missingDependencies); + await _cache.InvalidateCacheAsync(RedisCacheTestHelpers.FirstAnswer.Sys.Id, "answer"); Assert.NotNull(QueuedFunc); @@ -195,5 +205,14 @@ await Database.Received(1).SetRemoveAsync( firstAnswerKey, Arg.Is(arg => dependencies.All(dep => arg.Contains(dep))), Arg.Any()); + + await Database.Received(1).SetRemoveAsync( + _missingKey, + Arg.Is(arg => missingDependencies.All(dep => arg.Contains(dep))), + Arg.Any()); + + await Database.Received(1).KeyDeleteAsync( + "answers", + Arg.Any()); } } diff --git a/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisDependencyManagerTests.cs b/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisDependencyManagerTests.cs index c12b7cf3c..77c4f4b91 100644 --- a/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisDependencyManagerTests.cs +++ b/tests/Dfe.PlanTech.Infrastructure.Redis.UnitTests/RedisDependencyManagerTests.cs @@ -30,6 +30,21 @@ public async Task RegisterDependencyAsync_StoresAllContentIds() await batch.Received(1).SetAddAsync(_dependencyManager.GetDependencyKey(RedisCacheTestHelpers.SecondAnswer.Sys.Id), Key, CommandFlags.FireAndForget); } + [Fact] + public async Task RegisterDependencyAsync_StoresEmptyCollectionsUnderMissing() + { + var batch = Substitute.For(); + Database.CreateBatch().Returns(batch); + + await _dependencyManager.RegisterDependenciesAsync(Database, Key, RedisCacheTestHelpers.EmptyQuestionCollection); + + Assert.NotNull(QueuedFunc); + + await QueuedFunc(default); + + await batch.Received(1).SetAddAsync(_dependencyManager.EmptyCollectionDependencyKey, Key, CommandFlags.FireAndForget); + } + [Fact] public void GetDependencies_ThrowsException_When_Value_NotIContentComponent() {