Skip to content

Commit

Permalink
Implemented content locale creation/replacement. (#21)
Browse files Browse the repository at this point in the history
  • Loading branch information
Utar94 authored Jul 13, 2024
1 parent 8db52f8 commit e20d4e9
Show file tree
Hide file tree
Showing 9 changed files with 363 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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<ContentItem?>;
Original file line number Diff line number Diff line change
@@ -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<SaveContentLocaleCommand, ContentItem?>
{
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<ContentItem?> 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<LanguageAggregate>(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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ namespace Logitar.Cms.Core.Contents;

public interface IContentRepository
{
Task<ContentAggregate?> LoadAsync(ContentId id, CancellationToken cancellationToken = default);
Task<ContentAggregate?> LoadAsync(ContentTypeId contentTypeId, LanguageId? languageId, UniqueNameUnit uniqueName, CancellationToken cancellationToken = default);

Task SaveAsync(ContentAggregate content, CancellationToken cancellationToken = default);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<SaveContentLocalePayload>
{
public SaveContentLocaleValidator(IUniqueNameSettings uniqueNameSettings)
{
RuleFor(x => x.UniqueName).SetValidator(new UniqueNameValidator(uniqueNameSettings));
}
}
14 changes: 14 additions & 0 deletions backend/src/Logitar.Cms.Web/Controllers/ContentController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,18 @@ public async Task<ActionResult<ContentItem>> 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<ActionResult<ContentItem>> 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<ActionResult<ContentItem>> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<IContentRepository>();
_contentTypeRepository = ServiceProvider.GetRequiredService<IContentTypeRepository>();
_languageRepository = ServiceProvider.GetRequiredService<ILanguageRepository>();

_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);
}
}
Original file line number Diff line number Diff line change
@@ -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<IContentQuerier> _contentQuerier = new();
private readonly Mock<IContentRepository> _contentRepository = new();
private readonly Mock<IContentTypeRepository> _contentTypeRepository = new();
private readonly Mock<ILanguageRepository> _languageRepository = new();
private readonly Mock<ISender> _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<SaveContentCommand>(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<SaveContentCommand>(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<SaveContentCommand>(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<AggregateNotFoundException<LanguageAggregate>>(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<CannotCreateInvariantLocaleException>(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<FluentValidation.ValidationException>(async () => await _handler.Handle(command, _cancellationToken));
ValidationFailure error = Assert.Single(exception.Errors);
Assert.Equal("AllowedCharactersValidator", error.ErrorCode);
Assert.Equal("UniqueName", error.PropertyName);
}
}

0 comments on commit e20d4e9

Please sign in to comment.