From a67601e95460dc5c6278c7a2f9c1f7b51ee9ed1c Mon Sep 17 00:00:00 2001 From: Francis Pion Date: Wed, 18 Dec 2024 15:28:13 -0500 Subject: [PATCH] Implementing Language management. (#43) --- .github/workflows/.gitkeep | 0 .github/workflows/backend-build.yaml | 40 ++++++ backend/Cms.sln | 71 +++------- backend/src/.gitkeep | 0 .../Logitar.Cms.Core/BadRequestException.cs | 10 ++ .../src/Logitar.Cms.Core/ConflictException.cs | 10 ++ backend/src/Logitar.Cms.Core/Errors/Error.cs | 23 +++ .../src/Logitar.Cms.Core/Errors/ErrorData.cs | 23 +++ .../Logitar.Cms.Core/Errors/ErrorException.cs | 10 ++ .../Logitar.Cms.Core/IApplicationContext.cs | 8 ++ .../CreateOrReplaceLanguageCommand.cs | 76 ++++++++++ .../Commands/SaveLanguageCommand.cs | 35 +++++ .../Commands/SetDefaultLanguageCommand.cs | 46 ++++++ .../Commands/UpdateLanguageCommand.cs | 52 +++++++ .../Localization/Events/LanguageCreated.cs | 6 + .../Localization/Events/LanguageSetDefault.cs | 6 + .../Localization/Events/LanguageUpdated.cs | 12 ++ .../Localization/ILanguageQuerier.cs | 16 +++ .../Localization/ILanguageRepository.cs | 10 ++ .../Logitar.Cms.Core/Localization/Language.cs | 72 ++++++++++ .../Localization/LanguageId.cs | 33 +++++ .../Logitar.Cms.Core/Localization/Locale.cs | 34 +++++ .../LocaleAlreadyUsedException.cs | 51 +++++++ .../Models/CreateOrReplaceLanguagePayload.cs | 15 ++ .../Localization/Models/LanguageModel.cs | 23 +++ .../Localization/Models/LocaleModel.cs | 33 +++++ .../Models/UpdateLanguagePayload.cs | 6 + .../Localization/Queries/ReadLanguageQuery.cs | 50 +++++++ .../CreateOrReplaceLanguageValidator.cs | 12 ++ .../Validators/UpdateLanguageValidator.cs | 12 ++ .../Logitar.Cms.Core/Logitar.Cms.Core.csproj | 34 +++++ .../src/Logitar.Cms.Core/Models/ActorModel.cs | 37 +++++ .../src/Logitar.Cms.Core/Models/ActorType.cs | 8 ++ .../Logitar.Cms.Core/Models/AggregateModel.cs | 17 +++ .../TooManyResultsException.cs | 52 +++++++ .../Logitar.Cms.Core/ValidationExtensions.cs | 12 ++ .../Validators/LocaleValidator.cs | 30 ++++ .../Controllers/LanguageController.cs | 83 +++++++++++ .../DependencyInjectionExtensions.cs | 12 ++ .../Logitar.Cms.Web/Logitar.Cms.Web.csproj | 25 ++++ backend/src/Logitar.Cms.Web/Program.cs | 9 ++ .../Properties/launchSettings.json | 12 ++ backend/tests/.gitkeep | 0 .../tests/Logitar.Cms.UnitTests/Categories.cs | 6 + ...ateOrReplaceLanguageCommandHandlerTests.cs | 131 ++++++++++++++++++ .../SaveLanguageCommandHandlerTests.cs | 46 ++++++ .../SetDefaultLanguageCommandHandler.cs | 67 +++++++++ .../UpdateLanguageCommandHandlerTests.cs | 74 ++++++++++ .../Queries/ReadLanguageQueryHandlerTests.cs | 87 ++++++++++++ .../Logitar.Cms.UnitTests.csproj | 38 +++++ backend/tests/Logitar.Cms.UnitTests/Traits.cs | 6 + 51 files changed, 1527 insertions(+), 54 deletions(-) delete mode 100644 .github/workflows/.gitkeep create mode 100644 .github/workflows/backend-build.yaml delete mode 100644 backend/src/.gitkeep create mode 100644 backend/src/Logitar.Cms.Core/BadRequestException.cs create mode 100644 backend/src/Logitar.Cms.Core/ConflictException.cs create mode 100644 backend/src/Logitar.Cms.Core/Errors/Error.cs create mode 100644 backend/src/Logitar.Cms.Core/Errors/ErrorData.cs create mode 100644 backend/src/Logitar.Cms.Core/Errors/ErrorException.cs create mode 100644 backend/src/Logitar.Cms.Core/IApplicationContext.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Commands/CreateOrReplaceLanguageCommand.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Commands/SaveLanguageCommand.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Commands/SetDefaultLanguageCommand.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Commands/UpdateLanguageCommand.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Events/LanguageCreated.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Events/LanguageSetDefault.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Events/LanguageUpdated.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/ILanguageQuerier.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/ILanguageRepository.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Language.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/LanguageId.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Locale.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/LocaleAlreadyUsedException.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Models/CreateOrReplaceLanguagePayload.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Models/LanguageModel.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Models/LocaleModel.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Models/UpdateLanguagePayload.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Queries/ReadLanguageQuery.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Validators/CreateOrReplaceLanguageValidator.cs create mode 100644 backend/src/Logitar.Cms.Core/Localization/Validators/UpdateLanguageValidator.cs create mode 100644 backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj create mode 100644 backend/src/Logitar.Cms.Core/Models/ActorModel.cs create mode 100644 backend/src/Logitar.Cms.Core/Models/ActorType.cs create mode 100644 backend/src/Logitar.Cms.Core/Models/AggregateModel.cs create mode 100644 backend/src/Logitar.Cms.Core/TooManyResultsException.cs create mode 100644 backend/src/Logitar.Cms.Core/ValidationExtensions.cs create mode 100644 backend/src/Logitar.Cms.Core/Validators/LocaleValidator.cs create mode 100644 backend/src/Logitar.Cms.Web/Controllers/LanguageController.cs create mode 100644 backend/src/Logitar.Cms.Web/DependencyInjectionExtensions.cs create mode 100644 backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj create mode 100644 backend/src/Logitar.Cms.Web/Program.cs create mode 100644 backend/src/Logitar.Cms.Web/Properties/launchSettings.json delete mode 100644 backend/tests/.gitkeep create mode 100644 backend/tests/Logitar.Cms.UnitTests/Categories.cs create mode 100644 backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/CreateOrReplaceLanguageCommandHandlerTests.cs create mode 100644 backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SaveLanguageCommandHandlerTests.cs create mode 100644 backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SetDefaultLanguageCommandHandler.cs create mode 100644 backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/UpdateLanguageCommandHandlerTests.cs create mode 100644 backend/tests/Logitar.Cms.UnitTests/Core/Localization/Queries/ReadLanguageQueryHandlerTests.cs create mode 100644 backend/tests/Logitar.Cms.UnitTests/Logitar.Cms.UnitTests.csproj create mode 100644 backend/tests/Logitar.Cms.UnitTests/Traits.cs diff --git a/.github/workflows/.gitkeep b/.github/workflows/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/backend-build.yaml b/.github/workflows/backend-build.yaml new file mode 100644 index 0000000..a7a6141 --- /dev/null +++ b/.github/workflows/backend-build.yaml @@ -0,0 +1,40 @@ +name: Build CMS Backend + +on: + push: + branches: + - main + paths: + - 'backend/**' + pull_request: + branches: + - main + paths: + - 'backend/**' + workflow_dispatch: + +defaults: + run: + working-directory: ./backend + +jobs: + build: + name: Build CMS Backend + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Setup .NET9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build Solution + run: dotnet build --no-restore + + - name: Test Solution + run: dotnet test --no-build --verbosity normal --filter Category=Unit diff --git a/backend/Cms.sln b/backend/Cms.sln index 48c5a8d..28e2933 100644 --- a/backend/Cms.sln +++ b/backend/Cms.sln @@ -12,25 +12,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Contracts", "src\Logitar.Cms.Contracts\Logitar.Cms.Contracts.csproj", "{02C0849F-005D-43A1-9EED-4CA0998C35FA}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.Core", "src\Logitar.Cms.Core\Logitar.Cms.Core.csproj", "{382907CD-18AF-45DE-A1AB-263353F0A9C8}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Core", "src\Logitar.Cms.Core\Logitar.Cms.Core.csproj", "{CABB3261-5723-4A0A-B373-DB4758092E97}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.Web", "src\Logitar.Cms.Web\Logitar.Cms.Web.csproj", "{134EAA3D-D3A5-45F0-982B-D58BF1434B0F}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C73FCDEC-8D2B-4E48-81BF-BD86ACBBB07C}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{49A3AE69-C12F-465C-8C4E-CCE766C2414E}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Core.UnitTests", "tests\Logitar.Cms.Core.UnitTests\Logitar.Cms.Core.UnitTests.csproj", "{411F8113-64F4-4C79-B445-4F716E300144}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.UnitTests", "tests\Logitar.Cms.UnitTests\Logitar.Cms.UnitTests.csproj", "{8392DAB5-9FEF-4FD9-8747-EBE684E06BE1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.Infrastructure", "src\Logitar.Cms.Infrastructure\Logitar.Cms.Infrastructure.csproj", "{588FCFB1-B0FE-4598-8B31-B4F7494B94D4}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.EntityFrameworkCore", "src\Logitar.Cms.EntityFrameworkCore\Logitar.Cms.EntityFrameworkCore.csproj", "{6786E254-83CF-4FEC-B3A3-185AACAD9705}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms.EntityFrameworkCore.SqlServer", "src\Logitar.Cms.EntityFrameworkCore.SqlServer\Logitar.Cms.EntityFrameworkCore.SqlServer.csproj", "{5F740A3A-5598-4B0B-BFE5-DF7E0781232D}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Logitar.Cms", "src\Logitar.Cms\Logitar.Cms.csproj", "{C06B0081-9E08-4416-98A5-30538C18F1E7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.Web", "src\Logitar.Cms.Web\Logitar.Cms.Web.csproj", "{0144EB23-8A46-4823-861B-8E67F3A154B7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logitar.Cms.UnitTests", "tests\Logitar.Cms.UnitTests\Logitar.Cms.UnitTests.csproj", "{F367B008-1CAD-49A5-9EB1-5160661FF9CC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -38,49 +26,24 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {02C0849F-005D-43A1-9EED-4CA0998C35FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {02C0849F-005D-43A1-9EED-4CA0998C35FA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {02C0849F-005D-43A1-9EED-4CA0998C35FA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {02C0849F-005D-43A1-9EED-4CA0998C35FA}.Release|Any CPU.Build.0 = Release|Any CPU - {CABB3261-5723-4A0A-B373-DB4758092E97}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {CABB3261-5723-4A0A-B373-DB4758092E97}.Debug|Any CPU.Build.0 = Debug|Any CPU - {CABB3261-5723-4A0A-B373-DB4758092E97}.Release|Any CPU.ActiveCfg = Release|Any CPU - {CABB3261-5723-4A0A-B373-DB4758092E97}.Release|Any CPU.Build.0 = Release|Any CPU - {411F8113-64F4-4C79-B445-4F716E300144}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {411F8113-64F4-4C79-B445-4F716E300144}.Debug|Any CPU.Build.0 = Debug|Any CPU - {411F8113-64F4-4C79-B445-4F716E300144}.Release|Any CPU.ActiveCfg = Release|Any CPU - {411F8113-64F4-4C79-B445-4F716E300144}.Release|Any CPU.Build.0 = Release|Any CPU - {8392DAB5-9FEF-4FD9-8747-EBE684E06BE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8392DAB5-9FEF-4FD9-8747-EBE684E06BE1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8392DAB5-9FEF-4FD9-8747-EBE684E06BE1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8392DAB5-9FEF-4FD9-8747-EBE684E06BE1}.Release|Any CPU.Build.0 = Release|Any CPU - {588FCFB1-B0FE-4598-8B31-B4F7494B94D4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {588FCFB1-B0FE-4598-8B31-B4F7494B94D4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {588FCFB1-B0FE-4598-8B31-B4F7494B94D4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {588FCFB1-B0FE-4598-8B31-B4F7494B94D4}.Release|Any CPU.Build.0 = Release|Any CPU - {6786E254-83CF-4FEC-B3A3-185AACAD9705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6786E254-83CF-4FEC-B3A3-185AACAD9705}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6786E254-83CF-4FEC-B3A3-185AACAD9705}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6786E254-83CF-4FEC-B3A3-185AACAD9705}.Release|Any CPU.Build.0 = Release|Any CPU - {5F740A3A-5598-4B0B-BFE5-DF7E0781232D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5F740A3A-5598-4B0B-BFE5-DF7E0781232D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5F740A3A-5598-4B0B-BFE5-DF7E0781232D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5F740A3A-5598-4B0B-BFE5-DF7E0781232D}.Release|Any CPU.Build.0 = Release|Any CPU - {C06B0081-9E08-4416-98A5-30538C18F1E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C06B0081-9E08-4416-98A5-30538C18F1E7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C06B0081-9E08-4416-98A5-30538C18F1E7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C06B0081-9E08-4416-98A5-30538C18F1E7}.Release|Any CPU.Build.0 = Release|Any CPU - {0144EB23-8A46-4823-861B-8E67F3A154B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0144EB23-8A46-4823-861B-8E67F3A154B7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0144EB23-8A46-4823-861B-8E67F3A154B7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0144EB23-8A46-4823-861B-8E67F3A154B7}.Release|Any CPU.Build.0 = Release|Any CPU + {382907CD-18AF-45DE-A1AB-263353F0A9C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {382907CD-18AF-45DE-A1AB-263353F0A9C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {382907CD-18AF-45DE-A1AB-263353F0A9C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {382907CD-18AF-45DE-A1AB-263353F0A9C8}.Release|Any CPU.Build.0 = Release|Any CPU + {134EAA3D-D3A5-45F0-982B-D58BF1434B0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {134EAA3D-D3A5-45F0-982B-D58BF1434B0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {134EAA3D-D3A5-45F0-982B-D58BF1434B0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {134EAA3D-D3A5-45F0-982B-D58BF1434B0F}.Release|Any CPU.Build.0 = Release|Any CPU + {F367B008-1CAD-49A5-9EB1-5160661FF9CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F367B008-1CAD-49A5-9EB1-5160661FF9CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F367B008-1CAD-49A5-9EB1-5160661FF9CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F367B008-1CAD-49A5-9EB1-5160661FF9CC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {411F8113-64F4-4C79-B445-4F716E300144} = {C73FCDEC-8D2B-4E48-81BF-BD86ACBBB07C} - {8392DAB5-9FEF-4FD9-8747-EBE684E06BE1} = {C73FCDEC-8D2B-4E48-81BF-BD86ACBBB07C} + {F367B008-1CAD-49A5-9EB1-5160661FF9CC} = {49A3AE69-C12F-465C-8C4E-CCE766C2414E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DA500B33-B3CA-431A-80D8-55CD95479A70} diff --git a/backend/src/.gitkeep b/backend/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/Logitar.Cms.Core/BadRequestException.cs b/backend/src/Logitar.Cms.Core/BadRequestException.cs new file mode 100644 index 0000000..e96025a --- /dev/null +++ b/backend/src/Logitar.Cms.Core/BadRequestException.cs @@ -0,0 +1,10 @@ +using Logitar.Cms.Core.Errors; + +namespace Logitar.Cms.Core; + +public abstract class BadRequestException : ErrorException +{ + protected BadRequestException(string? message, Exception? innerException = null) : base(message, innerException) + { + } +} diff --git a/backend/src/Logitar.Cms.Core/ConflictException.cs b/backend/src/Logitar.Cms.Core/ConflictException.cs new file mode 100644 index 0000000..efbe757 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/ConflictException.cs @@ -0,0 +1,10 @@ +using Logitar.Cms.Core.Errors; + +namespace Logitar.Cms.Core; + +public abstract class ConflictException : ErrorException +{ + protected ConflictException(string? message, Exception? innerException = null) : base(message, innerException) + { + } +} diff --git a/backend/src/Logitar.Cms.Core/Errors/Error.cs b/backend/src/Logitar.Cms.Core/Errors/Error.cs new file mode 100644 index 0000000..c6176bf --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Errors/Error.cs @@ -0,0 +1,23 @@ +namespace Logitar.Cms.Core.Errors; + +public record Error +{ + public string Code { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + public IReadOnlyCollection Data { get; init; } = []; + + public Error() + { + } + + public Error(string code, string message, IEnumerable? data = null) + { + Code = code; + Message = message; + + if (data != null) + { + Data = data.ToArray(); + } + } +} diff --git a/backend/src/Logitar.Cms.Core/Errors/ErrorData.cs b/backend/src/Logitar.Cms.Core/Errors/ErrorData.cs new file mode 100644 index 0000000..6c73ea2 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Errors/ErrorData.cs @@ -0,0 +1,23 @@ +namespace Logitar.Cms.Core.Errors; + +public record ErrorData +{ + public string Key { get; init; } = string.Empty; + public object? Value { get; init; } + + public ErrorData() + { + } + + public ErrorData(KeyValuePair pair) + { + Key = pair.Key; + Value = pair.Value; + } + + public ErrorData(string key, object? value) + { + Key = key; + Value = value; + } +} diff --git a/backend/src/Logitar.Cms.Core/Errors/ErrorException.cs b/backend/src/Logitar.Cms.Core/Errors/ErrorException.cs new file mode 100644 index 0000000..ef62d09 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Errors/ErrorException.cs @@ -0,0 +1,10 @@ +namespace Logitar.Cms.Core.Errors; + +public abstract class ErrorException : Exception +{ + public abstract Error Error { get; } + + protected ErrorException(string? message, Exception? innerException = null) : base(message, innerException) + { + } +} diff --git a/backend/src/Logitar.Cms.Core/IApplicationContext.cs b/backend/src/Logitar.Cms.Core/IApplicationContext.cs new file mode 100644 index 0000000..74b536c --- /dev/null +++ b/backend/src/Logitar.Cms.Core/IApplicationContext.cs @@ -0,0 +1,8 @@ +using Logitar.EventSourcing; + +namespace Logitar.Cms.Core; + +public interface IApplicationContext +{ + ActorId? ActorId { get; } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Commands/CreateOrReplaceLanguageCommand.cs b/backend/src/Logitar.Cms.Core/Localization/Commands/CreateOrReplaceLanguageCommand.cs new file mode 100644 index 0000000..74a7ea1 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Commands/CreateOrReplaceLanguageCommand.cs @@ -0,0 +1,76 @@ +using FluentValidation; +using Logitar.Cms.Core.Localization.Models; +using Logitar.Cms.Core.Localization.Validators; +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Commands; + +public record CreateOrReplaceLanguageResult(LanguageModel? Language = null, bool Created = false); + +public record CreateOrReplaceLanguageCommand(Guid? Id, CreateOrReplaceLanguagePayload Payload, long? Version) : IRequest; + +internal class CreateOrReplaceLanguageCommandHandler : IRequestHandler +{ + private readonly IApplicationContext _applicationContext; + private readonly ILanguageQuerier _languageQuerier; + private readonly ILanguageRepository _languageRepository; + private readonly IMediator _mediator; + + public CreateOrReplaceLanguageCommandHandler( + IApplicationContext applicationContext, + ILanguageQuerier languageQuerier, + ILanguageRepository languageRepository, + IMediator mediator) + { + _applicationContext = applicationContext; + _languageQuerier = languageQuerier; + _languageRepository = languageRepository; + _mediator = mediator; + } + + public async Task Handle(CreateOrReplaceLanguageCommand command, CancellationToken cancellationToken) + { + CreateOrReplaceLanguagePayload payload = command.Payload; + new CreateOrReplaceLanguageValidator().ValidateAndThrow(payload); + + ActorId? actorId = _applicationContext.ActorId; + Locale locale = new(payload.Locale); + + LanguageId? languageId = null; + Language? language = null; + if (command.Id.HasValue) + { + languageId = new(command.Id.Value); + language = await _languageRepository.LoadAsync(languageId.Value, cancellationToken); + } + + bool created = false; + if (language == null) + { + if (command.Version.HasValue) + { + return new CreateOrReplaceLanguageResult(); + } + + language = new(locale, isDefault: false, actorId, languageId); + created = true; + } + + Language reference = (command.Version.HasValue + ? await _languageRepository.LoadAsync(language.Id, command.Version.Value, cancellationToken) + : null) ?? language; + + if (reference.Locale != locale) + { + language.Locale = locale; + } + + language.Update(actorId); + + await _mediator.Send(new SaveLanguageCommand(language), cancellationToken); + + LanguageModel model = await _languageQuerier.ReadAsync(language, cancellationToken); + return new CreateOrReplaceLanguageResult(model, created); + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Commands/SaveLanguageCommand.cs b/backend/src/Logitar.Cms.Core/Localization/Commands/SaveLanguageCommand.cs new file mode 100644 index 0000000..64c74ef --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Commands/SaveLanguageCommand.cs @@ -0,0 +1,35 @@ +using Logitar.Cms.Core.Localization.Events; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Commands; + +public record SaveLanguageCommand(Language Language) : IRequest; + +internal class SaveLanguageCommandHandler : IRequestHandler +{ + private readonly ILanguageQuerier _languageQuerier; + private readonly ILanguageRepository _languageRepository; + + public SaveLanguageCommandHandler(ILanguageQuerier languageQuerier, ILanguageRepository languageRepository) + { + _languageQuerier = languageQuerier; + _languageRepository = languageRepository; + } + + public async Task Handle(SaveLanguageCommand command, CancellationToken cancellationToken) + { + Language language = command.Language; + + bool hasLocaleChanged = language.Changes.Any(change => change is LanguageCreated || change is LanguageUpdated updated && updated.Locale != null); + if (hasLocaleChanged) + { + LanguageId? conflictId = await _languageQuerier.FindIdAsync(language.Locale, cancellationToken); + if (conflictId != null && !conflictId.Equals(language.Id)) + { + throw new LocaleAlreadyUsedException(language, conflictId.Value); + } + } + + await _languageRepository.SaveAsync(language, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Commands/SetDefaultLanguageCommand.cs b/backend/src/Logitar.Cms.Core/Localization/Commands/SetDefaultLanguageCommand.cs new file mode 100644 index 0000000..eb699f1 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Commands/SetDefaultLanguageCommand.cs @@ -0,0 +1,46 @@ +using Logitar.Cms.Core.Localization.Models; +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Commands; + +public record SetDefaultLanguageCommand(Guid Id) : IRequest; + +internal class SetDefaultLanguageCommandHandler : IRequestHandler +{ + private readonly IApplicationContext _applicationContext; + private readonly ILanguageQuerier _languageQuerier; + private readonly ILanguageRepository _languageRepository; + + public SetDefaultLanguageCommandHandler(IApplicationContext applicationContext, ILanguageQuerier languageQuerier, ILanguageRepository languageRepository) + { + _applicationContext = applicationContext; + _languageQuerier = languageQuerier; + _languageRepository = languageRepository; + } + + public async Task Handle(SetDefaultLanguageCommand command, CancellationToken cancellationToken) + { + LanguageId languageId = new(command.Id); + Language? language = await _languageRepository.LoadAsync(languageId, cancellationToken); + if (language == null) + { + return null; + } + + if (!language.IsDefault) + { + LanguageId defaultId = await _languageQuerier.FindDefaultIdAsync(cancellationToken); + Language @default = await _languageRepository.LoadAsync(defaultId, cancellationToken) + ?? throw new InvalidOperationException($"The default language 'Id={defaultId}' could not be loaded."); + + ActorId? actorId = _applicationContext.ActorId; + @default.SetDefault(false, actorId); + language.SetDefault(true, actorId); + + await _languageRepository.SaveAsync([@default, language], cancellationToken); + } + + return await _languageQuerier.ReadAsync(language, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Commands/UpdateLanguageCommand.cs b/backend/src/Logitar.Cms.Core/Localization/Commands/UpdateLanguageCommand.cs new file mode 100644 index 0000000..fb9f983 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Commands/UpdateLanguageCommand.cs @@ -0,0 +1,52 @@ +using FluentValidation; +using Logitar.Cms.Core.Localization.Models; +using Logitar.Cms.Core.Localization.Validators; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Commands; + +public record UpdateLanguageCommand(Guid Id, UpdateLanguagePayload Payload) : IRequest; + +internal class UpdateLanguageCommandHandler : IRequestHandler +{ + private readonly IApplicationContext _applicationContext; + private readonly ILanguageQuerier _languageQuerier; + private readonly ILanguageRepository _languageRepository; + private readonly IMediator _mediator; + + public UpdateLanguageCommandHandler( + IApplicationContext applicationContext, + ILanguageQuerier languageQuerier, + ILanguageRepository languageRepository, + IMediator mediator) + { + _applicationContext = applicationContext; + _languageQuerier = languageQuerier; + _languageRepository = languageRepository; + _mediator = mediator; + } + + public async Task Handle(UpdateLanguageCommand command, CancellationToken cancellationToken) + { + UpdateLanguagePayload payload = command.Payload; + new UpdateLanguageValidator().ValidateAndThrow(payload); + + LanguageId languageId = new(command.Id); + Language? language = await _languageRepository.LoadAsync(languageId, cancellationToken); + if (language == null) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(payload.Locale)) + { + language.Locale = new Locale(payload.Locale); + } + + language.Update(_applicationContext.ActorId); + + await _mediator.Send(new SaveLanguageCommand(language), cancellationToken); + + return await _languageQuerier.ReadAsync(language, cancellationToken); + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Events/LanguageCreated.cs b/backend/src/Logitar.Cms.Core/Localization/Events/LanguageCreated.cs new file mode 100644 index 0000000..fb597ca --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Events/LanguageCreated.cs @@ -0,0 +1,6 @@ +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Events; + +public record LanguageCreated(Locale Locale, bool IsDefault) : DomainEvent, INotification; diff --git a/backend/src/Logitar.Cms.Core/Localization/Events/LanguageSetDefault.cs b/backend/src/Logitar.Cms.Core/Localization/Events/LanguageSetDefault.cs new file mode 100644 index 0000000..60536eb --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Events/LanguageSetDefault.cs @@ -0,0 +1,6 @@ +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Events; + +public record LanguageSetDefault(bool IsDefault) : DomainEvent, INotification; diff --git a/backend/src/Logitar.Cms.Core/Localization/Events/LanguageUpdated.cs b/backend/src/Logitar.Cms.Core/Localization/Events/LanguageUpdated.cs new file mode 100644 index 0000000..ec0d83f --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Events/LanguageUpdated.cs @@ -0,0 +1,12 @@ +using Logitar.EventSourcing; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Events; + +public record LanguageUpdated : DomainEvent, INotification +{ + public Locale? Locale { get; set; } + + [JsonIgnore] + public bool HasChanges => Locale != null; +} diff --git a/backend/src/Logitar.Cms.Core/Localization/ILanguageQuerier.cs b/backend/src/Logitar.Cms.Core/Localization/ILanguageQuerier.cs new file mode 100644 index 0000000..04f077f --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/ILanguageQuerier.cs @@ -0,0 +1,16 @@ +using Logitar.Cms.Core.Localization.Models; + +namespace Logitar.Cms.Core.Localization; + +public interface ILanguageQuerier +{ + Task FindDefaultIdAsync(CancellationToken cancellationToken = default); + Task FindIdAsync(Locale locale, CancellationToken cancellationToken = default); + + Task ReadAsync(Language language, CancellationToken cancellationToken = default); + Task ReadAsync(LanguageId id, CancellationToken cancellationToken = default); + Task ReadAsync(Guid id, CancellationToken cancellationToken = default); + Task ReadAsync(string locale, CancellationToken cancellationToken = default); + + Task ReadDefaultAsync(CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Cms.Core/Localization/ILanguageRepository.cs b/backend/src/Logitar.Cms.Core/Localization/ILanguageRepository.cs new file mode 100644 index 0000000..51ab78f --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/ILanguageRepository.cs @@ -0,0 +1,10 @@ +namespace Logitar.Cms.Core.Localization; + +public interface ILanguageRepository +{ + Task LoadAsync(LanguageId id, CancellationToken cancellationToken = default); + Task LoadAsync(LanguageId id, long? version, CancellationToken cancellationToken = default); + + Task SaveAsync(Language language, CancellationToken cancellationToken = default); + Task SaveAsync(IEnumerable languages, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Language.cs b/backend/src/Logitar.Cms.Core/Localization/Language.cs new file mode 100644 index 0000000..ee41138 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Language.cs @@ -0,0 +1,72 @@ +using Logitar.Cms.Core.Localization.Events; +using Logitar.EventSourcing; + +namespace Logitar.Cms.Core.Localization; + +public class Language : AggregateRoot +{ + private LanguageUpdated _updated = new(); + + public new LanguageId Id => new(base.Id); + + public bool IsDefault { get; private set; } + + private Locale? _locale = null; + public Locale Locale + { + get => _locale ?? throw new InvalidOperationException($"The {nameof(Locale)} has not been initialized yet."); + set + { + if (_locale != value) + { + _locale = value; + _updated.Locale = value; + } + } + } + + public Language() : base() + { + } + + public Language(Locale locale, bool isDefault = false, ActorId? actorId = null, LanguageId? id = null) : base((id ?? LanguageId.NewId()).StreamId) + { + Raise(new LanguageCreated(locale, isDefault), actorId); + } + protected virtual void Handle(LanguageCreated @event) + { + IsDefault = @event.IsDefault; + + _locale = @event.Locale; + } + + public void SetDefault(bool isDefault = true, ActorId? actorId = null) + { + if (IsDefault != isDefault) + { + Raise(new LanguageSetDefault(isDefault), actorId); + } + } + protected virtual void Handle(LanguageSetDefault @event) + { + IsDefault = @event.IsDefault; + } + + public void Update(ActorId? actorId = null) + { + if (_updated.HasChanges) + { + Raise(_updated, actorId, DateTime.Now); + _updated = new(); + } + } + protected virtual void Handle(LanguageUpdated @event) + { + if (@event.Locale != null) + { + _locale = @event.Locale; + } + } + + public override string ToString() => $"{Locale} | {base.ToString()}"; +} diff --git a/backend/src/Logitar.Cms.Core/Localization/LanguageId.cs b/backend/src/Logitar.Cms.Core/Localization/LanguageId.cs new file mode 100644 index 0000000..a37e77e --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/LanguageId.cs @@ -0,0 +1,33 @@ +using Logitar.EventSourcing; + +namespace Logitar.Cms.Core.Localization; + +public readonly struct LanguageId +{ + public StreamId StreamId { get; } + public string Value => StreamId.Value; + + public LanguageId(Guid value) + { + StreamId = new(value); + } + public LanguageId(string value) + { + StreamId = new(value); + } + public LanguageId(StreamId streamId) + { + StreamId = streamId; + } + + public static LanguageId NewId() => new(StreamId.NewId()); + + public Guid ToGuid() => StreamId.ToGuid(); + + public static bool operator ==(LanguageId left, LanguageId right) => left.Equals(right); + public static bool operator !=(LanguageId left, LanguageId right) => !left.Equals(right); + + public override bool Equals([NotNullWhen(true)] object? obj) => obj is LanguageId languageId && languageId.Value == Value; + public override int GetHashCode() => Value.GetHashCode(); + public override string ToString() => Value; +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Locale.cs b/backend/src/Logitar.Cms.Core/Localization/Locale.cs new file mode 100644 index 0000000..deb154a --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Locale.cs @@ -0,0 +1,34 @@ +using FluentValidation; + +namespace Logitar.Cms.Core.Localization; + +public record Locale +{ + public const int MaximumLength = 16; + + public CultureInfo Culture { get; } + public string Value { get; } + + public Locale(CultureInfo culture) + { + Culture = culture; + Value = culture.Name; + } + public Locale(string value) + { + Value = value.Trim(); + new Validator().ValidateAndThrow(this); + + Culture = CultureInfo.GetCultureInfo(value); + } + + public override string ToString() => $"{Culture.DisplayName} ({Culture.Name})"; + + private class Validator : AbstractValidator + { + public Validator() + { + RuleFor(x => x.Value).Locale(); + } + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/LocaleAlreadyUsedException.cs b/backend/src/Logitar.Cms.Core/Localization/LocaleAlreadyUsedException.cs new file mode 100644 index 0000000..badc05f --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/LocaleAlreadyUsedException.cs @@ -0,0 +1,51 @@ +using Logitar.Cms.Core.Errors; + +namespace Logitar.Cms.Core.Localization; + +public class LocaleAlreadyUsedException : ConflictException +{ + private const string ErrorMessage = "The specified locale is already used."; + + public Guid LanguageId + { + get => (Guid)Data[nameof(LanguageId)]!; + private set => Data[nameof(LanguageId)] = value; + } + public Guid ConflictId + { + get => (Guid)Data[nameof(ConflictId)]!; + private set => Data[nameof(ConflictId)] = value; + } + public string Locale + { + get => (string)Data[nameof(Locale)]!; + private set => Data[nameof(Locale)] = value; + } + public string PropertyName + { + get => (string)Data[nameof(PropertyName)]!; + private set => Data[nameof(PropertyName)] = value; + } + + public override Error Error => new(this.GetErrorCode(), ErrorMessage, + [ + new ErrorData(nameof(ConflictId), ConflictId), + new ErrorData(nameof(Locale), Locale), + new ErrorData(nameof(PropertyName), PropertyName) + ]); + + public LocaleAlreadyUsedException(Language language, LanguageId conflictId) : base(BuildMessage(language, conflictId)) + { + LanguageId = language.Id.ToGuid(); + ConflictId = conflictId.ToGuid(); + Locale = language.Locale.ToString(); + PropertyName = nameof(language.Locale); + } + + private static string BuildMessage(Language language, LanguageId conflictId) => new ErrorMessageBuilder(ErrorMessage) + .AddData(nameof(LanguageId), language.Id.ToGuid()) + .AddData(nameof(ConflictId), conflictId.ToGuid()) + .AddData(nameof(Locale), language.Locale) + .AddData(nameof(PropertyName), nameof(language.Locale)) + .Build(); +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Models/CreateOrReplaceLanguagePayload.cs b/backend/src/Logitar.Cms.Core/Localization/Models/CreateOrReplaceLanguagePayload.cs new file mode 100644 index 0000000..12fbb23 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Models/CreateOrReplaceLanguagePayload.cs @@ -0,0 +1,15 @@ +namespace Logitar.Cms.Core.Localization.Models; + +public record CreateOrReplaceLanguagePayload +{ + public string Locale { get; set; } = string.Empty; + + public CreateOrReplaceLanguagePayload() + { + } + + public CreateOrReplaceLanguagePayload(string locale) + { + Locale = locale; + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Models/LanguageModel.cs b/backend/src/Logitar.Cms.Core/Localization/Models/LanguageModel.cs new file mode 100644 index 0000000..4e7617a --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Models/LanguageModel.cs @@ -0,0 +1,23 @@ +using Logitar.Cms.Core.Models; + +namespace Logitar.Cms.Core.Localization.Models; + +public class LanguageModel : AggregateModel +{ + public bool IsDefault { get; set; } + + public LocaleModel Locale { get; set; } = new(); + + public LanguageModel() + { + } + + public LanguageModel(LocaleModel locale, bool isDefault = false) + { + IsDefault = isDefault; + + Locale = locale; + } + + public override string ToString() => $"{Locale} | {base.ToString()}"; +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Models/LocaleModel.cs b/backend/src/Logitar.Cms.Core/Localization/Models/LocaleModel.cs new file mode 100644 index 0000000..415012a --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Models/LocaleModel.cs @@ -0,0 +1,33 @@ +namespace Logitar.Cms.Core.Localization.Models; + +public class LocaleModel +{ + public int LCID { get; set; } + public string Code { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + public string EnglishName { get; set; } = string.Empty; + public string NativeName { get; set; } = string.Empty; + + public LocaleModel() : this(string.Empty) + { + } + + public LocaleModel(string locale) : this(CultureInfo.GetCultureInfo(locale)) + { + } + + public LocaleModel(CultureInfo culture) + { + LCID = culture.LCID; + Code = culture.Name; + + DisplayName = culture.DisplayName; + EnglishName = culture.EnglishName; + NativeName = culture.NativeName; + } + + public override bool Equals(object? obj) => obj is LocaleModel locale && locale.LCID == LCID; + public override int GetHashCode() => LCID.GetHashCode(); + public override string ToString() => $"{DisplayName} ({Code})"; +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Models/UpdateLanguagePayload.cs b/backend/src/Logitar.Cms.Core/Localization/Models/UpdateLanguagePayload.cs new file mode 100644 index 0000000..1ff0c10 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Models/UpdateLanguagePayload.cs @@ -0,0 +1,6 @@ +namespace Logitar.Cms.Core.Localization.Models; + +public record UpdateLanguagePayload +{ + public string? Locale { get; set; } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Queries/ReadLanguageQuery.cs b/backend/src/Logitar.Cms.Core/Localization/Queries/ReadLanguageQuery.cs new file mode 100644 index 0000000..38a075c --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Queries/ReadLanguageQuery.cs @@ -0,0 +1,50 @@ +using Logitar.Cms.Core.Localization.Models; +using MediatR; + +namespace Logitar.Cms.Core.Localization.Queries; + +public record ReadLanguageQuery(Guid? Id, string? Locale, bool IsDefault) : IRequest; + +internal class ReadLanguageQueryHandler : IRequestHandler +{ + private readonly ILanguageQuerier _languageQuerier; + + public ReadLanguageQueryHandler(ILanguageQuerier languageQuerier) + { + _languageQuerier = languageQuerier; + } + + public async Task Handle(ReadLanguageQuery query, CancellationToken cancellationToken) + { + Dictionary languages = new(capacity: 3); + + if (query.Id.HasValue) + { + LanguageModel? language = await _languageQuerier.ReadAsync(query.Id.Value, cancellationToken); + if (language != null) + { + languages[language.Id] = language; + } + } + if (!string.IsNullOrWhiteSpace(query.Locale)) + { + LanguageModel? language = await _languageQuerier.ReadAsync(query.Locale, cancellationToken); + if (language != null) + { + languages[language.Id] = language; + } + } + if (query.IsDefault) + { + LanguageModel language = await _languageQuerier.ReadDefaultAsync(cancellationToken); + languages[language.Id] = language; + } + + if (languages.Count > 1) + { + throw TooManyResultsException.ExpectedSingle(languages.Count); + } + + return languages.Values.SingleOrDefault(); + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Validators/CreateOrReplaceLanguageValidator.cs b/backend/src/Logitar.Cms.Core/Localization/Validators/CreateOrReplaceLanguageValidator.cs new file mode 100644 index 0000000..1aada88 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Validators/CreateOrReplaceLanguageValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Logitar.Cms.Core.Localization.Models; + +namespace Logitar.Cms.Core.Localization.Validators; + +internal class CreateOrReplaceLanguageValidator : AbstractValidator +{ + public CreateOrReplaceLanguageValidator() + { + RuleFor(x => x.Locale).Locale(); + } +} diff --git a/backend/src/Logitar.Cms.Core/Localization/Validators/UpdateLanguageValidator.cs b/backend/src/Logitar.Cms.Core/Localization/Validators/UpdateLanguageValidator.cs new file mode 100644 index 0000000..5471b08 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Localization/Validators/UpdateLanguageValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Logitar.Cms.Core.Localization.Models; + +namespace Logitar.Cms.Core.Localization.Validators; + +internal class UpdateLanguageValidator : AbstractValidator +{ + public UpdateLanguageValidator() + { + When(x => !string.IsNullOrWhiteSpace(x.Locale), () => RuleFor(x => x.Locale!).Locale()); + } +} diff --git a/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj b/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj new file mode 100644 index 0000000..260f946 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Logitar.Cms.Core.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + + + + True + + + + True + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/Logitar.Cms.Core/Models/ActorModel.cs b/backend/src/Logitar.Cms.Core/Models/ActorModel.cs new file mode 100644 index 0000000..522b801 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Models/ActorModel.cs @@ -0,0 +1,37 @@ +namespace Logitar.Cms.Core.Models; + +public class ActorModel +{ + public static ActorModel System => new(nameof(System)); + + public Guid Id { get; set; } + public ActorType Type { get; set; } + public bool IsDeleted { get; set; } + + public string DisplayName { get; set; } = string.Empty; + public string? EmailAddress { get; set; } + public string? PictureUrl { get; set; } + + public ActorModel() + { + } + + public ActorModel(string displayName) + { + DisplayName = displayName; + } + + public override bool Equals(object? obj) => obj is ActorModel actor && actor.Id == Id; + public override int GetHashCode() => Id.GetHashCode(); + public override string ToString() + { + StringBuilder actor = new(); + actor.Append(DisplayName); + if (EmailAddress != null) + { + actor.Append(" <").Append(EmailAddress).Append('>'); + } + actor.Append(" (").Append(Type).Append(".Id=").Append(Id).Append(')'); + return actor.ToString(); + } +} diff --git a/backend/src/Logitar.Cms.Core/Models/ActorType.cs b/backend/src/Logitar.Cms.Core/Models/ActorType.cs new file mode 100644 index 0000000..58a1434 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Models/ActorType.cs @@ -0,0 +1,8 @@ +namespace Logitar.Cms.Core.Models; + +public enum ActorType +{ + System = 0, + ApiKey = 1, + User = 2 +} diff --git a/backend/src/Logitar.Cms.Core/Models/AggregateModel.cs b/backend/src/Logitar.Cms.Core/Models/AggregateModel.cs new file mode 100644 index 0000000..5b8942b --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Models/AggregateModel.cs @@ -0,0 +1,17 @@ +namespace Logitar.Cms.Core.Models; + +public abstract class AggregateModel +{ + public Guid Id { get; set; } + public long Version { get; set; } + + public ActorModel CreatedBy { get; set; } = ActorModel.System; + public DateTime CreatedOn { get; set; } + + public ActorModel UpdatedBy { get; set; } = ActorModel.System; + public DateTime UpdatedOn { get; set; } + + public override bool Equals(object? obj) => obj is AggregateModel aggregate && aggregate.Id == Id; + public override int GetHashCode() => Id.GetHashCode(); + public override string ToString() => $"{GetType()} (Id={Id})"; +} diff --git a/backend/src/Logitar.Cms.Core/TooManyResultsException.cs b/backend/src/Logitar.Cms.Core/TooManyResultsException.cs new file mode 100644 index 0000000..7437c5a --- /dev/null +++ b/backend/src/Logitar.Cms.Core/TooManyResultsException.cs @@ -0,0 +1,52 @@ +using Logitar.Cms.Core.Errors; + +namespace Logitar.Cms.Core; + +public class TooManyResultsException : BadRequestException +{ + private const string ErrorMessage = "There are too many results."; + + public string TypeName + { + get => (string)Data[nameof(TypeName)]!; + private set => Data[nameof(TypeName)] = value; + } + public int ExpectedCount + { + get => (int)Data[nameof(ExpectedCount)]!; + private set => Data[nameof(ExpectedCount)] = value; + } + public int ActualCount + { + get => (int)Data[nameof(ActualCount)]!; + private set => Data[nameof(ActualCount)] = value; + } + + public override Error Error => new(this.GetErrorCode(), ErrorMessage, + [ + new ErrorData(nameof(ExpectedCount), ExpectedCount), + new ErrorData(nameof(ActualCount), ActualCount) + ]); + + public TooManyResultsException(Type type, int expectedCount, int actualCount) : base(BuildMessage(type, expectedCount, actualCount)) + { + TypeName = type.GetNamespaceQualifiedName(); + ExpectedCount = expectedCount; + ActualCount = actualCount; + } + + private static string BuildMessage(Type type, int expectedCount, int actualCount) => new ErrorMessageBuilder(ErrorMessage) + .AddData(nameof(TypeName), type.GetNamespaceQualifiedName()) + .AddData(nameof(ExpectedCount), expectedCount) + .AddData(nameof(ActualCount), actualCount) + .Build(); +} + +public class TooManyResultsException : TooManyResultsException +{ + public TooManyResultsException(int expectedCount, int actualCount) : base(typeof(T), expectedCount, actualCount) + { + } + + public static TooManyResultsException ExpectedSingle(int actualCount) => new(expectedCount: 1, actualCount); +} diff --git a/backend/src/Logitar.Cms.Core/ValidationExtensions.cs b/backend/src/Logitar.Cms.Core/ValidationExtensions.cs new file mode 100644 index 0000000..1e06ee4 --- /dev/null +++ b/backend/src/Logitar.Cms.Core/ValidationExtensions.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Logitar.Cms.Core.Validators; + +namespace Logitar.Cms.Core; + +public static class ValidationExtensions +{ + public static IRuleBuilderOptions Locale(this IRuleBuilder ruleBuilder) + { + return ruleBuilder.NotEmpty().MaximumLength(Localization.Locale.MaximumLength).SetValidator(new LocaleValidator()); + } +} diff --git a/backend/src/Logitar.Cms.Core/Validators/LocaleValidator.cs b/backend/src/Logitar.Cms.Core/Validators/LocaleValidator.cs new file mode 100644 index 0000000..56c8d0f --- /dev/null +++ b/backend/src/Logitar.Cms.Core/Validators/LocaleValidator.cs @@ -0,0 +1,30 @@ +using FluentValidation; +using FluentValidation.Validators; + +namespace Logitar.Cms.Core.Validators; + +internal class LocaleValidator : IPropertyValidator +{ + private const int LOCALE_CUSTOM_UNSPECIFIED = 0x1000; + + public string Name { get; } = "LocaleValidator"; + + public string GetDefaultMessageTemplate(string errorCode) + { + return "'{PropertyName}' must be a valid culture code. It cannot be the invariant culture, nor a user-defined culture."; + } + + public bool IsValid(ValidationContext context, string value) + { + try + { + CultureInfo culture = CultureInfo.GetCultureInfo(value); + return !string.IsNullOrEmpty(culture.Name) && culture.LCID != LOCALE_CUSTOM_UNSPECIFIED; + } + catch (CultureNotFoundException) + { + } + + return false; + } +} diff --git a/backend/src/Logitar.Cms.Web/Controllers/LanguageController.cs b/backend/src/Logitar.Cms.Web/Controllers/LanguageController.cs new file mode 100644 index 0000000..511dc41 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Controllers/LanguageController.cs @@ -0,0 +1,83 @@ +using Logitar.Cms.Core.Localization.Commands; +using Logitar.Cms.Core.Localization.Models; +using Logitar.Cms.Core.Localization.Queries; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace Logitar.Cms.Web.Controllers; + +[ApiController] +[Route("api/languages")] +public class LanguageController : ControllerBase +{ + private readonly IMediator _mediator; + + public LanguageController(IMediator mediator) + { + _mediator = mediator; + } + + [HttpPost] + public async Task> CreateAsync([FromBody] CreateOrReplaceLanguagePayload payload, CancellationToken cancellationToken) + { + CreateOrReplaceLanguageResult result = await _mediator.Send(new CreateOrReplaceLanguageCommand(Id: null, payload, Version: null), cancellationToken); + return ToActionResult(result); + } + + [HttpGet("{id}")] + public async Task> ReadAsync(Guid id, CancellationToken cancellationToken) + { + LanguageModel? language = await _mediator.Send(new ReadLanguageQuery(id, Locale: null, IsDefault: false), cancellationToken); + return language == null ? NotFound() : Ok(language); + } + + [HttpGet("locale:{locale}")] + public async Task> ReadAsync(string locale, CancellationToken cancellationToken) + { + LanguageModel? language = await _mediator.Send(new ReadLanguageQuery(Id: null, locale, IsDefault: false), cancellationToken); + return language == null ? NotFound() : Ok(language); + } + + [HttpGet("default")] + public async Task> ReadDefaultAsync(CancellationToken cancellationToken) + { + LanguageModel? language = await _mediator.Send(new ReadLanguageQuery(Id: null, Locale: null, IsDefault: true), cancellationToken); + return language == null ? NotFound() : Ok(language); + } + + [HttpPut("{id}")] + public async Task> ReplaceAsync(Guid id, [FromBody] CreateOrReplaceLanguagePayload payload, long? version, CancellationToken cancellationToken) + { + CreateOrReplaceLanguageResult result = await _mediator.Send(new CreateOrReplaceLanguageCommand(id, payload, version), cancellationToken); + return ToActionResult(result); + } + + [HttpPatch("{id}/default")] + public async Task> SetDefaultAsync(Guid id, CancellationToken cancellationToken) + { + LanguageModel? language = await _mediator.Send(new SetDefaultLanguageCommand(id), cancellationToken); + return language == null ? NotFound() : Ok(language); + } + + [HttpPatch("{id}")] + public async Task> UpdateAsync(Guid id, [FromBody] UpdateLanguagePayload payload, CancellationToken cancellationToken) + { + LanguageModel? language = await _mediator.Send(new UpdateLanguageCommand(id, payload), cancellationToken); + return language == null ? NotFound() : Ok(language); + } + + private ActionResult ToActionResult(CreateOrReplaceLanguageResult result) + { + if (result.Language == null) + { + return NotFound(); + } + else if (result.Created) + { + Uri location = new($"{Request.Scheme}://{Request.Host}/api/languages/{result.Language.Id}", UriKind.Absolute); + return Created(location, result.Language); + } + + return Ok(result.Language); + } +} diff --git a/backend/src/Logitar.Cms.Web/DependencyInjectionExtensions.cs b/backend/src/Logitar.Cms.Web/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..a341262 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/DependencyInjectionExtensions.cs @@ -0,0 +1,12 @@ +namespace Logitar.Cms.Web; + +public static class DependencyInjectionExtensions +{ + public static IServiceCollection AddLogitarCmsWeb(this IServiceCollection services) + { + services.AddControllersWithViews() // TODO(fpion): Error Handling, Logging + .AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())); + + return services; + } +} diff --git a/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj b/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj new file mode 100644 index 0000000..780e96a --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Logitar.Cms.Web.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + enable + enable + + + + True + + + + True + + + + + + + + + + + diff --git a/backend/src/Logitar.Cms.Web/Program.cs b/backend/src/Logitar.Cms.Web/Program.cs new file mode 100644 index 0000000..6748b96 --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Program.cs @@ -0,0 +1,9 @@ +namespace Logitar.Cms.Web; + +internal class Program +{ + public static void Main() + { + throw new InvalidOperationException("Do not start this project. Its only purpose is to be packaged as a NuGet. Start the 'Logitar.Cms' project instead."); + } +} diff --git a/backend/src/Logitar.Cms.Web/Properties/launchSettings.json b/backend/src/Logitar.Cms.Web/Properties/launchSettings.json new file mode 100644 index 0000000..075573d --- /dev/null +++ b/backend/src/Logitar.Cms.Web/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Logitar.Cms.Web": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61765;http://localhost:61766" + } + } +} \ No newline at end of file diff --git a/backend/tests/.gitkeep b/backend/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/tests/Logitar.Cms.UnitTests/Categories.cs b/backend/tests/Logitar.Cms.UnitTests/Categories.cs new file mode 100644 index 0000000..1f0c7b5 --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Categories.cs @@ -0,0 +1,6 @@ +namespace Logitar.Cms; + +internal static class Categories +{ + public const string Unit = "Unit"; +} diff --git a/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/CreateOrReplaceLanguageCommandHandlerTests.cs b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/CreateOrReplaceLanguageCommandHandlerTests.cs new file mode 100644 index 0000000..616a52e --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/CreateOrReplaceLanguageCommandHandlerTests.cs @@ -0,0 +1,131 @@ +using Logitar.Cms.Core.Localization.Models; +using Logitar.EventSourcing; +using MediatR; +using Moq; + +namespace Logitar.Cms.Core.Localization.Commands; + +[Trait(Traits.Category, Categories.Unit)] +public class CreateOrReplaceLanguageCommandHandlerTests +{ + private readonly ActorId _actorId = ActorId.NewId(); + private readonly CancellationToken _cancellationToken = default; + + private readonly Mock _applicationContext = new(); + private readonly Mock _languageQuerier = new(); + private readonly Mock _languageRepository = new(); + private readonly Mock _mediator = new(); + + private readonly CreateOrReplaceLanguageCommandHandler _handler; + + private readonly Language _english = new(new Locale("en"), isDefault: true); + + public CreateOrReplaceLanguageCommandHandlerTests() + { + _handler = new(_applicationContext.Object, _languageQuerier.Object, _languageRepository.Object, _mediator.Object); + + _applicationContext.Setup(x => x.ActorId).Returns(_actorId); + _languageRepository.Setup(x => x.LoadAsync(_english.Id, _cancellationToken)).ReturnsAsync(_english); + } + + [Theory(DisplayName = "Handle: it should create a new language.")] + [InlineData(null)] + [InlineData("d855cace-5c65-4eab-9f48-19095c276c36")] + public async Task Given_NewLanguage_When_Handle_Then_LanguageCreated(string? idValue) + { + Guid? id = idValue == null ? null : Guid.Parse(idValue); + + LanguageModel language = new(); + _languageQuerier.Setup(x => x.ReadAsync(It.IsAny(), _cancellationToken)).ReturnsAsync(language); + + CreateOrReplaceLanguagePayload payload = new("en"); + CreateOrReplaceLanguageCommand command = new(id, payload, Version: null); + CreateOrReplaceLanguageResult result = await _handler.Handle(command, _cancellationToken); + + Assert.NotNull(result.Language); + Assert.Same(language, result.Language); + Assert.True(result.Created); + + _mediator.Verify(x => x.Send( + It.Is(y => (!id.HasValue || y.Language.Id.ToGuid().Equals(id.Value)) + && y.Language.CreatedBy == _actorId + && !y.Language.IsDefault + && y.Language.Locale.Value == payload.Locale), + _cancellationToken), Times.Once); + } + + [Fact(DisplayName = "Handle: it should replace an existing language.")] + public async Task Given_ExistingLanguageNoVersion_When_Handle_Then_LanguageReplaced() + { + LanguageModel language = new(); + _languageQuerier.Setup(x => x.ReadAsync(_english, _cancellationToken)).ReturnsAsync(language); + + CreateOrReplaceLanguagePayload payload = new("en-US"); + CreateOrReplaceLanguageCommand command = new(_english.Id.ToGuid(), payload, Version: null); + CreateOrReplaceLanguageResult result = await _handler.Handle(command, _cancellationToken); + + Assert.NotNull(result.Language); + Assert.Same(language, result.Language); + Assert.False(result.Created); + + Assert.Equal(_actorId, _english.UpdatedBy); + Assert.True(_english.IsDefault); + Assert.Equal(payload.Locale, _english.Locale.Value); + + _mediator.Verify(x => x.Send(It.Is(y => y.Language.Equals(_english)), _cancellationToken), Times.Once); + } + + [Fact(DisplayName = "Handle: it should return null when updating a language that does not exist.")] + public async Task Given_UpdatingNotFound_When_Handle_Then_NullReturned() + { + CreateOrReplaceLanguagePayload payload = new("en"); + CreateOrReplaceLanguageCommand command = new(Id: null, payload, Version: 1); + CreateOrReplaceLanguageResult result = await _handler.Handle(command, _cancellationToken); + Assert.Null(result.Language); + Assert.False(result.Created); + } + + [Fact(DisplayName = "Handle: it should throw ValidationException when the payload is not valid.")] + public async Task Given_InvalidPayload_When_Handle_Then_ValidationException() + { + CreateOrReplaceLanguagePayload payload = new(); + CreateOrReplaceLanguageCommand command = new(Id: null, payload, Version: null); + var exception = await Assert.ThrowsAsync(async () => await _handler.Handle(command, _cancellationToken)); + + Assert.Equal(2, exception.Errors.Count()); + Assert.Contains(exception.Errors, e => e.ErrorCode == "NotEmptyValidator" && e.PropertyName == "Locale"); + Assert.Contains(exception.Errors, e => e.ErrorCode == "LocaleValidator" && e.PropertyName == "Locale"); + } + + [Fact(DisplayName = "Handle: it should update an existing language.")] + public async Task Given_ExistingLanguageWithVersion_When_Handle_Then_LanguageUpdated() + { + _english.Locale = new Locale("en-US"); + _english.Update(_actorId); + long version = _english.Version; + + Language reference = new(_english.Locale, _english.IsDefault, _actorId, _english.Id); + _languageRepository.Setup(x => x.LoadAsync(reference.Id, version, _cancellationToken)).ReturnsAsync(reference); + + Locale locale = new("en-CA"); + _english.Locale = locale; + _english.Update(_actorId); + + LanguageModel language = new(); + _languageQuerier.Setup(x => x.ReadAsync(_english, _cancellationToken)).ReturnsAsync(language); + + CreateOrReplaceLanguagePayload payload = new("en-US"); + CreateOrReplaceLanguageCommand command = new(_english.Id.ToGuid(), payload, version); + CreateOrReplaceLanguageResult result = await _handler.Handle(command, _cancellationToken); + + Assert.NotNull(result.Language); + Assert.Same(language, result.Language); + Assert.False(result.Created); + + Assert.Equal(_actorId, _english.UpdatedBy); + Assert.True(_english.IsDefault); + Assert.Equal(locale, _english.Locale); + + _mediator.Verify(x => x.Send(It.Is(y => y.Language.Equals(_english)), _cancellationToken), Times.Once); + } +} diff --git a/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SaveLanguageCommandHandlerTests.cs b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SaveLanguageCommandHandlerTests.cs new file mode 100644 index 0000000..2849f90 --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SaveLanguageCommandHandlerTests.cs @@ -0,0 +1,46 @@ +using Moq; + +namespace Logitar.Cms.Core.Localization.Commands; + +[Trait(Traits.Category, Categories.Unit)] +public class SaveLanguageCommandHandlerTests +{ + private readonly CancellationToken _cancellationToken = default; + + private readonly Mock _languageQuerier = new(); + private readonly Mock _languageRepository = new(); + + private readonly SaveLanguageCommandHandler _handler; + + private readonly Language _language = new(new Locale("fr"), isDefault: true); + + public SaveLanguageCommandHandlerTests() + { + _handler = new(_languageQuerier.Object, _languageRepository.Object); + } + + [Fact(DisplayName = "Handle: it should save the language when there is no issue.")] + public async Task Given_NoIssue_When_Handle_Then_LanguageSaved() + { + _languageQuerier.Setup(x => x.FindIdAsync(_language.Locale, _cancellationToken)).ReturnsAsync(_language.Id); + + SaveLanguageCommand command = new(_language); + await _handler.Handle(command, _cancellationToken); + + _languageRepository.Verify(x => x.SaveAsync(_language, _cancellationToken), Times.Once); + } + + [Fact(DisplayName = "Handle: it should throw LocaleAlreadyUsedException when there is a locale conflict.")] + public async Task Given_LocaleConflict_When_Handle_Then_LocaleAlreadyUsedException() + { + LanguageId conflictId = LanguageId.NewId(); + _languageQuerier.Setup(x => x.FindIdAsync(_language.Locale, _cancellationToken)).ReturnsAsync(conflictId); + + SaveLanguageCommand command = new(_language); + var exception = await Assert.ThrowsAsync(async () => await _handler.Handle(command, _cancellationToken)); + Assert.Equal(_language.Id.ToGuid(), exception.LanguageId); + Assert.Equal(conflictId.ToGuid(), exception.ConflictId); + Assert.Equal(_language.Locale.ToString(), exception.Locale); + Assert.Equal("Locale", exception.PropertyName); + } +} diff --git a/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SetDefaultLanguageCommandHandler.cs b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SetDefaultLanguageCommandHandler.cs new file mode 100644 index 0000000..d8389ef --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/SetDefaultLanguageCommandHandler.cs @@ -0,0 +1,67 @@ +using Logitar.Cms.Core.Localization.Models; +using Moq; + +namespace Logitar.Cms.Core.Localization.Commands; + +[Trait(Traits.Category, Categories.Unit)] +public class SetDefaultLanguageCommandHandlerTests +{ + private readonly CancellationToken _cancellationToken = default; + + private readonly Mock _applicationContext = new(); + private readonly Mock _languageQuerier = new(); + private readonly Mock _languageRepository = new(); + + private readonly SetDefaultLanguageCommandHandler _handler; + + private readonly Language _english = new(new Locale("en"), isDefault: true); + private readonly Language _french = new(new Locale("fr")); + + public SetDefaultLanguageCommandHandlerTests() + { + _handler = new(_applicationContext.Object, _languageQuerier.Object, _languageRepository.Object); + + _languageQuerier.Setup(x => x.FindDefaultIdAsync(_cancellationToken)).ReturnsAsync(_english.Id); + _languageRepository.Setup(x => x.LoadAsync(_english.Id, _cancellationToken)).ReturnsAsync(_english); + _languageRepository.Setup(x => x.LoadAsync(_french.Id, _cancellationToken)).ReturnsAsync(_french); + } + + [Fact(DisplayName = "Handle: it should not do anything when the language is already default.")] + public async Task Given_LanguageIsDefault_When_Handle_Then_DoNothing() + { + LanguageModel model = new(); + _languageQuerier.Setup(x => x.ReadAsync(_english, _cancellationToken)).ReturnsAsync(model); + + SetDefaultLanguageCommand command = new(_english.Id.ToGuid()); + LanguageModel? language = await _handler.Handle(command, _cancellationToken); + Assert.NotNull(language); + Assert.Same(model, language); + + _languageRepository.Verify(x => x.SaveAsync(It.IsAny>(), _cancellationToken), Times.Never); + } + + [Fact(DisplayName = "Handle: it should return null when the language was not found.")] + public async Task Given_LanguageNotFound_When_Handle_Then_NullReturned() + { + Assert.Null(await _handler.Handle(new SetDefaultLanguageCommand(Guid.NewGuid()), _cancellationToken)); + } + + [Fact(DisplayName = "Handle: it should set the default language.")] + public async Task Given_LanguageNotDefault_When_Handle_Then_DefaultLanguageSet() + { + LanguageModel model = new(); + _languageQuerier.Setup(x => x.ReadAsync(_french, _cancellationToken)).ReturnsAsync(model); + + SetDefaultLanguageCommand command = new(_french.Id.ToGuid()); + LanguageModel? language = await _handler.Handle(command, _cancellationToken); + Assert.NotNull(language); + Assert.Same(model, language); + + _languageRepository.Verify(x => x.SaveAsync( + It.Is>(y => y.SequenceEqual(new Language[] { _english, _french })), + _cancellationToken), Times.Once); + + Assert.False(_english.IsDefault); + Assert.True(_french.IsDefault); + } +} diff --git a/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/UpdateLanguageCommandHandlerTests.cs b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/UpdateLanguageCommandHandlerTests.cs new file mode 100644 index 0000000..f2d8fc5 --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Commands/UpdateLanguageCommandHandlerTests.cs @@ -0,0 +1,74 @@ +using FluentValidation.Results; +using Logitar.Cms.Core.Localization.Models; +using Logitar.EventSourcing; +using MediatR; +using Moq; + +namespace Logitar.Cms.Core.Localization.Commands; + +[Trait(Traits.Category, Categories.Unit)] +public class UpdateLanguageCommandHandlerTests +{ + private readonly ActorId _actorId = ActorId.NewId(); + private readonly CancellationToken _cancellationToken = default; + + private readonly Mock _applicationContext = new(); + private readonly Mock _languageQuerier = new(); + private readonly Mock _languageRepository = new(); + private readonly Mock _mediator = new(); + + private readonly UpdateLanguageCommandHandler _handler; + + public UpdateLanguageCommandHandlerTests() + { + _handler = new(_applicationContext.Object, _languageQuerier.Object, _languageRepository.Object, _mediator.Object); + + _applicationContext.Setup(x => x.ActorId).Returns(_actorId); + } + + [Fact(DisplayName = "Handle: it should return null when the language was not found.")] + public async Task Given_LanguageNotFound_When_Handle_Then_NullReturned() + { + UpdateLanguageCommand command = new(Guid.NewGuid(), new UpdateLanguagePayload()); + Assert.Null(await _handler.Handle(command, _cancellationToken)); + } + + [Fact(DisplayName = "Handle: it should throw ValidationException when the payload is not valid.")] + public async Task Given_InvalidPayload_When_Handle_Then_ValidationException() + { + UpdateLanguagePayload payload = new() + { + Locale = "invalid" + }; + UpdateLanguageCommand command = new(Guid.NewGuid(), payload); + var exception = await Assert.ThrowsAsync(async () => await _handler.Handle(command, _cancellationToken)); + + ValidationFailure failure = Assert.Single(exception.Errors); + Assert.Equal("LocaleValidator", failure.ErrorCode); + Assert.Equal("Locale", failure.PropertyName); + } + + [Fact(DisplayName = "Handle: it should update the language.")] + public async Task Given_Changes_When_Handle_Then_LanguageUpdated() + { + Language language = new(new Locale("fr"), isDefault: true); + _languageRepository.Setup(x => x.LoadAsync(language.Id, _cancellationToken)).ReturnsAsync(language); + + LanguageModel model = new(); + _languageQuerier.Setup(x => x.ReadAsync(language, _cancellationToken)).ReturnsAsync(model); + + UpdateLanguagePayload payload = new() + { + Locale = "fr-CA" + }; + UpdateLanguageCommand command = new(language.Id.ToGuid(), payload); + LanguageModel? result = await _handler.Handle(command, _cancellationToken); + Assert.NotNull(result); + Assert.Same(model, result); + + Assert.Equal(_actorId, language.UpdatedBy); + Assert.Equal(payload.Locale, language.Locale.Value); + + _mediator.Verify(x => x.Send(It.Is(y => y.Language.Equals(language)), _cancellationToken), Times.Once); + } +} diff --git a/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Queries/ReadLanguageQueryHandlerTests.cs b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Queries/ReadLanguageQueryHandlerTests.cs new file mode 100644 index 0000000..5c2ba72 --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Core/Localization/Queries/ReadLanguageQueryHandlerTests.cs @@ -0,0 +1,87 @@ +using Logitar.Cms.Core.Localization.Models; +using Moq; + +namespace Logitar.Cms.Core.Localization.Queries; + +[Trait(Traits.Category, Categories.Unit)] +public class ReadLanguageQueryHandlerTests +{ + private readonly CancellationToken _cancellationToken = default; + + private readonly Mock _languageQuerier = new(); + + private readonly ReadLanguageQueryHandler _handler; + + private readonly LanguageModel _english = new(new LocaleModel("en")) + { + Id = Guid.NewGuid(), + IsDefault = true + }; + private readonly LanguageModel _french = new(new LocaleModel("fr")) + { + Id = Guid.NewGuid() + }; + private readonly LanguageModel _spanish = new(new LocaleModel("es")) + { + Id = Guid.NewGuid() + }; + + public ReadLanguageQueryHandlerTests() + { + _handler = new(_languageQuerier.Object); + } + + [Fact(DisplayName = "Handle: it should return null when language was found.")] + public async Task Given_NoLanguageFound_When_Handle_Then_NullReturned() + { + ReadLanguageQuery query = new(_french.Id, _spanish.Locale.Code, IsDefault: false); + LanguageModel? language = await _handler.Handle(query, _cancellationToken); + Assert.Null(language); + } + + [Fact(DisplayName = "Handle: it should return the default language.")] + public async Task Given_Default_When_Handle_Then_LanguageReturned() + { + _languageQuerier.Setup(x => x.ReadDefaultAsync(_cancellationToken)).ReturnsAsync(_english); + + ReadLanguageQuery query = new(Id: null, Locale: null, IsDefault: true); + LanguageModel? language = await _handler.Handle(query, _cancellationToken); + Assert.NotNull(language); + Assert.Same(_english, language); + } + + [Fact(DisplayName = "Handle: it should return the language found by ID.")] + public async Task Given_FoundById_When_Handle_Then_LanguageReturned() + { + _languageQuerier.Setup(x => x.ReadAsync(_spanish.Id, _cancellationToken)).ReturnsAsync(_spanish); + + ReadLanguageQuery query = new(_spanish.Id, Locale: null, IsDefault: false); + LanguageModel? language = await _handler.Handle(query, _cancellationToken); + Assert.NotNull(language); + Assert.Same(_spanish, language); + } + + [Fact(DisplayName = "Handle: it should return the language found by locale.")] + public async Task Given_FoundByLocale_When_Handle_Then_LanguageReturned() + { + _languageQuerier.Setup(x => x.ReadAsync(_french.Locale.Code, _cancellationToken)).ReturnsAsync(_french); + + ReadLanguageQuery query = new(Id: null, _french.Locale.Code, IsDefault: false); + LanguageModel? language = await _handler.Handle(query, _cancellationToken); + Assert.NotNull(language); + Assert.Same(_french, language); + } + + [Fact(DisplayName = "Handle: it should throw TooManyResultsException when many languages were found.")] + public async Task Given_ManyLanguagesFound_When_Handle_Then_TooManyResultsException() + { + _languageQuerier.Setup(x => x.ReadAsync(_spanish.Id, _cancellationToken)).ReturnsAsync(_spanish); + _languageQuerier.Setup(x => x.ReadAsync(_french.Locale.Code, _cancellationToken)).ReturnsAsync(_french); + _languageQuerier.Setup(x => x.ReadDefaultAsync(_cancellationToken)).ReturnsAsync(_english); + + ReadLanguageQuery query = new(_spanish.Id, _french.Locale.Code, IsDefault: true); + var exception = await Assert.ThrowsAsync>(async () => await _handler.Handle(query, _cancellationToken)); + Assert.Equal(1, exception.ExpectedCount); + Assert.Equal(3, exception.ActualCount); + } +} diff --git a/backend/tests/Logitar.Cms.UnitTests/Logitar.Cms.UnitTests.csproj b/backend/tests/Logitar.Cms.UnitTests/Logitar.Cms.UnitTests.csproj new file mode 100644 index 0000000..e67c6ba --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Logitar.Cms.UnitTests.csproj @@ -0,0 +1,38 @@ + + + + net9.0 + enable + enable + false + Logitar.Cms + + + + True + + + + True + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/backend/tests/Logitar.Cms.UnitTests/Traits.cs b/backend/tests/Logitar.Cms.UnitTests/Traits.cs new file mode 100644 index 0000000..79218a6 --- /dev/null +++ b/backend/tests/Logitar.Cms.UnitTests/Traits.cs @@ -0,0 +1,6 @@ +namespace Logitar.Cms; + +internal static class Traits +{ + public const string Category = "Category"; +}