diff --git a/backend/src/Logitar.Cms.Contracts/Contents/SaveContentLocalePayload.cs b/backend/src/Logitar.Cms.Contracts/Contents/SaveContentLocalePayload.cs new file mode 100644 index 0000000..8c5a670 --- /dev/null +++ b/backend/src/Logitar.Cms.Contracts/Contents/SaveContentLocalePayload.cs @@ -0,0 +1,15 @@ +namespace Logitar.Cms.Contracts.Contents; + +public class SaveContentLocalePayload +{ + public string UniqueName { get; set; } + + public SaveContentLocalePayload() : this(string.Empty) + { + } + + public SaveContentLocalePayload(string uniqueName) + { + UniqueName = uniqueName; + } +} diff --git a/backend/src/Logitar.Cms.Core/Contents/CannotCreateInvariantLocaleException.cs b/backend/src/Logitar.Cms.Core/Contents/CannotCreateInvariantLocaleException.cs new file mode 100644 index 0000000..3229a8c --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Contents/CannotCreateInvariantLocaleException.cs @@ -0,0 +1,33 @@ +using Logitar.Cms.Contracts.Errors; + +namespace Logitar.Cms.Core.Contents; + +public class CannotCreateInvariantLocaleException : BadRequestException +{ + private const string ErrorMessage = "A locale cannot be created for an invariant content."; + + public string ContentId + { + get => (string)Data[nameof(ContentId)]!; + private set => Data[nameof(ContentId)] = value; + } + public string ContentTypeId + { + get => (string)Data[nameof(ContentTypeId)]!; + private set => Data[nameof(ContentTypeId)] = value; + } + + public override Error Error => new(this.GetErrorCode(), ErrorMessage); + + public CannotCreateInvariantLocaleException(ContentAggregate content) + : base(BuildMessage(content)) + { + ContentId = content.Id.Value; + ContentTypeId = content.ContentTypeId.Value; + } + + private static string BuildMessage(ContentAggregate content) => new ErrorMessageBuilder(ErrorMessage) + .AddData(nameof(ContentId), content.Id.Value) + .AddData(nameof(ContentTypeId), content.ContentTypeId.Value) + .Build(); +} diff --git a/backend/src/Logitar.Cms.Core/Contents/Commands/SaveContentLocaleCommand.cs b/backend/src/Logitar.Cms.Core/Contents/Commands/SaveContentLocaleCommand.cs new file mode 100644 index 0000000..97a4c9c --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Contents/Commands/SaveContentLocaleCommand.cs @@ -0,0 +1,6 @@ +using Logitar.Cms.Contracts.Contents; +using MediatR; + +namespace Logitar.Cms.Core.Contents.Commands; + +public record SaveContentLocaleCommand(Guid ContentId, Guid? LanguageId, SaveContentLocalePayload Payload) : Activity, IRequest; diff --git a/backend/src/Logitar.Cms.Core/Contents/Commands/SaveContentLocaleCommandHandler.cs b/backend/src/Logitar.Cms.Core/Contents/Commands/SaveContentLocaleCommandHandler.cs new file mode 100644 index 0000000..9510938 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Contents/Commands/SaveContentLocaleCommandHandler.cs @@ -0,0 +1,74 @@ +using FluentValidation; +using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Core.Contents.Validators; +using Logitar.Cms.Core.ContentTypes; +using Logitar.Cms.Core.Languages; +using Logitar.Identity.Contracts.Settings; +using Logitar.Identity.Domain.Shared; +using MediatR; + +namespace Logitar.Cms.Core.Contents.Commands; + +internal class SaveContentLocaleCommandHandler : IRequestHandler +{ + private readonly IContentQuerier _contentQuerier; + private readonly IContentRepository _contentRepository; + private readonly IContentTypeRepository _contentTypeRepository; + private readonly ILanguageRepository _languageRepository; + private readonly ISender _sender; + + public SaveContentLocaleCommandHandler( + IContentQuerier contentQuerier, + IContentRepository contentRepository, + IContentTypeRepository contentTypeRepository, + ILanguageRepository languageRepository, + ISender sender) + { + _contentQuerier = contentQuerier; + _contentRepository = contentRepository; + _contentTypeRepository = contentTypeRepository; + _languageRepository = languageRepository; + _sender = sender; + } + + public async Task Handle(SaveContentLocaleCommand command, CancellationToken cancellationToken) + { + IUniqueNameSettings uniqueNameSettings = ContentAggregate.UniqueNameSettings; + + SaveContentLocalePayload payload = command.Payload; + new SaveContentLocaleValidator(uniqueNameSettings).ValidateAndThrow(payload); + + ContentId contentId = new(command.ContentId); + ContentAggregate? content = await _contentRepository.LoadAsync(contentId, cancellationToken); + if (content == null) + { + return null; + } + + ContentLocaleUnit locale = new(new UniqueNameUnit(uniqueNameSettings, payload.UniqueName)); + + if (command.LanguageId.HasValue) + { + ContentTypeAggregate contentType = await _contentTypeRepository.LoadAsync(content.ContentTypeId, cancellationToken) + ?? throw new InvalidOperationException($"The content type aggregate 'Id={content.ContentTypeId.Value}' could not be found."); + if (contentType.IsInvariant) + { + throw new CannotCreateInvariantLocaleException(content); + } + + LanguageId languageId = new(command.LanguageId.Value); + LanguageAggregate language = await _languageRepository.LoadAsync(languageId, cancellationToken) + ?? throw new AggregateNotFoundException(languageId.AggregateId, nameof(command.LanguageId)); + + content.SetLocale(language, locale, command.ActorId); + } + else + { + content.SetInvariant(locale, command.ActorId); + } + + await _sender.Send(new SaveContentCommand(content), cancellationToken); + + return await _contentQuerier.ReadAsync(content, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs b/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs index ef273be..4c10ec0 100644 --- a/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs +++ b/backend/src/Logitar.Cms.Core/Contents/IContentRepository.cs @@ -6,6 +6,7 @@ namespace Logitar.Cms.Core.Contents; public interface IContentRepository { + Task LoadAsync(ContentId id, CancellationToken cancellationToken = default); Task LoadAsync(ContentTypeId contentTypeId, LanguageId? languageId, UniqueNameUnit uniqueName, CancellationToken cancellationToken = default); Task SaveAsync(ContentAggregate content, CancellationToken cancellationToken = default); diff --git a/backend/src/Logitar.Cms.Core/Contents/Validators/SaveContentLocaleValidator.cs b/backend/src/Logitar.Cms.Core/Contents/Validators/SaveContentLocaleValidator.cs new file mode 100644 index 0000000..dc7ed01 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Contents/Validators/SaveContentLocaleValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; +using Logitar.Cms.Contracts.Contents; +using Logitar.Identity.Contracts.Settings; +using Logitar.Identity.Domain.Shared; + +namespace Logitar.Cms.Core.Contents.Validators; + +public class SaveContentLocaleValidator : AbstractValidator +{ + public SaveContentLocaleValidator(IUniqueNameSettings uniqueNameSettings) + { + RuleFor(x => x.UniqueName).SetValidator(new UniqueNameValidator(uniqueNameSettings)); + } +} diff --git a/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs b/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs index bf5ed91..8516b22 100644 --- a/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs +++ b/backend/src/Logitar.Cms.Web/Controllers/ContentController.cs @@ -34,4 +34,18 @@ public async Task> ReadAsync(Guid id, CancellationToke ContentItem? content = await _pipeline.ExecuteAsync(new ReadContentQuery(id), cancellationToken); return content == null ? NotFound() : Ok(content); } + + [HttpPut("{contentId}/invariant")] + public async Task> SaveInvariantAsync(Guid contentId, [FromBody] SaveContentLocalePayload payload, CancellationToken cancellationToken) + { + ContentItem? content = await _pipeline.ExecuteAsync(new SaveContentLocaleCommand(contentId, LanguageId: null, payload), cancellationToken); + return content == null ? NotFound() : Ok(content); + } + + [HttpPut("{contentId}/locales/{languageId}")] + public async Task> SaveLocaleAsync(Guid contentId, Guid languageId, [FromBody] SaveContentLocalePayload payload, CancellationToken cancellationToken) + { + ContentItem? content = await _pipeline.ExecuteAsync(new SaveContentLocaleCommand(contentId, languageId, payload), cancellationToken); + return content == null ? NotFound() : Ok(content); + } } diff --git a/backend/tests/Logitar.Cms.Core.IntegrationTests/Contents/Commands/SaveContentLocaleCommandHandlerTests.cs b/backend/tests/Logitar.Cms.Core.IntegrationTests/Contents/Commands/SaveContentLocaleCommandHandlerTests.cs new file mode 100644 index 0000000..6c5f11e --- /dev/null +++ b/backend/tests/Logitar.Cms.Core.IntegrationTests/Contents/Commands/SaveContentLocaleCommandHandlerTests.cs @@ -0,0 +1,63 @@ +using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Core.ContentTypes; +using Logitar.Cms.Core.Languages; +using Logitar.Identity.Domain.Shared; +using Microsoft.Extensions.DependencyInjection; + +namespace Logitar.Cms.Core.Contents.Commands; + +[Trait(Traits.Category, Categories.Integration)] +public class SaveContentLocaleCommandHandlerTests : IntegrationTests +{ + private readonly IContentRepository _contentRepository; + private readonly IContentTypeRepository _contentTypeRepository; + private readonly ILanguageRepository _languageRepository; + + private readonly ContentTypeAggregate _contentType; + private readonly ContentAggregate _content; + + public SaveContentLocaleCommandHandlerTests() : base() + { + _contentRepository = ServiceProvider.GetRequiredService(); + _contentTypeRepository = ServiceProvider.GetRequiredService(); + _languageRepository = ServiceProvider.GetRequiredService(); + + _contentType = new(new IdentifierUnit("BlogArticle"), isInvariant: false, ActorId); + _content = new(_contentType, new ContentLocaleUnit(new UniqueNameUnit(ContentAggregate.UniqueNameSettings, "rendered-lego-acura-models")), ActorId); + } + + public override async Task InitializeAsync() + { + await base.InitializeAsync(); + + await _contentTypeRepository.SaveAsync(_contentType); + await _contentRepository.SaveAsync(_content); + } + + [Fact(DisplayName = "It should save a content locale.")] + public async Task It_should_save_a_content_locale() + { + LanguageAggregate language = Assert.Single(await _languageRepository.LoadAsync()); + + SaveContentLocalePayload payload = new("rendered-lego-acura-models-2"); + SaveContentLocaleCommand command = new(_content.Id.ToGuid(), language.Id.ToGuid(), payload); + ContentItem? content = await Pipeline.ExecuteAsync(command); + Assert.NotNull(content); + + Assert.Equal(_content.Id.ToGuid(), content.Id); + Assert.Equal(_content.Version + 1, content.Version); + Assert.Equal(Actor, content.UpdatedBy); + Assert.True(content.CreatedOn < content.UpdatedOn); + + ContentLocale locale = Assert.Single(content.Locales, l => l.Language != null); + Assert.NotEqual(default, locale.Id); + Assert.Same(content, locale.Item); + Assert.NotNull(locale.Language); + Assert.Equal(language.Id.ToGuid(), locale.Language.Id); + Assert.Equal(payload.UniqueName, locale.UniqueName); + Assert.Equal(Actor, locale.CreatedBy); + Assert.NotEqual(default, locale.CreatedOn); + Assert.Equal(Actor, locale.UpdatedBy); + Assert.Equal(locale.CreatedOn, locale.UpdatedOn); + } +} diff --git a/backend/tests/Logitar.Cms.Core.UnitTests/Contents/Commands/SaveContentLocaleCommandHandlerTests.cs b/backend/tests/Logitar.Cms.Core.UnitTests/Contents/Commands/SaveContentLocaleCommandHandlerTests.cs new file mode 100644 index 0000000..ee4fcb8 --- /dev/null +++ b/backend/tests/Logitar.Cms.Core.UnitTests/Contents/Commands/SaveContentLocaleCommandHandlerTests.cs @@ -0,0 +1,143 @@ +using FluentValidation.Results; +using Logitar.Cms.Contracts.Contents; +using Logitar.Cms.Core.ContentTypes; +using Logitar.Cms.Core.Languages; +using Logitar.Identity.Domain.Shared; +using MediatR; +using Moq; + +namespace Logitar.Cms.Core.Contents.Commands; + +[Trait(Traits.Category, Categories.Unit)] +public class SaveContentLocaleCommandHandlerTests +{ + private readonly CancellationToken _cancellationToken = default; + + private readonly Mock _contentQuerier = new(); + private readonly Mock _contentRepository = new(); + private readonly Mock _contentTypeRepository = new(); + private readonly Mock _languageRepository = new(); + private readonly Mock _sender = new(); + + private readonly SaveContentLocaleCommandHandler _handler; + + private readonly ContentTypeAggregate _articleType; + private readonly ContentTypeAggregate _authorType; + private readonly ContentAggregate _article; + private readonly ContentAggregate _author; + private readonly LanguageAggregate _english; + + public SaveContentLocaleCommandHandlerTests() + { + _handler = new(_contentQuerier.Object, _contentRepository.Object, _contentTypeRepository.Object, _languageRepository.Object, _sender.Object); + + _articleType = new(new IdentifierUnit("BlogArticle"), isInvariant: false); + _authorType = new(new IdentifierUnit("BlogAuthor"), isInvariant: true); + _article = new(_articleType, new ContentLocaleUnit(new UniqueNameUnit(ContentAggregate.UniqueNameSettings, "rendered-lego-acura-models"))); + _author = new(_authorType, new ContentLocaleUnit(new UniqueNameUnit(ContentAggregate.UniqueNameSettings, "ryan-hucks"))); + _english = new(new LocaleUnit("en"), isDefault: true); + } + + [Fact(DisplayName = "It should create a new locale.")] + public async Task It_should_create_a_new_locale() + { + _contentRepository.Setup(x => x.LoadAsync(_article.Id, _cancellationToken)).ReturnsAsync(_article); + _contentTypeRepository.Setup(x => x.LoadAsync(_articleType.Id, _cancellationToken)).ReturnsAsync(_articleType); + _languageRepository.Setup(x => x.LoadAsync(_english.Id, _cancellationToken)).ReturnsAsync(_english); + + SaveContentLocalePayload payload = new("rendered-lego-acura-models"); + SaveContentLocaleCommand command = new(_article.Id.ToGuid(), _english.Id.ToGuid(), payload); + ActivityHelper.Contextualize(command); + + await _handler.Handle(command, _cancellationToken); + + _sender.Verify(x => x.Send(It.Is(y => y.Content.Equals(_article)), _cancellationToken), Times.Once); + + ContentLocaleUnit? locale = _article.TryGetLocale(_english); + Assert.NotNull(locale); + Assert.Equal(payload.UniqueName, locale.UniqueName.Value); + } + + [Fact(DisplayName = "It should return null when the content cannot be found.")] + public async Task It_should_return_null_when_the_content_cannot_be_found() + { + SaveContentLocalePayload payload = new("rendered-lego-acura-models"); + SaveContentLocaleCommand command = new(ContentId: Guid.NewGuid(), LanguageId: null, payload); + Assert.Null(await _handler.Handle(command, _cancellationToken)); + } + + [Fact(DisplayName = "It should replace an existing locale.")] + public async Task It_should_replace_an_existing_locale() + { + _article.SetLocale(_english, _article.Invariant); + + _contentRepository.Setup(x => x.LoadAsync(_article.Id, _cancellationToken)).ReturnsAsync(_article); + _contentTypeRepository.Setup(x => x.LoadAsync(_articleType.Id, _cancellationToken)).ReturnsAsync(_articleType); + _languageRepository.Setup(x => x.LoadAsync(_english.Id, _cancellationToken)).ReturnsAsync(_english); + + SaveContentLocalePayload payload = new("rendered-lego-acura-models-2"); + SaveContentLocaleCommand command = new(_article.Id.ToGuid(), _english.Id.ToGuid(), payload); + ActivityHelper.Contextualize(command); + + await _handler.Handle(command, _cancellationToken); + + _sender.Verify(x => x.Send(It.Is(y => y.Content.Equals(_article)), _cancellationToken), Times.Once); + + ContentLocaleUnit? locale = _article.TryGetLocale(_english); + Assert.NotNull(locale); + Assert.Equal(payload.UniqueName, locale.UniqueName.Value); + } + + [Fact(DisplayName = "It should replace the content invariant.")] + public async Task It_should_replace_the_content_invariant() + { + _contentRepository.Setup(x => x.LoadAsync(_article.Id, _cancellationToken)).ReturnsAsync(_article); + + SaveContentLocalePayload payload = new("rendered-lego-acura-models-2"); + SaveContentLocaleCommand command = new(_article.Id.ToGuid(), LanguageId: null, payload); + ActivityHelper.Contextualize(command); + + await _handler.Handle(command, _cancellationToken); + + _sender.Verify(x => x.Send(It.Is(y => y.Content.Equals(_article)), _cancellationToken), Times.Once); + + Assert.Equal(payload.UniqueName, _article.Invariant.UniqueName.Value); + } + + [Fact(DisplayName = "It should throw AggregateNotFoundException when the language cannot be found.")] + public async Task It_should_throw_AggregateNotFoundException_when_the_language_cannot_be_found() + { + _contentRepository.Setup(x => x.LoadAsync(_article.Id, _cancellationToken)).ReturnsAsync(_article); + _contentTypeRepository.Setup(x => x.LoadAsync(_articleType.Id, _cancellationToken)).ReturnsAsync(_articleType); + + SaveContentLocalePayload payload = new("rendered-lego-acura-models"); + SaveContentLocaleCommand command = new(_article.Id.ToGuid(), _english.Id.ToGuid(), payload); + var exception = await Assert.ThrowsAsync>(async () => await _handler.Handle(command, _cancellationToken)); + Assert.Equal(_english.Id.Value, exception.Id); + Assert.Equal("LanguageId", exception.PropertyName); + } + + [Fact(DisplayName = "It should throw CannotCreateInvariantLocaleException when creating a locale to an invariant content.")] + public async Task It_should_throw_CannotCreateInvariantLocaleException_when_creating_a_locale_to_an_invariant_content() + { + _contentRepository.Setup(x => x.LoadAsync(_author.Id, _cancellationToken)).ReturnsAsync(_author); + _contentTypeRepository.Setup(x => x.LoadAsync(_authorType.Id, _cancellationToken)).ReturnsAsync(_authorType); + + SaveContentLocalePayload payload = new("ryan-hucks"); + SaveContentLocaleCommand command = new(_author.Id.ToGuid(), _english.Id.ToGuid(), payload); + var exception = await Assert.ThrowsAsync(async () => await _handler.Handle(command, _cancellationToken)); + Assert.Equal(_author.Id.Value, exception.ContentId); + Assert.Equal(_authorType.Id.Value, exception.ContentTypeId); + } + + [Fact(DisplayName = "It should throw ValidationException when the payload is not valid.")] + public async Task It_should_throw_ValidationException_when_the_payload_is_not_valid() + { + SaveContentLocalePayload payload = new("rendered-lego-acura-models!"); + SaveContentLocaleCommand command = new(ContentId: Guid.NewGuid(), LanguageId: null, payload); + var exception = await Assert.ThrowsAsync(async () => await _handler.Handle(command, _cancellationToken)); + ValidationFailure error = Assert.Single(exception.Errors); + Assert.Equal("AllowedCharactersValidator", error.ErrorCode); + Assert.Equal("UniqueName", error.PropertyName); + } +}