diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml new file mode 100644 index 00000000..18d0ec1b --- /dev/null +++ b/.github/workflows/build-test.yml @@ -0,0 +1,60 @@ +name: Build and Test + +on: + push: + branches: [ "feature/**", "develop" ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.gitignore' + - 'LICENSE' + - '.editorconfig' + pull_request: + branches: [ "develop", "main" ] + +jobs: + build-test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x + + - name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore /p:ContinuousIntegrationBuild=true + + - name: Test with coverage + run: dotnet test --configuration Release --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Generate coverage report + uses: danielpalme/ReportGenerator-GitHub-Action@5.2.0 + if: always() + with: + reports: '**/coverage.cobertura.xml' + targetdir: 'coveragereport' + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coveragereport + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml deleted file mode 100644 index edf8f267..00000000 --- a/.github/workflows/dotnet.yml +++ /dev/null @@ -1,28 +0,0 @@ -# This workflow will build a .NET project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net - -name: CmsEngine - -on: - push: - branches: [ "feature/**" ] - pull_request: - branches: [ "main", "develop" ] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 8.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build --no-restore - # - name: Test - # run: dotnet test --no-build --verbosity normal diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 00000000..ed389d0e --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,69 @@ +name: Preview Build + +on: + push: + branches: [ "develop" ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.gitignore' + - 'LICENSE' + - '.editorconfig' + +jobs: + preview: + name: Create preview package + runs-on: ubuntu-latest + environment: Preview + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x + + - name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore + + - name: Set preview version + id: version + run: | + VERSION=$(date +'%Y.%m.%d').${{ github.run_number }} + echo "Version: $VERSION-preview" + echo "package_version=$VERSION-preview" >> $GITHUB_OUTPUT + + - name: Build + run: > + dotnet build --configuration Release --no-restore + /p:ContinuousIntegrationBuild=true + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Publish MVC App + run: > + dotnet publish src/CmsEngine.Ui/CmsEngine.Ui.csproj + --configuration Release + --no-build + --output ./publish + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Upload MVC App artifact + uses: actions/upload-artifact@v4 + with: + name: mvc-app + path: publish/ + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1822f367 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: 'Release version (without v prefix)' + required: true + type: string + +jobs: + release: + name: Create release + runs-on: ubuntu-latest + environment: Production + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x + + - name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Set version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION=${{ github.event.inputs.version }} + else + VERSION=${GITHUB_REF#refs/tags/v} + fi + echo "Version: $VERSION" + echo "package_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: > + dotnet build --configuration Release --no-restore + /p:ContinuousIntegrationBuild=true + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Publish MVC App + run: > + dotnet publish src/CmsEngine.Ui/CmsEngine.Ui.csproj + --configuration Release + --no-build + --output ./publish + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Create ZIP file + run: | + cd publish + zip -r ../CmsEngine-${{ steps.version.outputs.package_version }}.zip . + cd .. + + - name: Generate changelog + id: changelog + uses: metcalfc/changelog-generator@v4.1.0 + with: + myToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: CmsEngine-${{ steps.version.outputs.package_version }}.zip + name: Release ${{ steps.version.outputs.package_version }} + body: ${{ steps.changelog.outputs.changelog }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifact for debugging + uses: actions/upload-artifact@v4 + with: + name: mvc-app + path: publish/ + retention-days: 7 diff --git a/CmsEngine.sln b/CmsEngine.sln index fa4e9f19..cef87fbb 100644 --- a/CmsEngine.sln +++ b/CmsEngine.sln @@ -20,6 +20,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CmsEngine.Application", "sr EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CmsEngine.Ui", "src\CmsEngine.Ui\CmsEngine.Ui.csproj", "{AEED8BDE-0C8A-43BC-91B8-77B967511921}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{417CF032-20B7-4521-984B-11D57C462BEA}" + ProjectSection(SolutionItems) = preProject + .github\workflows\build-test.yml = .github\workflows\build-test.yml + .github\workflows\preview.yml = .github\workflows\preview.yml + .github\workflows\release.yml = .github\workflows\release.yml + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CmsEngine.Tests", "tests\CmsEngine.Tests\CmsEngine.Tests.csproj", "{6F257EAE-CC97-4ADB-A892-452C0404BFAC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{4308B932-654E-487F-BABA-36FBE3EF59C1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,10 +55,19 @@ Global {AEED8BDE-0C8A-43BC-91B8-77B967511921}.Debug|Any CPU.Build.0 = Debug|Any CPU {AEED8BDE-0C8A-43BC-91B8-77B967511921}.Release|Any CPU.ActiveCfg = Release|Any CPU {AEED8BDE-0C8A-43BC-91B8-77B967511921}.Release|Any CPU.Build.0 = Release|Any CPU + {6F257EAE-CC97-4ADB-A892-452C0404BFAC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F257EAE-CC97-4ADB-A892-452C0404BFAC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F257EAE-CC97-4ADB-A892-452C0404BFAC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F257EAE-CC97-4ADB-A892-452C0404BFAC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} = {5C4438C1-7278-44BF-8473-0F1655DA512B} + {417CF032-20B7-4521-984B-11D57C462BEA} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {6F257EAE-CC97-4ADB-A892-452C0404BFAC} = {4308B932-654E-487F-BABA-36FBE3EF59C1} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0EB67BE0-B210-4352-8C85-01C9D6CA3E15} EndGlobalSection diff --git a/README.md b/README.md index b5633110..b552fc4f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # CmsEngine ## Build status -![CmsEngine](https://github.com/davidsonsousa/CmsEngine/actions/workflows/dotnet.yml/badge.svg) +![CmsEngine](https://github.com/davidsonsousa/CmsEngine/actions/workflows/build-test.yml/badge.svg) ## What is it? This the code-base of the CMS I am using in my website [https://davidsonsousa.net](https://davidsonsousa.net "my website"). diff --git a/azure-pipelines.yml b/azure-pipelines.yml deleted file mode 100644 index bf56801d..00000000 --- a/azure-pipelines.yml +++ /dev/null @@ -1,34 +0,0 @@ -# ASP.NET -# Build and test ASP.NET projects. -# Add steps that publish symbols, save build artifacts, deploy, and more: -# https://docs.microsoft.com/azure/devops/pipelines/apps/aspnet/build-aspnet-4 - -trigger: -- main - -pool: - vmImage: 'windows-latest' - -variables: - solution: '**/*.sln' - buildPlatform: 'Any CPU' - buildConfiguration: 'Release' - -steps: -- task: NuGetToolInstaller@1 - -- task: NuGetCommand@2 - inputs: - restoreSolution: '$(solution)' - -- task: VSBuild@1 - inputs: - solution: '$(solution)' - msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true /p:PackageLocation="$(build.artifactStagingDirectory)"' - platform: '$(buildPlatform)' - configuration: '$(buildConfiguration)' - -- task: VSTest@2 - inputs: - platform: '$(buildPlatform)' - configuration: '$(buildConfiguration)' diff --git a/global.json b/global.json index 24345292..5838e3c7 100644 --- a/global.json +++ b/global.json @@ -1,5 +1,5 @@ { "sdk": { - "version": "8.0.302" + "version": "9.0.300" } } \ No newline at end of file diff --git a/preview.yml b/preview.yml new file mode 100644 index 00000000..99cd9d7b --- /dev/null +++ b/preview.yml @@ -0,0 +1,69 @@ +name: Preview Build + +on: + push: + branches: [ "develop" ] + paths-ignore: + - '**.md' + - 'docs/**' + - '.gitignore' + - 'LICENSE' + - '.editorconfig' + +jobs: + preview: + name: Create preview package + runs-on: ubuntu-latest + environment: Preview + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x + + - name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Restore dependencies + run: dotnet restore + + - name: Set preview version + id: version + run: | + VERSION=$(date +'%Y.%m.%d').${{ github.run_number }} + echo "Version: $VERSION-preview" + echo "package_version=$VERSION-preview" >> $GITHUB_OUTPUT + + - name: Build + run: > + dotnet build --configuration Release --no-restore + /p:ContinuousIntegrationBuild=true + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Publish MVC App + run: > + dotnet publish src/CmsEngine.Ui/CmsEngine.Ui.csproj + --configuration Release + --no-build + --output ./publish + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Upload MVC App artifact + uses: actions/upload-artifact@v4 + with: + name: mvc-app + path: publish/ + retention-days: 7 \ No newline at end of file diff --git a/release.yml b/release.yml new file mode 100644 index 00000000..a65fff12 --- /dev/null +++ b/release.yml @@ -0,0 +1,98 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: + inputs: + version: + description: 'Release version (without v prefix)' + required: true + type: string + +jobs: + release: + name: Create release + runs-on: ubuntu-latest + environment: Production + + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 9.0.x + + - name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json', '**/packages.props') }} + restore-keys: | + ${{ runner.os }}-nuget- + + - name: Set version + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION=${{ github.event.inputs.version }} + else + VERSION=${GITHUB_REF#refs/tags/v} + fi + echo "Version: $VERSION" + echo "package_version=$VERSION" >> $GITHUB_OUTPUT + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: > + dotnet build --configuration Release --no-restore + /p:ContinuousIntegrationBuild=true + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Test + run: dotnet test --configuration Release --no-build --verbosity normal + + - name: Publish MVC App + run: > + dotnet publish src/CmsEngine.Ui/CmsEngine.Ui.csproj + --configuration Release + --no-build + --output ./publish + /p:Version=${{ steps.version.outputs.package_version }} + + - name: Create ZIP file + run: | + cd publish + zip -r ../CmsEngine-${{ steps.version.outputs.package_version }}.zip . + cd .. + + - name: Generate changelog + id: changelog + uses: metcalfc/changelog-generator@v4.1.0 + with: + myToken: ${{ secrets.GITHUB_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + if: startsWith(github.ref, 'refs/tags/') + with: + files: CmsEngine-${{ steps.version.outputs.package_version }}.zip + name: Release ${{ steps.version.outputs.package_version }} + body: ${{ steps.changelog.outputs.changelog }} + draft: false + prerelease: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Upload artifact for debugging + uses: actions/upload-artifact@v4 + with: + name: mvc-app + path: publish/ + retention-days: 7 \ No newline at end of file diff --git a/src/CmsEngine.Application/CmsEngine.Application.csproj b/src/CmsEngine.Application/CmsEngine.Application.csproj index 22eedb92..38adbfe6 100644 --- a/src/CmsEngine.Application/CmsEngine.Application.csproj +++ b/src/CmsEngine.Application/CmsEngine.Application.csproj @@ -1,15 +1,15 @@ - net8.0 + net9.0 enable enable - - - + + + diff --git a/src/CmsEngine.Application/Models/EditModels/WebsiteEditModel.cs b/src/CmsEngine.Application/Models/EditModels/WebsiteEditModel.cs index b44627ee..eca0ac44 100644 --- a/src/CmsEngine.Application/Models/EditModels/WebsiteEditModel.cs +++ b/src/CmsEngine.Application/Models/EditModels/WebsiteEditModel.cs @@ -38,7 +38,6 @@ public class WebsiteEditModel : BaseEditModel, IEditModel [MaxLength(20)] public string? Phone { get; set; } - [Required] [MaxLength(250)] public string? Email { get; set; } diff --git a/src/CmsEngine.Application/Models/ViewModels/InstanceViewModel.cs b/src/CmsEngine.Application/Models/ViewModels/InstanceViewModel.cs index fe5d4fbb..937e2fa8 100644 --- a/src/CmsEngine.Application/Models/ViewModels/InstanceViewModel.cs +++ b/src/CmsEngine.Application/Models/ViewModels/InstanceViewModel.cs @@ -20,6 +20,57 @@ public class InstanceViewModel public string SiteUrl { get; set; } = string.Empty; + public string Slug { get; set; } = string.Empty; + + public string CurrentUrl { + get { + switch (CanonicalType) + { + case CanonicalType.Archive: + return UrlFormat.Replace("[site_url]", SiteUrl) + .Replace("[culture]", Culture) + .Replace("[short_culture]", Culture.Substring(0, 2)) + .Replace("[type]/", string.Empty) + .Replace("[slug]", "archive"); + case CanonicalType.Blog: + return UrlFormat.Replace("[site_url]", SiteUrl) + .Replace("[culture]", Culture) + .Replace("[short_culture]", Culture.Substring(0, 2)) + .Replace("[type]/", string.Empty) + .Replace("[slug]", "blog"); + case CanonicalType.Category: + return UrlFormat.Replace("[site_url]", SiteUrl) + .Replace("[culture]", Culture) + .Replace("[short_culture]", Culture.Substring(0, 2)) + .Replace("[type]", "blog/category") + .Replace("[slug]", Slug); + case CanonicalType.Page: + return UrlFormat.Replace("[site_url]", SiteUrl) + .Replace("[culture]", Culture) + .Replace("[short_culture]", Culture.Substring(0, 2)) + .Replace("[type]/", string.Empty) + .Replace("[slug]", SelectedDocument?.Slug); + case CanonicalType.Post: + return UrlFormat.Replace("[site_url]", SiteUrl) + .Replace("[culture]", Culture) + .Replace("[short_culture]", Culture.Substring(0, 2)) + .Replace("[type]", "blog/post") + .Replace("[slug]", SelectedDocument?.Slug); + case CanonicalType.Tag: + return UrlFormat.Replace("[site_url]", SiteUrl) + .Replace("[culture]", Culture) + .Replace("[short_culture]", Culture.Substring(0, 2)) + .Replace("[type]", "blog/tag") + .Replace("[slug]", Slug); + case CanonicalType.Index: + default: + return SiteUrl; + } + } + } + + public CanonicalType CanonicalType { get; set; } + public int ArticleLimit { get; set; } public ContactDetailsViewModel? ContactDetails { get; set; } diff --git a/src/CmsEngine.Application/Services/CategoryService.cs b/src/CmsEngine.Application/Services/CategoryService.cs index 2d25c1f5..cbc5c11b 100644 --- a/src/CmsEngine.Application/Services/CategoryService.cs +++ b/src/CmsEngine.Application/Services/CategoryService.cs @@ -2,8 +2,8 @@ namespace CmsEngine.Application.Services; public class CategoryService : Service, ICategoryService { - public CategoryService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) - : base(uow, hca, loggerFactory, memoryCache) + public CategoryService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) + : base(uow, hca, loggerFactory, cacheService) { } @@ -48,7 +48,7 @@ public async Task DeleteRange(Guid[] ids) return returnValue; } - public IEnumerable FilterForDataTable(string searchValue, IEnumerable items) + public IQueryable FilterForDataTable(string searchValue, IQueryable items) { if (!string.IsNullOrWhiteSpace(searchValue)) { @@ -56,7 +56,7 @@ public IEnumerable FilterForDataTable(string searchValue, IEnumerable< var searchExpression = items.GetSearchExpression(searchValue, searchableProperties); Guard.Against.Null(searchExpression); - items = items.Where(searchExpression.Compile()); + items = items.Where(searchExpression); } return items; @@ -86,9 +86,9 @@ public async Task> GetCategoriesWithPostCount() return items.MapToViewModelWithPostCount(); } - public async Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters) + public (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters) { - var items = await unitOfWork.Categories.GetAllAsync(); + var items = unitOfWork.Categories.GetAll(); var recordsTotal = items.Count(); if (!string.IsNullOrWhiteSpace(parameters.Search?.Value)) @@ -102,29 +102,22 @@ public async Task> GetCategoriesWithPostCount() return (items.MapToTableViewModel(), recordsTotal, items.Count()); } - public IEnumerable OrderForDataTable(int column, string direction, IEnumerable items) + public IQueryable OrderForDataTable(int column, string direction, IQueryable items) { - try - { - switch (column) - { - case 1: - items = direction == "asc" ? items.OrderBy(o => o.Name) : items.OrderByDescending(o => o.Name); - break; - case 2: - items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); - break; - case 3: - items = direction == "asc" ? items.OrderBy(o => o.Description) : items.OrderByDescending(o => o.Description); - break; - default: - items = items.OrderBy(o => o.Name); - break; - } - } - catch + switch (column) { - throw; + case 1: + items = direction == "asc" ? items.OrderBy(o => o.Name) : items.OrderByDescending(o => o.Name); + break; + case 2: + items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); + break; + case 3: + items = direction == "asc" ? items.OrderBy(o => o.Description) : items.OrderByDescending(o => o.Description); + break; + default: + items = items.OrderBy(o => o.Name); + break; } return items; diff --git a/src/CmsEngine.Application/Services/EmailService.cs b/src/CmsEngine.Application/Services/EmailService.cs index eca793a3..583431e5 100644 --- a/src/CmsEngine.Application/Services/EmailService.cs +++ b/src/CmsEngine.Application/Services/EmailService.cs @@ -3,8 +3,8 @@ namespace CmsEngine.Application.Services; public class EmailService : Service, IEmailService { private readonly IUnitOfWork _unitOfWork; - public EmailService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) - : base(uow, hca, loggerFactory, memoryCache) + public EmailService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) + : base(uow, hca, loggerFactory, cacheService) { _unitOfWork = uow; } diff --git a/src/CmsEngine.Application/Services/Interfaces/ICacheService.cs b/src/CmsEngine.Application/Services/Interfaces/ICacheService.cs new file mode 100644 index 00000000..2fa778ff --- /dev/null +++ b/src/CmsEngine.Application/Services/Interfaces/ICacheService.cs @@ -0,0 +1,12 @@ +namespace CmsEngine.Application.Services.Interfaces; + +public interface ICacheService +{ + T? Get(string key); + + void Set(string key, T value, TimeSpan? expiration = null); + + bool TryGet(string key, out T? value); + + void Remove(string key); +} diff --git a/src/CmsEngine.Application/Services/Interfaces/ICategoryService.cs b/src/CmsEngine.Application/Services/Interfaces/ICategoryService.cs index 35d32339..553a05a4 100644 --- a/src/CmsEngine.Application/Services/Interfaces/ICategoryService.cs +++ b/src/CmsEngine.Application/Services/Interfaces/ICategoryService.cs @@ -6,7 +6,7 @@ public interface ICategoryService : IDataTableService, IDisposable Task SetupEditModel(Guid id); Task Delete(Guid id); Task DeleteRange(Guid[] ids); - Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters); + (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters); Task Save(CategoryEditModel categoryEditModel); Task GetCategoryCount(); Task> GetCategoriesWithPostCount(); diff --git a/src/CmsEngine.Application/Services/Interfaces/IDataTableService.cs b/src/CmsEngine.Application/Services/Interfaces/IDataTableService.cs index acf0702d..c7109aae 100644 --- a/src/CmsEngine.Application/Services/Interfaces/IDataTableService.cs +++ b/src/CmsEngine.Application/Services/Interfaces/IDataTableService.cs @@ -2,6 +2,6 @@ namespace CmsEngine.Application.Services.Interfaces; public interface IDataTableService where T : BaseEntity { - IEnumerable FilterForDataTable(string searchValue, IEnumerable items); - IEnumerable OrderForDataTable(int column, string direction, IEnumerable items); + IQueryable FilterForDataTable(string searchValue, IQueryable items); + IQueryable OrderForDataTable(int column, string direction, IQueryable items); } diff --git a/src/CmsEngine.Application/Services/Interfaces/IPageService.cs b/src/CmsEngine.Application/Services/Interfaces/IPageService.cs index aad41e23..746bee7d 100644 --- a/src/CmsEngine.Application/Services/Interfaces/IPageService.cs +++ b/src/CmsEngine.Application/Services/Interfaces/IPageService.cs @@ -6,7 +6,7 @@ public interface IPageService : IDataTableService, IDisposable Task SetupEditModel(Guid id); Task Delete(Guid id); Task DeleteRange(Guid[] ids); - Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters); + (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters); Task Save(PageEditModel pageEditModel); Task GetBySlug(string slug); Task GetPageCount(); diff --git a/src/CmsEngine.Application/Services/Interfaces/IPostService.cs b/src/CmsEngine.Application/Services/Interfaces/IPostService.cs index de2869d6..c66fc66e 100644 --- a/src/CmsEngine.Application/Services/Interfaces/IPostService.cs +++ b/src/CmsEngine.Application/Services/Interfaces/IPostService.cs @@ -2,11 +2,11 @@ namespace CmsEngine.Application.Services.Interfaces; public interface IPostService : IDataTableService, IDisposable { - Task SetupEditModel(); + PostEditModel SetupEditModel(); Task SetupEditModel(Guid id); Task Delete(Guid id); Task DeleteRange(Guid[] ids); - Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters); + (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters); Task Save(PostEditModel postEditModel); Task> GetPublishedOrderedByDate(int count = 0); Task GetBySlug(string slug); diff --git a/src/CmsEngine.Application/Services/Interfaces/ITagService.cs b/src/CmsEngine.Application/Services/Interfaces/ITagService.cs index da9f68e8..e8e72a58 100644 --- a/src/CmsEngine.Application/Services/Interfaces/ITagService.cs +++ b/src/CmsEngine.Application/Services/Interfaces/ITagService.cs @@ -6,8 +6,8 @@ public interface ITagService : IDataTableService, IDisposable Task SetupEditModel(Guid id); Task Delete(Guid id); Task DeleteRange(Guid[] ids); - Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters); + (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters); Task Save(TagEditModel tagEditModel); Task GetTagCount(); - Task> GetAllTags(); + IEnumerable GetAllTags(); } diff --git a/src/CmsEngine.Application/Services/Interfaces/IWebsiteService.cs b/src/CmsEngine.Application/Services/Interfaces/IWebsiteService.cs index 1e12f3c9..432734ea 100644 --- a/src/CmsEngine.Application/Services/Interfaces/IWebsiteService.cs +++ b/src/CmsEngine.Application/Services/Interfaces/IWebsiteService.cs @@ -10,7 +10,7 @@ public interface IWebsiteService : IDataTableService, IDisposable Task DeleteRange(Guid[] ids); - Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters); + (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters); Task Save(WebsiteEditModel categoryEditModel); } diff --git a/src/CmsEngine.Application/Services/MemoryCacheService.cs b/src/CmsEngine.Application/Services/MemoryCacheService.cs new file mode 100644 index 00000000..555c4ca2 --- /dev/null +++ b/src/CmsEngine.Application/Services/MemoryCacheService.cs @@ -0,0 +1,45 @@ +namespace CmsEngine.Application.Services; + +public class MemoryCacheService : ICacheService +{ + private readonly IMemoryCache _memoryCache; + private readonly bool _sizeLimitEnabled; + + public MemoryCacheService(IMemoryCache memoryCache, MemoryCacheOptions options) + { + _memoryCache = memoryCache; + _sizeLimitEnabled = options.SizeLimit.HasValue; + } + + public T? Get(string key) + { + return _memoryCache.TryGetValue(key, out T value) ? value : default; + } + + public void Set(string key, T value, TimeSpan? expiration = null) + { + var options = new MemoryCacheEntryOptions(); + + if (expiration.HasValue) + { + options.SetSlidingExpiration(expiration.Value); + } + + if (_sizeLimitEnabled) + { + options.Size = 1; + } + + _memoryCache.Set(key, value, options); + } + + public bool TryGet(string key, out T? value) + { + return _memoryCache.TryGetValue(key, out value); + } + + public void Remove(string key) + { + _memoryCache.Remove(key); + } +} diff --git a/src/CmsEngine.Application/Services/PageService.cs b/src/CmsEngine.Application/Services/PageService.cs index 0fafe1b0..ded27023 100644 --- a/src/CmsEngine.Application/Services/PageService.cs +++ b/src/CmsEngine.Application/Services/PageService.cs @@ -2,8 +2,8 @@ namespace CmsEngine.Application.Services; public class PageService : Service, IPageService { - public PageService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) - : base(uow, hca, loggerFactory, memoryCache) + public PageService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) + : base(uow, hca, loggerFactory, cacheService) { } @@ -48,7 +48,7 @@ public async Task DeleteRange(Guid[] ids) return returnValue; } - public IEnumerable FilterForDataTable(string searchValue, IEnumerable items) + public IQueryable FilterForDataTable(string searchValue, IQueryable items) { if (!string.IsNullOrWhiteSpace(searchValue)) { @@ -56,7 +56,7 @@ public IEnumerable FilterForDataTable(string searchValue, IEnumerable GetBySlug(string slug) return item.MapToViewModel(Instance.DateFormat); } - public async Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters) + public (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters) { - var items = await unitOfWork.Pages.GetForDataTable(); + var items = unitOfWork.Pages.GetForDataTable(); var recordsTotal = items.Count(); if (!string.IsNullOrWhiteSpace(parameters.Search?.Value)) { @@ -101,38 +101,31 @@ public async Task GetBySlug(string slug) return (items.MapToTableViewModel(), recordsTotal, items.Count()); } - public IEnumerable OrderForDataTable(int column, string direction, IEnumerable items) + public IQueryable OrderForDataTable(int column, string direction, IQueryable items) { - try - { - switch (column) - { - case 1: - items = direction == "asc" ? items.OrderBy(o => o.Title) : items.OrderByDescending(o => o.Title); - break; - case 2: - items = direction == "asc" ? items.OrderBy(o => o.Description) : items.OrderByDescending(o => o.Description); - break; - case 3: - items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); - break; - //case 4: - // items = direction == "asc" ? items.OrderBy(o => o.Author.FullName) : items.OrderByDescending(o => o.Author.FullName); - // break; - case 5: - items = direction == "asc" ? items.OrderBy(o => o.PublishedOn) : items.OrderByDescending(o => o.PublishedOn); - break; - case 6: - items = direction == "asc" ? items.OrderBy(o => o.Status) : items.OrderByDescending(o => o.Status); - break; - default: - items = items.OrderByDescending(o => o.PublishedOn); - break; - } - } - catch + switch (column) { - throw; + case 1: + items = direction == "asc" ? items.OrderBy(o => o.Title) : items.OrderByDescending(o => o.Title); + break; + case 2: + items = direction == "asc" ? items.OrderBy(o => o.Description) : items.OrderByDescending(o => o.Description); + break; + case 3: + items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); + break; + //case 4: + // items = direction == "asc" ? items.OrderBy(o => o.Author.FullName) : items.OrderByDescending(o => o.Author.FullName); + // break; + case 5: + items = direction == "asc" ? items.OrderBy(o => o.PublishedOn) : items.OrderByDescending(o => o.PublishedOn); + break; + case 6: + items = direction == "asc" ? items.OrderBy(o => o.Status) : items.OrderByDescending(o => o.Status); + break; + default: + items = items.OrderByDescending(o => o.PublishedOn); + break; } return items; diff --git a/src/CmsEngine.Application/Services/PostService.cs b/src/CmsEngine.Application/Services/PostService.cs index f6f7a827..18239155 100644 --- a/src/CmsEngine.Application/Services/PostService.cs +++ b/src/CmsEngine.Application/Services/PostService.cs @@ -2,8 +2,8 @@ namespace CmsEngine.Application.Services; public class PostService : Service, IPostService { - public PostService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) - : base(uow, hca, loggerFactory, memoryCache) + public PostService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) + : base(uow, hca, loggerFactory, cacheService) { } @@ -48,7 +48,7 @@ public async Task DeleteRange(Guid[] ids) return returnValue; } - public IEnumerable FilterForDataTable(string searchValue, IEnumerable items) + public IQueryable FilterForDataTable(string searchValue, IQueryable items) { if (!string.IsNullOrWhiteSpace(searchValue)) { @@ -56,7 +56,7 @@ public IEnumerable FilterForDataTable(string searchValue, IEnumerable> GetPublishedOrderedByDate(int coun return items.MapToEditModel(); } - public async Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters) + public (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters) { - var items = await unitOfWork.Posts.GetForDataTable(); + var items = unitOfWork.Posts.GetForDataTable(); int recordsTotal = items.Count(); if (!string.IsNullOrWhiteSpace(parameters.Search?.Value)) { @@ -104,22 +104,22 @@ public async Task> GetPublishedOrderedByDate(int coun public async Task> GetPublishedByCategoryForPagination(string categorySlug, int page = 1) { - logger.LogDebug("CmsService > GetPublishedByCategoryForPagination(categorySlug: {0}, page: {1})", categorySlug, page); - var posts = await unitOfWork.Posts.GetPublishedByCategoryForPagination(categorySlug, page, Instance.ArticleLimit); + logger.LogDebug("CmsService > GetPublishedByCategoryForPagination(categorySlug: {0}, page: {1})", categorySlug, ValidatePage(page)); + var posts = await unitOfWork.Posts.GetPublishedByCategoryForPagination(categorySlug, ValidatePage(page), Instance.ArticleLimit); return new PaginatedList(posts.Items.MapToViewModelForPartialView(Instance.DateFormat), posts.Count, page, Instance.ArticleLimit); } public async Task> GetPublishedByTagForPagination(string tagSlug, int page = 1) { - logger.LogDebug("CmsService > GetPublishedByTagForPagination(tagSlug: {0}, page: {1})", tagSlug, page); - var posts = await unitOfWork.Posts.GetPublishedByTagForPagination(tagSlug, page, Instance.ArticleLimit); + logger.LogDebug("CmsService > GetPublishedByTagForPagination(tagSlug: {0}, page: {1})", tagSlug, ValidatePage(page)); + var posts = await unitOfWork.Posts.GetPublishedByTagForPagination(tagSlug, ValidatePage(page), Instance.ArticleLimit); return new PaginatedList(posts.Items.MapToViewModelForPartialViewForTags(Instance.DateFormat), posts.Count, page, Instance.ArticleLimit); } public async Task> GetPublishedForPagination(int page = 1) { - logger.LogDebug("CmsService > GetPublishedForPagination(page: {0})", page); - var posts = await unitOfWork.Posts.GetPublishedForPagination(page, Instance.ArticleLimit); + logger.LogDebug("CmsService > GetPublishedForPagination(page: {0})", ValidatePage(page)); + var posts = await unitOfWork.Posts.GetPublishedForPagination(ValidatePage(page), Instance.ArticleLimit); return new PaginatedList(posts.Items.MapToViewModelForPartialView(Instance.DateFormat), posts.Count, page, Instance.ArticleLimit); } @@ -131,43 +131,36 @@ public async Task> GetPublishedLatestPosts(int count) public async Task> FindPublishedForPaginationOrderByDateDescending(string searchTerm = "", int page = 1) { - logger.LogDebug("CmsService > FindPublishedForPaginationOrderByDateDescending(page: {0}, searchTerm: {1})", page, searchTerm); - var posts = await unitOfWork.Posts.FindPublishedForPaginationOrderByDateDescending(page, searchTerm, Instance.ArticleLimit); + logger.LogDebug("CmsService > FindPublishedForPaginationOrderByDateDescending(page: {0}, searchTerm: {1})", ValidatePage(page), searchTerm); + var posts = await unitOfWork.Posts.FindPublishedForPaginationOrderByDateDescending(ValidatePage(page), searchTerm, Instance.ArticleLimit); return new PaginatedList(posts.Items.MapToViewModelForPartialView(Instance.DateFormat), posts.Count, page, Instance.ArticleLimit); } - public IEnumerable OrderForDataTable(int column, string direction, IEnumerable items) + public IQueryable OrderForDataTable(int column, string direction, IQueryable items) { - try - { - switch (column) - { - case 1: - items = direction == "asc" ? items.OrderBy(o => o.Title) : items.OrderByDescending(o => o.Title); - break; - case 2: - items = direction == "asc" ? items.OrderBy(o => o.Description) : items.OrderByDescending(o => o.Description); - break; - case 3: - items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); - break; - //case 4: - // items = direction == "asc" ? items.OrderBy(o => o.Author.FullName) : items.OrderByDescending(o => o.Author.FullName); - // break; - case 5: - items = direction == "asc" ? items.OrderBy(o => o.PublishedOn) : items.OrderByDescending(o => o.PublishedOn); - break; - case 6: - items = direction == "asc" ? items.OrderBy(o => o.Status) : items.OrderByDescending(o => o.Status); - break; - default: - items = items.OrderByDescending(o => o.PublishedOn); - break; - } - } - catch + switch (column) { - throw; + case 1: + items = direction == "asc" ? items.OrderBy(o => o.Title) : items.OrderByDescending(o => o.Title); + break; + case 2: + items = direction == "asc" ? items.OrderBy(o => o.Description) : items.OrderByDescending(o => o.Description); + break; + case 3: + items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); + break; + //case 4: + // items = direction == "asc" ? items.OrderBy(o => o.Author.FullName) : items.OrderByDescending(o => o.Author.FullName); + // break; + case 5: + items = direction == "asc" ? items.OrderBy(o => o.PublishedOn) : items.OrderByDescending(o => o.PublishedOn); + break; + case 6: + items = direction == "asc" ? items.OrderBy(o => o.Status) : items.OrderByDescending(o => o.Status); + break; + default: + items = items.OrderByDescending(o => o.PublishedOn); + break; } return items; @@ -215,13 +208,13 @@ public async Task Save(PostEditModel postEditModel) return returnValue; } - public async Task SetupEditModel() + public PostEditModel SetupEditModel() { logger.LogDebug("PostService > SetupEditModel()"); return new PostEditModel { - Categories = (await unitOfWork.Categories.GetAllAsync()).MapToViewModelSimple().PopulateCheckboxList(), - Tags = (await unitOfWork.Tags.GetAllAsync()).MapToViewModelSimple().PopulateSelectList() + Categories = unitOfWork.Categories.GetAll().MapToViewModelSimple().PopulateCheckboxList(), + Tags = unitOfWork.Tags.GetAll().MapToViewModelSimple().PopulateSelectList() }; } @@ -234,8 +227,8 @@ public async Task SetupEditModel(Guid id) logger.LogDebug("Post: {item}", item.ToString()); var postEditModel = item.MapToEditModel(); - postEditModel.Categories = (await unitOfWork.Categories.GetAllAsync()).MapToViewModelSimple().PopulateCheckboxList(postEditModel.SelectedCategories); - postEditModel.Tags = (await unitOfWork.Tags.GetAllAsync()).MapToViewModelSimple().PopulateSelectList(postEditModel.SelectedTags); + postEditModel.Categories = unitOfWork.Categories.GetAll().MapToViewModelSimple().PopulateCheckboxList(postEditModel.SelectedCategories); + postEditModel.Tags = unitOfWork.Tags.GetAll().MapToViewModelSimple().PopulateSelectList(postEditModel.SelectedTags); return postEditModel; } diff --git a/src/CmsEngine.Application/Services/Service.cs b/src/CmsEngine.Application/Services/Service.cs index 61c39a71..5473e2e3 100644 --- a/src/CmsEngine.Application/Services/Service.cs +++ b/src/CmsEngine.Application/Services/Service.cs @@ -3,7 +3,7 @@ namespace CmsEngine.Application.Services; public class Service : IService { private readonly IHttpContextAccessor httpContextAccessor; - private readonly IMemoryCache memoryCache; + private readonly ICacheService cacheService; private readonly string instanceHost; private readonly string instanceKey; private bool disposedValue; @@ -26,18 +26,50 @@ public UserViewModel CurrentUser { } } - public Service(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) + public Service(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) { unitOfWork = uow ?? throw new ArgumentNullException(nameof(uow)); httpContextAccessor = hca; logger = loggerFactory.CreateLogger("Service"); - this.memoryCache = memoryCache; + this.cacheService = cacheService; instanceHost = httpContextAccessor.HttpContext!.Request.Host.Host; instanceKey = $"{Main.CacheKey.Instance}_{instanceHost}"; } - async internal Task GetCurrentUserAsync() + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + unitOfWork.Dispose(); + } + + disposedValue = true; + } + } + + protected int ValidatePage(int page) + { + return Math.Max(page, 1); + } + + protected void SaveInstanceToCache(object instance) + { + var timeSpan = TimeSpan.FromHours(1); //TODO: Perhaps set this in the config file. Or DB + logger.LogDebug("Adding '{instanceKey}' to cache with expiration date to {dateTimeNow}", instanceKey, DateTime.Now.AddMilliseconds(timeSpan.TotalMilliseconds).ToString()); + cacheService.Set(instanceKey, instance, timeSpan); + } + + protected async Task GetCurrentUserAsync() { var userName = httpContextAccessor.HttpContext?.User.Identity?.Name ?? "username"; logger.LogDebug("GetCurrentUserAsync() for {userName}", userName); @@ -54,118 +86,82 @@ async internal Task GetCurrentUserAsync() } } - protected void SaveInstanceToCache(object instance) - { - var timeSpan = TimeSpan.FromHours(1); //TODO: Perhaps set this in the config file. Or DB - logger.LogDebug("Adding '{instanceKey}' to cache with expiration date to {dateTimeNow}", instanceKey, DateTime.Now.AddMilliseconds(timeSpan.TotalMilliseconds).ToString()); - var cacheEntryOptions = new MemoryCacheEntryOptions().SetSlidingExpiration(timeSpan); - memoryCache.Set(instanceKey, instance, cacheEntryOptions); - } - private InstanceViewModel GetInstance() { logger.LogDebug("GetInstanceAsync()"); logger.LogDebug("Loading '{instanceKey}' from cache", instanceKey); - InstanceViewModel? instance; - - try + if (cacheService.TryGet(instanceKey, out InstanceViewModel? instance)) { - if (!memoryCache.TryGetValue(instanceKey, out instance)) - { - logger.LogDebug("Empty cache for '{instanceKey}'. Loading instance from DB", instanceKey); - var website = unitOfWork.Websites.GetWebsiteInstanceByHost(instanceHost); - - if (website == null) - { - throw new ItemNotFoundException($"Instance for '{instanceHost}' not found"); - } - - instance = new InstanceViewModel - { - Id = website.Id, - Name = website.Name, - Description = website.Description, - Tagline = website.Tagline, - HeaderImage = website.HeaderImage, - Culture = website.Culture, - UrlFormat = website.UrlFormat, - DateFormat = website.DateFormat, - SiteUrl = website.SiteUrl, - ArticleLimit = website.ArticleLimit, - PageTitle = website.Name, - ContactDetails = new ContactDetailsViewModel - { - Address = website.Address, - Phone = website.Phone, - Email = website.Email, - }, - ApiDetails = new ApiDetailsViewModel - { - FacebookAppId = website.FacebookAppId, - FacebookApiVersion = website.FacebookApiVersion, - DisqusShortName = website.DisqusShortName - }, - SocialMedia = new SocialMediaViewModel - { - Facebook = website.Facebook, - Twitter = website.Twitter, - Instagram = website.Instagram, - LinkedIn = website.LinkedIn - }, - Google = new GoogleViewModel - { - GoogleAnalytics = website.GoogleAnalytics, - GoogleRecaptchaSiteKey = website.GoogleRecaptchaSiteKey, - GoogleRecaptchaSecretKey = website.GoogleRecaptchaSecretKey - } - }; - - SaveInstanceToCache(instance); - } + return instance!; } - catch (Exception ex) + + logger.LogDebug("Empty cache for '{instanceKey}'. Loading instance from DB", instanceKey); + var website = unitOfWork.Websites.GetWebsiteInstanceByHost(instanceHost); + + if (website == null) { - logger.LogError(ex, "Error when trying to load Instance"); - throw; + throw new ItemNotFoundException($"Instance for '{instanceHost}' not found"); } + instance = new InstanceViewModel + { + Id = website.Id, + Name = website.Name, + Description = website.Description, + Tagline = website.Tagline, + HeaderImage = website.HeaderImage, + Culture = website.Culture, + UrlFormat = website.UrlFormat, + DateFormat = website.DateFormat, + SiteUrl = website.SiteUrl, + ArticleLimit = website.ArticleLimit, + PageTitle = website.Name, + ContactDetails = new ContactDetailsViewModel + { + Address = website.Address, + Phone = website.Phone, + Email = website.Email, + }, + ApiDetails = new ApiDetailsViewModel + { + FacebookAppId = website.FacebookAppId, + FacebookApiVersion = website.FacebookApiVersion, + DisqusShortName = website.DisqusShortName + }, + SocialMedia = new SocialMediaViewModel + { + Facebook = website.Facebook, + Twitter = website.Twitter, + Instagram = website.Instagram, + LinkedIn = website.LinkedIn + }, + Google = new GoogleViewModel + { + GoogleAnalytics = website.GoogleAnalytics, + GoogleRecaptchaSiteKey = website.GoogleRecaptchaSiteKey, + GoogleRecaptchaSecretKey = website.GoogleRecaptchaSecretKey + } + }; + + SaveInstanceToCache(instance); + return instance!; } private async Task GetCurrentUserViewModelAsync() { - var user = await GetCurrentUserAsync(); + var currentUser = await GetCurrentUserAsync(); - return user == null - ? null + return currentUser is null + ? throw new Exception("Current user not found") : new UserViewModel { - VanityId = Guid.Parse(user.Id), - Name = user.Name, - Surname = user.Surname, - Email = user.Email, - UserName = user.UserName + VanityId = Guid.Parse(currentUser.Id), + Name = currentUser.Name, + Surname = currentUser.Surname, + Email = currentUser.Email, + UserName = currentUser.UserName }; } - - protected virtual void Dispose(bool disposing) - { - if (!disposedValue) - { - if (disposing) - { - unitOfWork.Dispose(); - } - - disposedValue = true; - } - } - - public void Dispose() - { - // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method - Dispose(disposing: true); - GC.SuppressFinalize(this); - } } diff --git a/src/CmsEngine.Application/Services/TagService.cs b/src/CmsEngine.Application/Services/TagService.cs index d71c12c1..06c1da29 100644 --- a/src/CmsEngine.Application/Services/TagService.cs +++ b/src/CmsEngine.Application/Services/TagService.cs @@ -2,8 +2,8 @@ namespace CmsEngine.Application.Services; public class TagService : Service, ITagService { - public TagService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) - : base(uow, hca, loggerFactory, memoryCache) + public TagService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) + : base(uow, hca, loggerFactory, cacheService) { } @@ -12,7 +12,7 @@ public async Task Delete(Guid id) var item = await unitOfWork.Tags.GetByIdAsync(id); Guard.Against.Null(item); - var returnValue = new ReturnValue($"Tag '{item.Name}' deleted at {DateTime.Now.ToString("T")}."); + var returnValue = new ReturnValue($"Tag '{item.Name}' deleted at {DateTime.Now:T}."); try { @@ -32,7 +32,7 @@ public async Task DeleteRange(Guid[] ids) { var items = await unitOfWork.Tags.GetByMultipleIdsAsync(ids); - var returnValue = new ReturnValue($"Tags deleted at {DateTime.Now.ToString("T")}."); + var returnValue = new ReturnValue($"Tags deleted at {DateTime.Now:T}."); try { @@ -48,7 +48,7 @@ public async Task DeleteRange(Guid[] ids) return returnValue; } - public IEnumerable FilterForDataTable(string searchValue, IEnumerable items) + public IQueryable FilterForDataTable(string searchValue, IQueryable items) { if (!string.IsNullOrWhiteSpace(searchValue)) { @@ -56,15 +56,15 @@ public IEnumerable FilterForDataTable(string searchValue, IEnumerable var searchExpression = items.GetSearchExpression(searchValue, searchableProperties); Guard.Against.Null(searchExpression); - items = items.Where(searchExpression.Compile()); + items = items.Where(searchExpression); } return items; } - public async Task<(IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters) + public (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters) { - var items = await unitOfWork.Tags.GetAllAsync(); - int recordsTotal = items.Count(); + var items = unitOfWork.Tags.GetAll(); + var recordsTotal = items.Count(); if (!string.IsNullOrWhiteSpace(parameters.Search?.Value)) { items = FilterForDataTable(parameters.Search.Value, items); @@ -79,38 +79,31 @@ public async Task GetTagCount() { logger.LogDebug("TagService > GetTagCount()"); var items = await unitOfWork.Tags.CountAsync(); - logger.LogDebug("Tag count: {0}", items); + logger.LogDebug("Tag count: {items}", items); return items; } - public async Task> GetAllTags() + public IEnumerable GetAllTags() { logger.LogDebug("TagService > GetAllTags()"); - var items = await unitOfWork.Tags.GetAllAsync(); - logger.LogDebug("Tags loaded: {0}", items.Count()); + var items = unitOfWork.Tags.GetAll(); + logger.LogDebug("Tags loaded: {items}", items.Count()); return items.MapToViewModel(); } - public IEnumerable OrderForDataTable(int column, string direction, IEnumerable items) + public IQueryable OrderForDataTable(int column, string direction, IQueryable items) { - try - { - switch (column) - { - case 1: - items = direction == "asc" ? items.OrderBy(o => o.Name) : items.OrderByDescending(o => o.Name); - break; - case 2: - items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); - break; - default: - items = items.OrderBy(o => o.Name); - break; - } - } - catch + switch (column) { - throw; + case 1: + items = direction == "asc" ? items.OrderBy(o => o.Name) : items.OrderByDescending(o => o.Name); + break; + case 2: + items = direction == "asc" ? items.OrderBy(o => o.Slug) : items.OrderByDescending(o => o.Slug); + break; + default: + items = items.OrderBy(o => o.Name); + break; } return items; @@ -118,7 +111,7 @@ public IEnumerable OrderForDataTable(int column, string direction, IEnumera public async Task Save(TagEditModel tagEditModel) { - logger.LogDebug("CmsService > Save(TagEditModel: {0})", tagEditModel.ToString()); + logger.LogDebug("CmsService > Save(TagEditModel: {tagEditModel})", tagEditModel.ToString()); var returnValue = new ReturnValue($"Tag '{tagEditModel.Name}' saved."); diff --git a/src/CmsEngine.Application/Services/WebsiteService.cs b/src/CmsEngine.Application/Services/WebsiteService.cs index e98331c1..8592db4c 100644 --- a/src/CmsEngine.Application/Services/WebsiteService.cs +++ b/src/CmsEngine.Application/Services/WebsiteService.cs @@ -2,8 +2,8 @@ namespace CmsEngine.Application.Services; public class WebsiteService : Service, IWebsiteService { - public WebsiteService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) - : base(uow, hca, loggerFactory, memoryCache) + public WebsiteService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) + : base(uow, hca, loggerFactory, cacheService) { } @@ -48,7 +48,7 @@ public async Task DeleteRange(Guid[] ids) return returnValue; } - public IEnumerable FilterForDataTable(string searchValue, IEnumerable items) + public IQueryable FilterForDataTable(string searchValue, IQueryable items) { if (!string.IsNullOrWhiteSpace(searchValue)) { @@ -56,15 +56,15 @@ public IEnumerable FilterForDataTable(string searchValue, IEnumerable Data, int RecordsTotal, int RecordsFiltered)> GetForDataTable(DataParameters parameters) + public (IEnumerable Data, int RecordsTotal, int RecordsFiltered) GetForDataTable(DataParameters parameters) { - var items = await unitOfWork.Websites.GetForDataTable(); + var items = unitOfWork.Websites.GetForDataTable(); var recordsTotal = items.Count(); if (!string.IsNullOrWhiteSpace(parameters.Search?.Value)) { @@ -76,29 +76,22 @@ public IEnumerable FilterForDataTable(string searchValue, IEnumerable OrderForDataTable(int column, string direction, IEnumerable items) + public IQueryable OrderForDataTable(int column, string direction, IQueryable items) { - try - { - switch (column) - { - case 1: - items = direction == "asc" ? items.OrderBy(o => o.Name) : items.OrderByDescending(o => o.Name); - break; - case 2: - items = direction == "asc" ? items.OrderBy(o => o.Tagline) : items.OrderByDescending(o => o.Tagline); - break; - case 3: - items = direction == "asc" ? items.OrderBy(o => o.Culture) : items.OrderByDescending(o => o.Culture); - break; - default: - items = items.OrderBy(o => o.Name); - break; - } - } - catch + switch (column) { - throw; + case 1: + items = direction == "asc" ? items.OrderBy(o => o.Name) : items.OrderByDescending(o => o.Name); + break; + case 2: + items = direction == "asc" ? items.OrderBy(o => o.Tagline) : items.OrderByDescending(o => o.Tagline); + break; + case 3: + items = direction == "asc" ? items.OrderBy(o => o.Culture) : items.OrderByDescending(o => o.Culture); + break; + default: + items = items.OrderBy(o => o.Name); + break; } return items; diff --git a/src/CmsEngine.Application/Services/XmlService.cs b/src/CmsEngine.Application/Services/XmlService.cs index cfe5ee06..cc84ccaf 100644 --- a/src/CmsEngine.Application/Services/XmlService.cs +++ b/src/CmsEngine.Application/Services/XmlService.cs @@ -4,8 +4,8 @@ public class XmlService : Service, IXmlService { private readonly IUnitOfWork _unitOfWork; - public XmlService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, IMemoryCache memoryCache) - : base(uow, hca, loggerFactory, memoryCache) + public XmlService(IUnitOfWork uow, IHttpContextAccessor hca, ILoggerFactory loggerFactory, ICacheService cacheService) + : base(uow, hca, loggerFactory, cacheService) { _unitOfWork = uow; } @@ -16,7 +16,7 @@ public async Task GenerateFeed() foreach (var item in await _unitOfWork.Posts.GetPublishedPostsOrderByDescending(o => o.PublishedOn)) { - var url = FormatUrl("blog/post", item.Slug); + var url = FormatUrl("blog/post/", item.Slug); articleList.Add(new XElement("item", new XElement("title", item.Title), new XElement("link", url), @@ -44,14 +44,14 @@ public async Task GenerateSitemap() var orderedPosts = await _unitOfWork.Posts.GetPublishedPostsOrderByDescending(o => o.PublishedOn); items.AddRange(orderedPosts.Select(x => new SitemapViewModel { - Url = FormatUrl("blog/post", x.Slug), + Url = FormatUrl("blog/post/", x.Slug), PublishedOn = x.PublishedOn.ToString("yyyy-MM-dd") })); var orderedPages = await _unitOfWork.Pages.GetOrderByDescending(o => o.PublishedOn); items.AddRange(orderedPages.Select(x => new SitemapViewModel { - Url = FormatUrl("page", x.Slug), + Url = FormatUrl(string.Empty, x.Slug), PublishedOn = x.PublishedOn.ToString("yyyy-MM-dd") })); @@ -76,7 +76,7 @@ private string FormatUrl(string type, string slug = "") url = Instance.UrlFormat.Replace("[site_url]", Instance.SiteUrl) .Replace("[culture]", Instance.Culture) .Replace("[short_culture]", Instance.Culture.Substring(0, 2)) - .Replace("[type]", type) + .Replace("[type]/", type) .Replace("[slug]", slug); } diff --git a/src/CmsEngine.Core/CmsEngine.Core.csproj b/src/CmsEngine.Core/CmsEngine.Core.csproj index 931f1b3e..9fd94df1 100644 --- a/src/CmsEngine.Core/CmsEngine.Core.csproj +++ b/src/CmsEngine.Core/CmsEngine.Core.csproj @@ -1,13 +1,13 @@ - net8.0 + net9.0 enable enable - + diff --git a/src/CmsEngine.Core/Constants/Auditing.cs b/src/CmsEngine.Core/Constants/Auditing.cs new file mode 100644 index 00000000..cd2abe5d --- /dev/null +++ b/src/CmsEngine.Core/Constants/Auditing.cs @@ -0,0 +1,12 @@ +namespace CmsEngine.Core.Constants; + +/// +/// Auditing-related constants. +/// +public static class Auditing +{ + public const string DateCreated = "DateCreated"; + public const string UserCreated = "UserCreated"; + public const string DateModified = "DateModified"; + public const string UserModified = "UserModified"; +} diff --git a/src/CmsEngine.Core/Utils/Enums.cs b/src/CmsEngine.Core/Utils/Enums.cs index 6fa1c608..dbd6d53d 100644 --- a/src/CmsEngine.Core/Utils/Enums.cs +++ b/src/CmsEngine.Core/Utils/Enums.cs @@ -1,5 +1,16 @@ namespace CmsEngine.Core.Utils; +public enum CanonicalType +{ + Index, + Archive, + Blog, + Category, + Page, + Post, + Tag +} + public enum Gender { Male, diff --git a/src/CmsEngine.Data/CmsEngine.Data.csproj b/src/CmsEngine.Data/CmsEngine.Data.csproj index 5acebbc6..033cbeac 100644 --- a/src/CmsEngine.Data/CmsEngine.Data.csproj +++ b/src/CmsEngine.Data/CmsEngine.Data.csproj @@ -1,15 +1,15 @@ - net8.0 + net9.0 enable enable - - - + + + diff --git a/src/CmsEngine.Data/CmsEngineContext.cs b/src/CmsEngine.Data/CmsEngineContext.cs index f8c9743d..b18b061d 100644 --- a/src/CmsEngine.Data/CmsEngineContext.cs +++ b/src/CmsEngine.Data/CmsEngineContext.cs @@ -47,20 +47,21 @@ public override Task SaveChangesAsync(CancellationToken cancellationToken = { ChangeTracker.DetectChanges(); - var timeStamp = DateTime.Now; + // TODO: Make it testable + var currentUsername = httpContextAccessor.HttpContext?.User.Identity?.Name ?? "anonymous"; + + var timeStamp = DateTime.UtcNow; var entries = ChangeTracker.Entries().Where(e => e.Entity is BaseEntity && (e.State == EntityState.Added || e.State == EntityState.Modified)); - // TODO: Find a better way to get the user - var currentUsername = httpContextAccessor.HttpContext?.User.Identity?.Name ?? "username"; foreach (var entry in entries) { if (entry.State == EntityState.Added) { - entry.Property("DateCreated").CurrentValue = timeStamp; - entry.Property("UserCreated").CurrentValue = currentUsername; + entry.Property(Auditing.DateCreated).CurrentValue = timeStamp; + entry.Property(Auditing.UserCreated).CurrentValue = currentUsername; } - entry.Property("DateModified").CurrentValue = timeStamp; - entry.Property("UserModified").CurrentValue = currentUsername; + entry.Property(Auditing.DateModified).CurrentValue = timeStamp; + entry.Property(Auditing.UserModified).CurrentValue = currentUsername; } return base.SaveChangesAsync(cancellationToken); diff --git a/src/CmsEngine.Data/GlobalUsings.cs b/src/CmsEngine.Data/GlobalUsings.cs index 0959ff5b..47cdf7ce 100644 --- a/src/CmsEngine.Data/GlobalUsings.cs +++ b/src/CmsEngine.Data/GlobalUsings.cs @@ -13,3 +13,4 @@ global using Microsoft.EntityFrameworkCore; global using Microsoft.EntityFrameworkCore.ChangeTracking; global using Microsoft.EntityFrameworkCore.Metadata.Builders; +global using Microsoft.Extensions.Logging; diff --git a/src/CmsEngine.Data/IUnitOfWork.cs b/src/CmsEngine.Data/IUnitOfWork.cs index d62edde5..7fc3ecd1 100644 --- a/src/CmsEngine.Data/IUnitOfWork.cs +++ b/src/CmsEngine.Data/IUnitOfWork.cs @@ -13,5 +13,5 @@ public interface IUnitOfWork : IDisposable /// /// Saves all pending changes into the database /// - Task Save(); + Task Save(CancellationToken cancellationToken = default); } diff --git a/src/CmsEngine.Data/Repositories/Interfaces/IPageRepository.cs b/src/CmsEngine.Data/Repositories/Interfaces/IPageRepository.cs index 0ac00f0d..d3b800a4 100644 --- a/src/CmsEngine.Data/Repositories/Interfaces/IPageRepository.cs +++ b/src/CmsEngine.Data/Repositories/Interfaces/IPageRepository.cs @@ -6,7 +6,7 @@ public interface IPageRepository : IReadRepository, IDataModificationRepos Task> GetByStatusOrderByDescending(DocumentStatus documentStatus); - Task> GetForDataTable(); + IQueryable GetForDataTable(); Task GetBySlug(string slug); diff --git a/src/CmsEngine.Data/Repositories/Interfaces/IPostRepository.cs b/src/CmsEngine.Data/Repositories/Interfaces/IPostRepository.cs index 152b5e0d..14a703c8 100644 --- a/src/CmsEngine.Data/Repositories/Interfaces/IPostRepository.cs +++ b/src/CmsEngine.Data/Repositories/Interfaces/IPostRepository.cs @@ -16,7 +16,7 @@ public interface IPostRepository : IReadRepository, IDataModificationRepos Task> GetPublishedLatestPosts(int count); - Task> GetForDataTable(); + IQueryable GetForDataTable(); Task GetForSavingById(Guid id); diff --git a/src/CmsEngine.Data/Repositories/Interfaces/IReadRepository.cs b/src/CmsEngine.Data/Repositories/Interfaces/IReadRepository.cs index 69ef5411..8c33a544 100644 --- a/src/CmsEngine.Data/Repositories/Interfaces/IReadRepository.cs +++ b/src/CmsEngine.Data/Repositories/Interfaces/IReadRepository.cs @@ -12,7 +12,7 @@ public interface IReadRepository where TEntity : class /// Get all records which were not marked as deleted (IsDeleted == false) /// /// - Task> GetAllAsync(); + IQueryable GetAll(); /// /// Gets item based on condition and includes extra table diff --git a/src/CmsEngine.Data/Repositories/Interfaces/IWebsiteRepository.cs b/src/CmsEngine.Data/Repositories/Interfaces/IWebsiteRepository.cs index 07d1fdf4..4cca0ebf 100644 --- a/src/CmsEngine.Data/Repositories/Interfaces/IWebsiteRepository.cs +++ b/src/CmsEngine.Data/Repositories/Interfaces/IWebsiteRepository.cs @@ -4,5 +4,5 @@ public interface IWebsiteRepository : IReadRepository, IDataModificatio { Website? GetWebsiteInstanceByHost(string host); - Task> GetForDataTable(); + IQueryable GetForDataTable(); } diff --git a/src/CmsEngine.Data/Repositories/PageRepository.cs b/src/CmsEngine.Data/Repositories/PageRepository.cs index b345ed5e..89352509 100644 --- a/src/CmsEngine.Data/Repositories/PageRepository.cs +++ b/src/CmsEngine.Data/Repositories/PageRepository.cs @@ -40,9 +40,9 @@ public async Task> GetByStatusOrderByDescending(DocumentStatus .SingleOrDefaultAsync(); } - public async Task> GetForDataTable() + public IQueryable GetForDataTable() { - return await Get().Select(p => new Page + return Get().Select(p => new Page { VanityId = p.VanityId, Title = p.Title, @@ -57,7 +57,7 @@ public async Task> GetForDataTable() Surname = au.Surname, Email = au.Email }) - }).ToListAsync(); + }); } public async Task GetForSavingById(Guid id) diff --git a/src/CmsEngine.Data/Repositories/PostRepository.cs b/src/CmsEngine.Data/Repositories/PostRepository.cs index 5c93b615..048feee5 100644 --- a/src/CmsEngine.Data/Repositories/PostRepository.cs +++ b/src/CmsEngine.Data/Repositories/PostRepository.cs @@ -229,9 +229,9 @@ public async Task> GetPublishedLatestPosts(int count) .OrderByDescending(o => o.PublishedOn).Take(count).ToListAsync(); } - public async Task> GetForDataTable() + public IQueryable GetForDataTable() { - return await Get().Select(p => new Post + return Get().Select(p => new Post { VanityId = p.VanityId, Title = p.Title, @@ -246,7 +246,7 @@ public async Task> GetForDataTable() Surname = au.Surname, Email = au.Email }) - }).ToListAsync(); + }); } public async Task GetForSavingById(Guid id) diff --git a/src/CmsEngine.Data/Repositories/Repository.cs b/src/CmsEngine.Data/Repositories/Repository.cs index ee674e16..42d3c763 100644 --- a/src/CmsEngine.Data/Repositories/Repository.cs +++ b/src/CmsEngine.Data/Repositories/Repository.cs @@ -11,7 +11,6 @@ public class Repository : IReadRepository, public Repository(CmsEngineContext context) { - ArgumentNullException.ThrowIfNull(nameof(context)); dbContext = context; } @@ -20,9 +19,9 @@ public async Task CountAsync() return await Get().CountAsync(); } - public async Task> GetAllAsync() + public IQueryable GetAll() { - return await GetValidRecords().ToListAsync(); + return GetValidRecords(); } public IQueryable Get(Expression>? filter = null, int count = 0) @@ -84,50 +83,35 @@ public async Task> GetIdsByMultipleGuidsAsync(IEnumerable public async Task Insert(TEntity entity) { - if (entity is null) - { - throw new ArgumentNullException(nameof(entity)); - } + ArgumentNullException.ThrowIfNull(entity); await dbContext.AddAsync(entity); } public async Task InsertRange(IEnumerable entities) { - if (entities is null) - { - throw new ArgumentNullException(nameof(entities)); - } + ArgumentNullException.ThrowIfNull(entities); await dbContext.AddRangeAsync(entities); } public void Update(TEntity entity) { - if (entity is null) - { - throw new ArgumentNullException(nameof(entity)); - } + ArgumentNullException.ThrowIfNull(entity); dbContext.Update(entity); } public void UpdateRange(IEnumerable entities) { - if (entities is null) - { - throw new ArgumentNullException(nameof(entities)); - } + ArgumentNullException.ThrowIfNull(entities); dbContext.UpdateRange(entities); } public void Delete(TEntity entity) { - if (entity is null) - { - throw new ArgumentNullException(nameof(entity)); - } + ArgumentNullException.ThrowIfNull(entity); // We never delete anything, only update the IsDelete flag entity.IsDeleted = true; @@ -136,10 +120,7 @@ public void Delete(TEntity entity) public void DeleteRange(IEnumerable entities) { - if (entities is null) - { - throw new ArgumentNullException(nameof(entities)); - } + ArgumentNullException.ThrowIfNull(entities); for (var i = 0; i < entities.Count(); i++) { diff --git a/src/CmsEngine.Data/Repositories/WebsiteRepository.cs b/src/CmsEngine.Data/Repositories/WebsiteRepository.cs index 8749bb4f..5d400973 100644 --- a/src/CmsEngine.Data/Repositories/WebsiteRepository.cs +++ b/src/CmsEngine.Data/Repositories/WebsiteRepository.cs @@ -7,9 +7,9 @@ public WebsiteRepository(CmsEngineContext context) : base(context) } - public async Task> GetForDataTable() + public IQueryable GetForDataTable() { - return await Get().Select(w => new Website + return Get().Select(w => new Website { VanityId = w.VanityId, Name = w.Name, @@ -19,7 +19,7 @@ public async Task> GetForDataTable() DateFormat = w.DateFormat, SiteUrl = w.SiteUrl, GoogleAnalytics = w.GoogleAnalytics - }).ToListAsync(); + }); } public Website? GetWebsiteInstanceByHost(string host) diff --git a/src/CmsEngine.Data/UnitOfWork.cs b/src/CmsEngine.Data/UnitOfWork.cs index 8202a945..160c6284 100644 --- a/src/CmsEngine.Data/UnitOfWork.cs +++ b/src/CmsEngine.Data/UnitOfWork.cs @@ -3,6 +3,7 @@ namespace CmsEngine.Data; public class UnitOfWork : IUnitOfWork { private readonly CmsEngineContext _ctx; + private readonly ILogger _logger; private bool disposedValue; public ICategoryRepository Categories { get; private set; } @@ -15,9 +16,10 @@ public class UnitOfWork : IUnitOfWork public UnitOfWork(CmsEngineContext context, ICategoryRepository categoryRepository, IPageRepository pageRepository, IPostRepository postRepository, ITagRepository tagRepository, IWebsiteRepository websiteRepository, - UserManager userManager, IEmailRepository emailRepository) + UserManager userManager, IEmailRepository emailRepository, ILogger logger) { _ctx = context; + _logger = logger; Categories = categoryRepository; Pages = pageRepository; @@ -30,18 +32,25 @@ public UnitOfWork(CmsEngineContext context, ICategoryRepository categoryReposito disposedValue = false; } - public async Task Save() + public async Task Save(CancellationToken cancellationToken = default) { try { - await _ctx.SaveChangesAsync(); + return await _ctx.SaveChangesAsync(cancellationToken); } catch (DbUpdateConcurrencyException ex) { - ex.Entries[0].Reload(); + foreach (var entry in ex.Entries) + { + await entry.ReloadAsync(); + } + + throw new DbUpdateConcurrencyException("A concurrency conflict occurred. All entries were reloaded."); } catch (DbUpdateException ex) { + _logger.LogError(ex, "Unable to update data."); + var innerException = ex.InnerException; if (innerException is not null) { @@ -52,28 +61,42 @@ public async Task Save() throw; } } - catch + catch (Exception ex) { + _logger.LogError(ex, "Unable to save data."); throw; } } protected virtual void Dispose(bool disposing) { - if (!disposedValue) + if (disposedValue) { - if (disposing) - { - Categories.Dispose(); - Pages.Dispose(); - Posts.Dispose(); - Tags.Dispose(); - Users.Dispose(); - - _ctx.Dispose(); - } + return; } + disposedValue = true; + + if (disposing) + { + // Dispose managed resources + try { Categories?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing Categories."); } + try { Pages?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing Pages."); } + try { Posts?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing Posts."); } + try { Tags?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing Tags."); } + try { Websites?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing Websites."); } + try { Users?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing Users."); } + try { _ctx?.Dispose(); } catch (Exception ex) { _logger.LogError(ex, "Error disposing DbContext."); } + + // Null out references to help GC + Categories = null!; + Pages = null!; + Posts = null!; + Tags = null!; + Websites = null!; + Users = null!; + Emails = null!; + } } public void Dispose() diff --git a/src/CmsEngine.Ui/Areas/Cms/Controllers/CategoryController.cs b/src/CmsEngine.Ui/Areas/Cms/Controllers/CategoryController.cs index 1773fa01..c1d8de8a 100644 --- a/src/CmsEngine.Ui/Areas/Cms/Controllers/CategoryController.cs +++ b/src/CmsEngine.Ui/Areas/Cms/Controllers/CategoryController.cs @@ -81,11 +81,11 @@ public async Task BulkDeleteAsync([FromForm] Guid[] vanityId) } [HttpPost] - public async Task GetDataAsync([FromForm] DataParameters parameters) + public IActionResult GetData([FromForm] DataParameters parameters) { Guard.Against.Null(parameters); - var items = await _categoryService.GetForDataTable(parameters); + var items = _categoryService.GetForDataTable(parameters); var dataTable = DataTableHelper.BuildDataTable(items.Data, items.RecordsTotal, items.RecordsFiltered, parameters.Draw, parameters.Start, parameters.Length); return Ok(dataTable); diff --git a/src/CmsEngine.Ui/Areas/Cms/Controllers/PageController.cs b/src/CmsEngine.Ui/Areas/Cms/Controllers/PageController.cs index c9ea4a4e..9d2b8fa3 100644 --- a/src/CmsEngine.Ui/Areas/Cms/Controllers/PageController.cs +++ b/src/CmsEngine.Ui/Areas/Cms/Controllers/PageController.cs @@ -85,11 +85,11 @@ public async Task BulkDeleteAsync([FromForm] Guid[] vanityId) } [HttpPost] - public async Task GetDataAsync([FromForm] DataParameters parameters) + public IActionResult GetData([FromForm] DataParameters parameters) { Guard.Against.Equals(parameters); - var items = await _pageService.GetForDataTable(parameters); + var items = _pageService.GetForDataTable(parameters); var dataTable = DataTableHelper.BuildDataTable(items.Data, items.RecordsTotal, items.RecordsFiltered, parameters.Draw, parameters.Start, parameters.Length); return Ok(dataTable); diff --git a/src/CmsEngine.Ui/Areas/Cms/Controllers/PostController.cs b/src/CmsEngine.Ui/Areas/Cms/Controllers/PostController.cs index 5ad8c05d..d7c14cca 100644 --- a/src/CmsEngine.Ui/Areas/Cms/Controllers/PostController.cs +++ b/src/CmsEngine.Ui/Areas/Cms/Controllers/PostController.cs @@ -21,10 +21,10 @@ public IActionResult Index() return View("List"); } - public async Task CreateAsync() + public IActionResult Create() { SetupMessages("Post", PageType.Create, panelTitle: "Create a new post"); - var postEditModel = await _postService.SetupEditModel(); + var postEditModel = _postService.SetupEditModel(); return View("CreateEdit", postEditModel); } @@ -86,11 +86,11 @@ public async Task BulkDeleteAsync([FromForm] Guid[] vanityId) } [HttpPost] - public async Task GetDataAsync([FromForm] DataParameters parameters) + public IActionResult GetData([FromForm] DataParameters parameters) { Guard.Against.Equals(parameters); - var items = await _postService.GetForDataTable(parameters); + var items = _postService.GetForDataTable(parameters); var dataTable = DataTableHelper.BuildDataTable(items.Data, items.RecordsTotal, items.RecordsFiltered, parameters.Draw, parameters.Start, parameters.Length); return Ok(dataTable); diff --git a/src/CmsEngine.Ui/Areas/Cms/Controllers/TagController.cs b/src/CmsEngine.Ui/Areas/Cms/Controllers/TagController.cs index 51a4f58c..e384a1ea 100644 --- a/src/CmsEngine.Ui/Areas/Cms/Controllers/TagController.cs +++ b/src/CmsEngine.Ui/Areas/Cms/Controllers/TagController.cs @@ -80,11 +80,11 @@ public async Task BulkDeleteAsync([FromForm] Guid[] vanityId) } [HttpPost] - public async Task GetDataAsync([FromForm] DataParameters parameters) + public IActionResult GetData([FromForm] DataParameters parameters) { Guard.Against.Equals(parameters); - var items = await _tagService.GetForDataTable(parameters); + var items = _tagService.GetForDataTable(parameters); var dataTable = DataTableHelper.BuildDataTable(items.Data, items.RecordsTotal, items.RecordsFiltered, parameters.Draw, parameters.Start, parameters.Length); return Ok(dataTable); diff --git a/src/CmsEngine.Ui/Areas/Cms/Controllers/WebsiteController.cs b/src/CmsEngine.Ui/Areas/Cms/Controllers/WebsiteController.cs index 0aaed11d..287a34e0 100644 --- a/src/CmsEngine.Ui/Areas/Cms/Controllers/WebsiteController.cs +++ b/src/CmsEngine.Ui/Areas/Cms/Controllers/WebsiteController.cs @@ -84,11 +84,11 @@ public async Task BulkDeleteAsync([FromForm] Guid[] vanityId) } [HttpPost] - public async Task GetDataAsync([FromForm] DataParameters parameters) + public IActionResult GetData([FromForm] DataParameters parameters) { Guard.Against.Equals(parameters); - var items = await _websiteService.GetForDataTable(parameters); + var items = _websiteService.GetForDataTable(parameters); var dataTable = DataTableHelper.BuildDataTable(items.Data, items.RecordsTotal, items.RecordsFiltered, parameters.Draw, parameters.Start, parameters.Length); return Ok(dataTable); diff --git a/src/CmsEngine.Ui/CmsEngine.Ui.csproj b/src/CmsEngine.Ui/CmsEngine.Ui.csproj index 980fdb57..9252b777 100644 --- a/src/CmsEngine.Ui/CmsEngine.Ui.csproj +++ b/src/CmsEngine.Ui/CmsEngine.Ui.csproj @@ -1,7 +1,7 @@ - net8.0 + net9.0 enable enable aspnet-CmsEngine.Ui-4236C055-B60B-45F7-AA52-69581CC4669C @@ -9,20 +9,20 @@ true - - - - - - + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + - + @@ -31,4 +31,10 @@ + + + Never + + + diff --git a/src/CmsEngine.Ui/Controllers/BaseController.cs b/src/CmsEngine.Ui/Controllers/BaseController.cs index 820eef14..491f8c8b 100644 --- a/src/CmsEngine.Ui/Controllers/BaseController.cs +++ b/src/CmsEngine.Ui/Controllers/BaseController.cs @@ -57,18 +57,18 @@ public async override Task OnActionExecutionAsync(ActionExecutingContext context Instance.Pages = await _pageService.GetAllPublished(); Instance.Categories = await _categoryService.GetCategoriesWithPostCount(); Instance.CategoriesWithPosts = await _categoryService.GetCategoriesWithPost(); - Instance.Tags = await _tagService.GetAllTags(); + Instance.Tags = _tagService.GetAllTags(); await base.OnActionExecutionAsync(context, next); } protected override void Dispose(bool disposing) { - Instance.Categories = Array.Empty(); - Instance.CategoriesWithPosts = Array.Empty(); - Instance.LatestPosts = Array.Empty(); - Instance.Pages = Array.Empty(); - Instance.Tags = Array.Empty(); + Instance.Categories = []; + Instance.CategoriesWithPosts = []; + Instance.LatestPosts = []; + Instance.Pages = []; + Instance.Tags = []; Instance.PagedPosts.Clear(); _categoryService.Dispose(); diff --git a/src/CmsEngine.Ui/Controllers/BlogController.cs b/src/CmsEngine.Ui/Controllers/BlogController.cs index 7db27297..6ba62c13 100644 --- a/src/CmsEngine.Ui/Controllers/BlogController.cs +++ b/src/CmsEngine.Ui/Controllers/BlogController.cs @@ -26,6 +26,8 @@ public IActionResult Index(int page = 1, string q = "") Instance.PageTitle = $"Results for '{q}' - {Instance.Name}"; } + Instance.CanonicalType = CanonicalType.Blog; + return View(Instance); } @@ -39,6 +41,8 @@ public async Task PostAsync(string slug) } Instance.PageTitle = $"{Instance.SelectedDocument.Title} - {Instance.Name}"; + Instance.CanonicalType = CanonicalType.Post; + return View(Instance); } @@ -53,6 +57,9 @@ public async Task CategoryAsync(string slug, int page = 1) } Instance.PageTitle = $"{selectedCategory} - {Instance.Name}"; + Instance.CanonicalType = CanonicalType.Category; + Instance.Slug = slug; + return View("Index", Instance); } @@ -67,6 +74,9 @@ public async Task TagAsync(string slug, int page = 1) } Instance.PageTitle = $"#{selectedTag} - {Instance.Name}"; + Instance.CanonicalType = CanonicalType.Tag; + Instance.Slug = slug; + return View("Index", Instance); } diff --git a/src/CmsEngine.Ui/Controllers/HomeController.cs b/src/CmsEngine.Ui/Controllers/HomeController.cs index cefa04cb..ebf74cee 100644 --- a/src/CmsEngine.Ui/Controllers/HomeController.cs +++ b/src/CmsEngine.Ui/Controllers/HomeController.cs @@ -16,6 +16,8 @@ public HomeController(ILoggerFactory loggerFactory, IPageService pageService, IX public IActionResult Index() { Instance.PageTitle = $"{Instance.Name}"; + Instance.CanonicalType = CanonicalType.Index; + return View(Instance); } @@ -29,12 +31,16 @@ public async Task PageAsync(string slug) } Instance.PageTitle = $"{Instance.SelectedDocument.Title} - {Instance.Name}"; + Instance.CanonicalType = CanonicalType.Page; + return View(Instance); } public IActionResult Archive() { Instance.PageTitle = $"Archive - {Instance.Name}"; + Instance.CanonicalType = CanonicalType.Archive; + return View(Instance); } diff --git a/src/CmsEngine.Ui/GlobalUsings.cs b/src/CmsEngine.Ui/GlobalUsings.cs index 427e5fcf..416d1f15 100644 --- a/src/CmsEngine.Ui/GlobalUsings.cs +++ b/src/CmsEngine.Ui/GlobalUsings.cs @@ -20,7 +20,6 @@ global using CmsEngine.Data.Entities; global using CmsEngine.Data.Repositories; global using CmsEngine.Data.Repositories.Interfaces; -global using CmsEngine.Ui.Areas.Cms.Controllers; global using CmsEngine.Ui.Extensions; global using CmsEngine.Ui.Middleware; global using CmsEngine.Ui.Middleware.SecurityHeaders; @@ -35,11 +34,12 @@ global using Microsoft.AspNetCore.Mvc.RazorPages; global using Microsoft.AspNetCore.Mvc.Rendering; global using Microsoft.AspNetCore.Razor.TagHelpers; +global using Microsoft.AspNetCore.ResponseCompression; global using Microsoft.AspNetCore.Rewrite; global using Microsoft.EntityFrameworkCore; global using Microsoft.Extensions.DependencyInjection.Extensions; global using Microsoft.Extensions.FileProviders; global using Microsoft.Net.Http.Headers; global using Serilog; -global using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode; global using ILogger = Microsoft.Extensions.Logging.ILogger; +global using SameSiteMode = Microsoft.AspNetCore.Http.SameSiteMode; diff --git a/src/CmsEngine.Ui/Program.cs b/src/CmsEngine.Ui/Program.cs index 0879e3c9..d84d6ff9 100644 --- a/src/CmsEngine.Ui/Program.cs +++ b/src/CmsEngine.Ui/Program.cs @@ -1,14 +1,17 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; + var builder = WebApplication.CreateBuilder(args); +var isDevelopment = builder.Environment.IsDevelopment(); // Load json files var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; builder.Configuration.SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json") - .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: true) - .AddJsonFile("emailsettings.json", optional: false, reloadOnChange: true) - .AddJsonFile($"emailsettings.{environment}.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables() - .Build(); + .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: isDevelopment) + .AddJsonFile("emailsettings.json", optional: false, reloadOnChange: isDevelopment) + .AddJsonFile($"emailsettings.{environment}.json", optional: true, reloadOnChange: isDevelopment) + .AddEnvironmentVariables(); // Initializing Logger Log.Logger = new LoggerConfiguration().ReadFrom.Configuration(builder.Configuration) @@ -16,24 +19,32 @@ // Add certificate for development // https://dotnetplaybook.com/custom-local-domain-using-https-kestrel-asp-net-core/ -if (builder.Environment.IsDevelopment()) +if (isDevelopment) { var certificatePassword = builder.Configuration["CertPassword"]; Log.Debug("Dev environment: Using Kestrel with port 5001"); + builder.WebHost.ConfigureKestrel(options => { options.AddServerHeader = false; options.Listen(IPAddress.Loopback, 5001, listenOptions => { - listenOptions.UseHttps(new X509Certificate2("cmsengine.test.pfx", certificatePassword)); + listenOptions.UseHttps(X509CertificateLoader.LoadPkcs12FromFile("cmsengine.test.pfx", certificatePassword)); + //listenOptions.UseHttps(); + }); + }) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.AddDebug(); }); - }); } // Add services to the container. var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); -builder.Services.AddDbContext(options => options.EnableSensitiveDataLogging(builder.Environment.IsDevelopment()) +builder.Services.AddDbContext(options => options.EnableSensitiveDataLogging(isDevelopment) .UseSqlServer(connectionString, o => o.MigrationsAssembly("CmsEngine.Data") .UseQuerySplittingBehavior(QuerySplittingBehavior.SingleQuery))); @@ -43,7 +54,7 @@ { // This lambda determines whether user consent for non-essential cookies is needed for a given request. options.CheckConsentNeeded = context => true; - options.MinimumSameSitePolicy = SameSiteMode.None; + options.MinimumSameSitePolicy = SameSiteMode.Lax; }); builder.Services.Configure(builder.Configuration.GetSection("EmailSettings")); @@ -52,35 +63,50 @@ .AddEntityFrameworkStores() .AddDefaultTokenProviders(); -// Add HttpContextAccessor as .NET Core doesn't have HttpContext.Current anymore -builder.Services.AddHttpContextAccessor(); +builder.Services.AddMemoryCache(options => +{ + options.SizeLimit = 1024; + options.CompactionPercentage = 0.2; +}); +builder.Services.Configure(builder.Configuration.GetSection("MemoryCache")); +builder.Services.TryAddSingleton(resolver => resolver.GetRequiredService>().Value); + +builder.Services.TryAddSingleton(); // Add Repositories -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); // Add services -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); +builder.Services.TryAddScoped(); // Add Unit of Work -builder.Services.AddScoped(); +builder.Services.TryAddScoped(); builder.Services.AddTransient(); builder.Services.AddControllersWithViews(); builder.Services.AddRazorPages(); +builder.Services.AddResponseCompression(options => +{ + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); +}); + builder.Services.ConfigureApplicationCookie(options => { options.LoginPath = $"/Identity/Account/Login"; @@ -88,7 +114,7 @@ options.AccessDeniedPath = $"/Identity/Account/AccessDenied"; }); -if (!builder.Environment.IsDevelopment()) +if (!isDevelopment) { builder.Services.AddHttpsRedirection(options => { @@ -149,6 +175,8 @@ app.UseRouting(); +app.UseResponseCompression(); + app.UseAuthentication(); app.UseAuthorization(); @@ -199,3 +227,5 @@ app.MapRazorPages(); app.Run(); + +Log.CloseAndFlush(); \ No newline at end of file diff --git a/src/CmsEngine.Ui/RewriteRules/RedirectToNonWwwRule.cs b/src/CmsEngine.Ui/RewriteRules/RedirectToNonWwwRule.cs index 1e7b6df7..073ef134 100644 --- a/src/CmsEngine.Ui/RewriteRules/RedirectToNonWwwRule.cs +++ b/src/CmsEngine.Ui/RewriteRules/RedirectToNonWwwRule.cs @@ -18,13 +18,13 @@ public virtual void ApplyRule(RewriteContext context) return; } - if (!httpRequest.Host.Value.StartsWith(Main.WwwDot, StringComparison.OrdinalIgnoreCase)) + if (httpRequest.Host.Value is not null && !httpRequest.Host.Value.StartsWith(Main.WwwDot, StringComparison.OrdinalIgnoreCase)) { context.Result = RuleResult.ContinueRules; return; } - var wwwHost = new HostString(httpRequest.Host.Value.Replace(Main.WwwDot, string.Empty)); + var wwwHost = new HostString(httpRequest.Host.Value?.Replace(Main.WwwDot, string.Empty)); var newUrl = UriHelper.BuildAbsolute(httpRequest.Scheme, wwwHost, httpRequest.PathBase, httpRequest.Path, httpRequest.QueryString); var httpResponse = context.HttpContext.Response; httpResponse.StatusCode = _statusCode; diff --git a/src/CmsEngine.Ui/Views/Shared/_Layout.cshtml b/src/CmsEngine.Ui/Views/Shared/_Layout.cshtml index 84655c0c..e158ff89 100644 --- a/src/CmsEngine.Ui/Views/Shared/_Layout.cshtml +++ b/src/CmsEngine.Ui/Views/Shared/_Layout.cshtml @@ -9,6 +9,7 @@ @Model.PageTitle + diff --git a/tests/CmsEngine.Tests/Application/Extensions/EnumerableExtensionsTests.cs b/tests/CmsEngine.Tests/Application/Extensions/EnumerableExtensionsTests.cs new file mode 100644 index 00000000..0fa43256 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Extensions/EnumerableExtensionsTests.cs @@ -0,0 +1,68 @@ +namespace CmsEngine.Tests.Application.Extensions; + +public class EnumerableExtensionsTests +{ + [Fact] + public void PopulateSelectList_ReturnsCorrectSelectListItems() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var items = new List + { + new TestViewModel { Name = "Alpha", VanityId = id1 }, + new TestViewModel { Name = "Beta", VanityId = id2 } + }; + + var selected = new List { id2.ToString() }; + var result = items.PopulateSelectList(selected).ToList(); + + Assert.Equal(2, result.Count); + Assert.Equal("Alpha", result[0].Text); + Assert.Equal(id1.ToString(), result[0].Value); + Assert.False(result[0].Selected); + Assert.Equal("Beta", result[1].Text); + Assert.True(result[1].Selected); + } + + [Fact] + public void PopulateCheckboxList_ReturnsCorrectCheckboxEditModels() + { + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var items = new List + { + new TestViewModel { Name = "Alpha", VanityId = id1 }, + new TestViewModel { Name = "Beta", VanityId = id2 } + }; + + var selected = new List { id1.ToString() }; + var result = items.PopulateCheckboxList(selected).ToList(); + + Assert.Equal(2, result.Count); + Assert.Equal("Alpha", result[0].Label); + Assert.Equal(id1.ToString(), result[0].Value); + Assert.True(result[0].Selected); + Assert.True(result[0].Enabled); + Assert.Equal("Beta", result[1].Label); + Assert.False(result[1].Selected); + } + + [Fact] + public void GetSearchExpression_ReturnsExpression() + { + var items = new List + { + new TestViewModel { Name = "Alpha", VanityId = Guid.NewGuid() }, + new TestViewModel { Name = "Beta", VanityId = Guid.NewGuid() } + }; + + var properties = typeof(TestViewModel).GetProperties().Where(p => p.Name == "Name"); + var expr = items.GetSearchExpression("Alpha", properties); + + Assert.NotNull(expr); + // Compile and test the expression + var func = expr.Compile(); + Assert.True(func(items[0])); + Assert.False(func(items[1])); + } +} diff --git a/tests/CmsEngine.Tests/Application/Extensions/Mapper/CategoryExtensionsTests.cs b/tests/CmsEngine.Tests/Application/Extensions/Mapper/CategoryExtensionsTests.cs new file mode 100644 index 00000000..2a1bfb30 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Extensions/Mapper/CategoryExtensionsTests.cs @@ -0,0 +1,159 @@ +namespace CmsEngine.Tests.Application.Extensions.Mapper; + +public class CategoryExtensionsTests +{ + [Fact] + public void MapToEditModel_MapsCategoryToEditModel() + { + var category = new Category + { + Id = 1, + VanityId = Guid.NewGuid(), + Name = "Test", + Description = "Desc", + Slug = "test-slug" + }; + + var result = category.MapToEditModel(); + + Assert.Equal(category.Id, result.Id); + Assert.Equal(category.VanityId, result.VanityId); + Assert.Equal(category.Name, result.Name); + Assert.Equal(category.Description, result.Description); + Assert.Equal(category.Slug, result.Slug); + } + + [Fact] + public void MapToModel_MapsEditModelToCategory() + { + var editModel = new CategoryEditModel + { + Id = 2, + VanityId = Guid.NewGuid(), + Name = "Edit", + Description = "EditDesc", + Slug = "edit-slug" + }; + + var result = editModel.MapToModel(); + + Assert.Equal(editModel.Id, result.Id); + Assert.Equal(editModel.VanityId, result.VanityId); + Assert.Equal(editModel.Name, result.Name); + Assert.Equal(editModel.Description, result.Description); + Assert.Equal(editModel.Slug, result.Slug); + } + + [Fact] + public void MapToModel_UpdatesExistingCategory() + { + var editModel = new CategoryEditModel + { + Id = 3, + VanityId = Guid.NewGuid(), + Name = "Update", + Description = "UpdateDesc", + Slug = "update-slug" + }; + var category = new Category(); + + var result = editModel.MapToModel(category); + + Assert.Equal(editModel.Id, result.Id); + Assert.Equal(editModel.VanityId, result.VanityId); + Assert.Equal(editModel.Name, result.Name); + Assert.Equal(editModel.Description, result.Description); + Assert.Equal(editModel.Slug, result.Slug); + } + + [Fact] + public void MapToTableViewModel_MapsCategoriesToTableViewModels() + { + var categories = new List + { + new Category { Id = 1, VanityId = Guid.NewGuid(), Name = "A", Description = "D1", Slug = "a" }, + new Category { Id = 2, VanityId = Guid.NewGuid(), Name = "B", Description = "D2", Slug = "b" } + }; + + var result = categories.MapToTableViewModel().ToList(); + + Assert.Equal(2, result.Count); + Assert.Equal("A", result[0].Name); + Assert.Equal("b", result[1].Slug); + } + + [Fact] + public void MapToViewModel_MapsCategoriesToViewModels() + { + var post = new Post { VanityId = Guid.NewGuid(), Title = "Post1", Description = "Desc", Slug = "post1", PublishedOn = DateTime.Now }; + var category = new Category + { + Id = 1, + VanityId = Guid.NewGuid(), + Name = "Cat", + Description = "Desc", + Slug = "cat", + PostCategories = new List { new PostCategory { Post = post } } + }; + var categories = new List { category }; + + var result = categories.MapToViewModel("yyyy-MM-dd").ToList(); + + Assert.Single(result); + Assert.Equal("Cat", result[0].Name); + Assert.NotNull(result[0].Posts); + Assert.Single(result[0].Posts); + Assert.Equal("Post1", result[0].Posts.First().Title); + } + + [Fact] + public void MapToViewModelSimple_MapsCategoriesToSimpleViewModels() + { + var categories = new List + { + new Category { VanityId = Guid.NewGuid(), Name = "Simple", Slug = "simple" } + }; + + var result = categories.MapToViewModelSimple().ToList(); + + Assert.Single(result); + Assert.Equal("Simple", result[0].Name); + Assert.Equal("simple", result[0].Slug); + } + + [Fact] + public void MapToViewModelWithPostCount_MapsCategoriesToViewModelsWithPostCount() + { + var categories = new List + { + new Category { VanityId = Guid.NewGuid(), Name = "Count", Slug = "count", PostCount = 5 } + }; + + var result = categories.MapToViewModelWithPostCount().ToList(); + + Assert.Single(result); + Assert.Equal(5, result[0].PostCount); + } + + [Fact] + public void MapToViewModelWithPost_MapsCategoriesToViewModelsWithPosts() + { + var post = new Post { VanityId = Guid.NewGuid(), Title = "Post2", Description = "Desc2", Slug = "post2", PublishedOn = DateTime.Now }; + var category = new Category + { + VanityId = Guid.NewGuid(), + Name = "WithPost", + Slug = "withpost", + Posts = new List { post } + }; + var categories = new List { category }; + + var result = categories.MapToViewModelWithPost("yyyy-MM-dd").ToList(); + + Assert.Single(result); + Assert.Equal("WithPost", result[0].Name); + Assert.NotNull(result[0].Posts); + Assert.Single(result[0].Posts); + Assert.Equal("Post2", result[0].Posts.First().Title); + } +} diff --git a/tests/CmsEngine.Tests/Application/Services/BaseServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/BaseServiceTests.cs new file mode 100644 index 00000000..9e4ee7c6 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/BaseServiceTests.cs @@ -0,0 +1,96 @@ +namespace CmsEngine.Tests.Application.Services; + +public class BaseServiceTests +{ + protected readonly Mock _uowMock; + protected readonly Mock _httpContextAccessorMock; + protected readonly Mock _loggerFactoryMock; + protected readonly Mock _loggerMock; + + protected readonly Mock> _userManagerMock; + protected readonly Mock _emailRepoMock; + protected readonly Mock _websiteRepoMock; + protected readonly Mock _categoryRepoMock; + protected readonly Mock _tagRepoMock; + protected readonly Mock _pageRepoMock; + protected readonly Mock _postRepoMock; + + protected readonly Mock _cacheServiceMock; + protected readonly IEmailService _emailService; + protected readonly IWebsiteService _websiteService; + protected readonly ICategoryService _categoryService; + protected readonly ITagService _tagService; + protected readonly IPageService _pageService; + protected readonly IPostService _postService; + protected readonly IXmlService _xmlService; + + public BaseServiceTests() + { + var services = new ServiceCollection(); + services.AddMemoryCache(); + var serviceProvider = services.BuildServiceProvider(); + var memoryCache = serviceProvider.GetService(); + + _uowMock = new Mock(); + + // Repositories + _userManagerMock = new Mock>(Mock.Of>(), null, null, null, null, null, null, null, null); + _emailRepoMock = new Mock(); + _websiteRepoMock = new Mock(); + _categoryRepoMock = new Mock(); + _tagRepoMock = new Mock(); + _pageRepoMock = new Mock(); + _postRepoMock = new Mock(); + + var user = new ApplicationUser + { + Id = Guid.NewGuid().ToString(), + UserName = "testuser" + }; + _userManagerMock.Setup(u => u.FindByNameAsync(It.IsAny())).ReturnsAsync(user); + + var website = new Website + { + Id = 1, + Name = "TestSite", + Tagline = "Test Tagline", + Culture = "en-US", + UrlFormat = "[site_url]/[type]/[slug]", + DateFormat = "yyyy-MM-dd", + SiteUrl = "https://test.com" + }; + _websiteRepoMock.Setup(r => r.GetWebsiteInstanceByHost(It.IsAny())).Returns(website); + + _uowMock.Setup(u => u.Users).Returns(_userManagerMock.Object); + _uowMock.Setup(u => u.Emails).Returns(_emailRepoMock.Object); + _uowMock.Setup(u => u.Websites).Returns(_websiteRepoMock.Object); + _uowMock.Setup(u => u.Categories).Returns(_categoryRepoMock.Object); + _uowMock.Setup(u => u.Tags).Returns(_tagRepoMock.Object); + _uowMock.Setup(u => u.Pages).Returns(_pageRepoMock.Object); + _uowMock.Setup(u => u.Posts).Returns(_postRepoMock.Object); + + _httpContextAccessorMock = new Mock(); + var context = new DefaultHttpContext(); + context.Request.Host = new HostString("localhost"); + context.User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "testuser") })); + _httpContextAccessorMock.Setup(h => h.HttpContext).Returns(context); + + _loggerFactoryMock = new Mock(); + _loggerMock = new Mock(); + _loggerFactoryMock.Setup(l => l.CreateLogger(It.IsAny())).Returns(_loggerMock.Object); + + // Cache Service + _cacheServiceMock = new Mock(); + _cacheServiceMock.Setup(c => c.Set(It.IsAny(), It.IsAny(), It.IsAny())); + _cacheServiceMock.Setup(c => c.TryGet(It.IsAny(), out It.Ref.IsAny)).Returns(false); + + // Services + _emailService = new Mock(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object).Object; + _websiteService = new Mock(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object).Object; + _categoryService = new Mock(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object).Object; + _tagService = new Mock(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object).Object; + _pageService = new Mock(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object).Object; + _postService = new Mock(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object).Object; + _xmlService = new Mock(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object).Object; + } +} diff --git a/tests/CmsEngine.Tests/Application/Services/CategoryServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/CategoryServiceTests.cs new file mode 100644 index 00000000..c977ca49 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/CategoryServiceTests.cs @@ -0,0 +1,102 @@ +namespace CmsEngine.Tests.Application.Services; + +public class CategoryServiceTests : BaseServiceTests +{ + + public CategoryServiceTests() : base() + { + } + + [Fact] + public async Task Delete_ShouldDeleteCategory_WhenCategoryExists() + { + var id = Guid.NewGuid(); + var category = new Category { Name = "Test", VanityId = id }; + _categoryRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(category); + + var result = await _categoryService.Delete(id); + + _categoryRepoMock.Verify(r => r.Delete(category), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Delete_ShouldSetError_WhenExceptionThrown() + { + var id = Guid.NewGuid(); + var category = new Category { Name = "Test", VanityId = id }; + _categoryRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(category); + _categoryRepoMock.Setup(r => r.Delete(category)).Throws(new Exception("fail")); + + var result = await _categoryService.Delete(id); + + Assert.True(result.IsError); + Assert.Contains("error", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DeleteRange_ShouldDeleteCategories() + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var categories = ids.Select(id => new Category { Name = "Test", VanityId = id }).ToList(); + _categoryRepoMock.Setup(r => r.GetByMultipleIdsAsync(ids)).ReturnsAsync(categories); + + var result = await _categoryService.DeleteRange(ids); + + _categoryRepoMock.Verify(r => r.DeleteRange(categories), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldInsertNewCategory() + { + var editModel = new CategoryEditModel { VanityId = Guid.Empty, Name = "NewCat" }; + _categoryRepoMock.Setup(r => r.Insert(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _categoryService.Save(editModel); + + _categoryRepoMock.Verify(r => r.Insert(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldUpdateExistingCategory() + { + var id = Guid.NewGuid(); + var editModel = new CategoryEditModel { VanityId = id, Name = "ExistingCat" }; + var category = new Category { Name = "ExistingCat", VanityId = id }; + _categoryRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(category); + + var result = await _categoryService.Save(editModel); + + _categoryRepoMock.Verify(r => r.Update(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task GetCategoryCount_ShouldReturnCount() + { + _categoryRepoMock.Setup(r => r.CountAsync()).ReturnsAsync(5); + + var count = await _categoryService.GetCategoryCount(); + + Assert.Equal(5, count); + } + + [Fact] + public void SetupEditModel_ShouldReturnNewEditModel() + { + var result = _categoryService.SetupEditModel(); + + Assert.NotNull(result); + Assert.IsType(result); + } +} \ No newline at end of file diff --git a/tests/CmsEngine.Tests/Application/Services/EmailServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/EmailServiceTests.cs new file mode 100644 index 00000000..3afdfda5 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/EmailServiceTests.cs @@ -0,0 +1,64 @@ +namespace CmsEngine.Tests.Application.Services; + +public class EmailServiceTests : BaseServiceTests +{ + + public EmailServiceTests() : base() + { + } + + [Fact] + public async Task Save_ShouldInsertAndSaveContactForm() + { + // Arrange + var contactForm = new ContactForm("to@example.com", "Subject", "Message"); + var emailModel = new Email(); // Assuming Email is the mapped model + // Simulate MapToModel + var mapToModelCalled = false; + contactForm.GetType().GetMethod("MapToModel")?.Invoke(contactForm, null); + _emailRepoMock.Setup(r => r.Insert(It.IsAny())).Returns(Task.CompletedTask); + _uowMock.Setup(u => u.Save(default)).ReturnsAsync(1); + + // Act + var result = await _emailService.Save(contactForm); + + // Assert + _emailRepoMock.Verify(r => r.Insert(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message, StringComparison.OrdinalIgnoreCase); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldSetErrorMessage_OnException() + { + // Arrange + var contactForm = new ContactForm("to@example.com", "Subject", "Message"); + _emailRepoMock.Setup(r => r.Insert(It.IsAny())).Throws(new Exception("fail")); + + // Act + var result = await _emailService.Save(contactForm); + + // Assert + Assert.True(result.IsError); + Assert.Contains("error", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task GetOrderedByDate_ShouldReturnMappedContactForms() + { + // Arrange + var emails = new List { new Email(), new Email() }; + _emailRepoMock.Setup(r => r.GetOrderedByDate()).ReturnsAsync(emails); + + // Simulate MapToViewModel extension + IEnumerable mapped = new List { new ContactForm("to", "subj", "msg") }; + + // Act + var result = await _emailService.GetOrderedByDate(); + + // Assert + _emailRepoMock.Verify(r => r.GetOrderedByDate(), Times.Once); + Assert.NotNull(result); + } +} diff --git a/tests/CmsEngine.Tests/Application/Services/PageServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/PageServiceTests.cs new file mode 100644 index 00000000..739a2df5 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/PageServiceTests.cs @@ -0,0 +1,102 @@ +namespace CmsEngine.Tests.Application.Services; + +public class PageServiceTests : BaseServiceTests +{ + + public PageServiceTests() : base() + { + } + + [Fact] + public async Task Delete_ShouldDeletePage_WhenPageExists() + { + var id = Guid.NewGuid(); + var page = new Page { Title = "Test", VanityId = id }; + _pageRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(page); + + var result = await _pageService.Delete(id); + + _pageRepoMock.Verify(r => r.Delete(page), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Delete_ShouldSetError_WhenExceptionThrown() + { + var id = Guid.NewGuid(); + var page = new Page { Title = "Test", VanityId = id }; + _pageRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(page); + _pageRepoMock.Setup(r => r.Delete(page)).Throws(new Exception("fail")); + + var result = await _pageService.Delete(id); + + Assert.True(result.IsError); + Assert.Contains("error", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DeleteRange_ShouldDeletePages() + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var pages = ids.Select(id => new Page { Title = "Test", VanityId = id }).ToList(); + _pageRepoMock.Setup(r => r.GetByMultipleIdsAsync(ids)).ReturnsAsync(pages); + + var result = await _pageService.DeleteRange(ids); + + _pageRepoMock.Verify(r => r.DeleteRange(pages), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldInsertNewPage() + { + var editModel = new PageEditModel { VanityId = Guid.Empty, Title = "NewPage" }; + _pageRepoMock.Setup(r => r.Insert(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _pageService.Save(editModel); + + _pageRepoMock.Verify(r => r.Insert(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldUpdateExistingPage() + { + var id = Guid.NewGuid(); + var editModel = new PageEditModel { VanityId = id, Title = "ExistingPage" }; + var page = new Page { Title = "ExistingPage", VanityId = id }; + _pageRepoMock.Setup(r => r.GetForSavingById(id)).ReturnsAsync(page); + + var result = await _pageService.Save(editModel); + + _pageRepoMock.Verify(r => r.Update(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task GetPageCount_ShouldReturnCount() + { + _pageRepoMock.Setup(r => r.CountAsync()).ReturnsAsync(3); + + var count = await _pageService.GetPageCount(); + + Assert.Equal(3, count); + } + + [Fact] + public void SetupEditModel_ShouldReturnNewEditModel() + { + var result = _pageService.SetupEditModel(); + + Assert.NotNull(result); + Assert.IsType(result); + } +} \ No newline at end of file diff --git a/tests/CmsEngine.Tests/Application/Services/PostServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/PostServiceTests.cs new file mode 100644 index 00000000..f37b4478 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/PostServiceTests.cs @@ -0,0 +1,103 @@ +namespace CmsEngine.Tests.Application.Services; + +public class PostServiceTests : BaseServiceTests +{ + + public PostServiceTests() + { + + } + + [Fact] + public async Task Delete_ShouldDeletePost_WhenPostExists() + { + var id = Guid.NewGuid(); + var post = new Post { Title = "Test", VanityId = id }; + _postRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(post); + + var result = await _postService.Delete(id); + + _postRepoMock.Verify(r => r.Delete(post), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Delete_ShouldSetError_WhenExceptionThrown() + { + var id = Guid.NewGuid(); + var post = new Post { Title = "Test", VanityId = id }; + _postRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(post); + _postRepoMock.Setup(r => r.Delete(post)).Throws(new Exception("fail")); + + var result = await _postService.Delete(id); + + Assert.True(result.IsError); + Assert.Contains("error", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DeleteRange_ShouldDeletePosts() + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var posts = ids.Select(id => new Post { Title = "Test", VanityId = id }).ToList(); + _postRepoMock.Setup(r => r.GetByMultipleIdsAsync(ids)).ReturnsAsync(posts); + + var result = await _postService.DeleteRange(ids); + + _postRepoMock.Verify(r => r.DeleteRange(posts), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldInsertNewPost() + { + var editModel = new PostEditModel { VanityId = Guid.Empty, Title = "NewPost" }; + _postRepoMock.Setup(r => r.Insert(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _postService.Save(editModel); + + _postRepoMock.Verify(r => r.Insert(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldUpdateExistingPost() + { + var id = Guid.NewGuid(); + var editModel = new PostEditModel { VanityId = id, Title = "ExistingPost" }; + var post = new Post { Title = "ExistingPost", VanityId = id }; + _postRepoMock.Setup(r => r.GetForSavingById(id)).ReturnsAsync(post); + + var result = await _postService.Save(editModel); + + _postRepoMock.Verify(r => r.Update(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task GetPostCount_ShouldReturnCount() + { + _postRepoMock.Setup(r => r.CountAsync()).ReturnsAsync(4); + + var count = await _postService.GetPostCount(); + + Assert.Equal(4, count); + } + + [Fact] + public void SetupEditModel_ShouldReturnNewEditModel() + { + var result = _postService.SetupEditModel(); + + Assert.NotNull(result); + Assert.IsType(result); + } +} \ No newline at end of file diff --git a/tests/CmsEngine.Tests/Application/Services/ServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/ServiceTests.cs new file mode 100644 index 00000000..5ac1115f --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/ServiceTests.cs @@ -0,0 +1,66 @@ +namespace CmsEngine.Tests.Application.Services; + +public class ServiceTests : BaseServiceTests +{ + public ServiceTests() : base() + { + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenUnitOfWorkIsNull() + { + Assert.Throws(() => + new Service(null, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object)); + } + + [Fact] + public void ValidatePage_ShouldReturnAtLeastOne() + { + var service = CreateService(); + Assert.Equal(1, service.GetType().GetMethod("ValidatePage", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(service, new object[] { 0 })); + Assert.Equal(5, service.GetType().GetMethod("ValidatePage", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(service, new object[] { 5 })); + } + + [Fact] + public void SaveInstanceToCache_ShouldSetCache() + { + var service = CreateService(); + var instance = new object(); + _cacheServiceMock.Setup(m => m.Set(It.IsAny(), instance, It.IsAny())); + + service.GetType().GetMethod("SaveInstanceToCache", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(service, new object[] { instance }); + + _cacheServiceMock.Verify(m => m.Set(It.IsAny(), instance, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetCurrentUserAsync_ShouldReturnUser() + { + var service = CreateService(); + var appUser = new ApplicationUser { Id = Guid.NewGuid().ToString(), Name = "Test", Surname = "User", Email = "test@example.com", UserName = "testuser" }; + _userManagerMock.Setup(u => u.FindByNameAsync("testuser")).ReturnsAsync(appUser); + + var method = service.GetType().GetMethod("GetCurrentUserAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + var task = (Task)method.Invoke(service, null); + var result = await task; + + Assert.Equal("Test", result.Name); + Assert.Equal("testuser", result.UserName); + } + + [Fact] + public void Dispose_ShouldCallUnitOfWorkDispose() + { + var service = CreateService(); + service.Dispose(); + _uowMock.Verify(u => u.Dispose(), Times.Once); + } + + private Service CreateService() + { + return new Service(_uowMock.Object, _httpContextAccessorMock.Object, _loggerFactoryMock.Object, _cacheServiceMock.Object); + } +} \ No newline at end of file diff --git a/tests/CmsEngine.Tests/Application/Services/TagServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/TagServiceTests.cs new file mode 100644 index 00000000..cdc6beb0 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/TagServiceTests.cs @@ -0,0 +1,101 @@ +namespace CmsEngine.Tests.Application.Services; + +public class TagServiceTests : BaseServiceTests +{ + public TagServiceTests() : base() + { + } + + [Fact] + public async Task Delete_ShouldDeleteTag_WhenTagExists() + { + var id = Guid.NewGuid(); + var tag = new Tag { Name = "Test", VanityId = id }; + _tagRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(tag); + + var result = await _tagService.Delete(id); + + _tagRepoMock.Verify(r => r.Delete(tag), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Delete_ShouldSetError_WhenExceptionThrown() + { + var id = Guid.NewGuid(); + var tag = new Tag { Name = "Test", VanityId = id }; + _tagRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(tag); + _tagRepoMock.Setup(r => r.Delete(tag)).Throws(new Exception("fail")); + + var result = await _tagService.Delete(id); + + Assert.True(result.IsError); + Assert.Contains("error", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DeleteRange_ShouldDeleteTags() + { + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var tags = ids.Select(id => new Tag { Name = "Test", VanityId = id }).ToList(); + _tagRepoMock.Setup(r => r.GetByMultipleIdsAsync(ids)).ReturnsAsync(tags); + + var result = await _tagService.DeleteRange(ids); + + _tagRepoMock.Verify(r => r.DeleteRange(tags), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldInsertNewTag() + { + var editModel = new TagEditModel { VanityId = Guid.Empty, Name = "NewTag" }; + _tagRepoMock.Setup(r => r.Insert(It.IsAny())).Returns(Task.CompletedTask); + + var result = await _tagService.Save(editModel); + + _tagRepoMock.Verify(r => r.Insert(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldUpdateExistingTag() + { + var id = Guid.NewGuid(); + var editModel = new TagEditModel { VanityId = id, Name = "ExistingTag" }; + var tag = new Tag { Name = "ExistingTag", VanityId = id }; + _tagRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(tag); + + var result = await _tagService.Save(editModel); + + _tagRepoMock.Verify(r => r.Update(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task GetTagCount_ShouldReturnCount() + { + _tagRepoMock.Setup(r => r.CountAsync()).ReturnsAsync(7); + + var count = await _tagService.GetTagCount(); + + Assert.Equal(7, count); + } + + [Fact] + public void SetupEditModel_ShouldReturnNewEditModel() + { + var result = _tagService.SetupEditModel(); + + Assert.NotNull(result); + Assert.IsType(result); + } +} \ No newline at end of file diff --git a/tests/CmsEngine.Tests/Application/Services/WebsiteServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/WebsiteServiceTests.cs new file mode 100644 index 00000000..494ff2c7 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/WebsiteServiceTests.cs @@ -0,0 +1,109 @@ +namespace CmsEngine.Tests.Application.Services; + +public class WebsiteServiceTests : BaseServiceTests +{ + + public WebsiteServiceTests() : base() + { + } + + [Fact] + public async Task Delete_ShouldDeleteWebsite_WhenWebsiteExists() + { + // Arrange + var id = Guid.NewGuid(); + var website = new Website { Name = "Test", Id = 1, VanityId = id }; + _websiteRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(website); + + // Act + var result = await _websiteService.Delete(id); + + // Assert + _websiteRepoMock.Verify(r => r.Delete(website), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Delete_ShouldSetError_WhenExceptionThrown() + { + // Arrange + var id = Guid.NewGuid(); + var website = new Website { Name = "Test", Id = 1, VanityId = id }; + _websiteRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(website); + _websiteRepoMock.Setup(r => r.Delete(website)).Throws(new Exception("fail")); + + // Act + var result = await _websiteService.Delete(id); + + // Assert + Assert.True(result.IsError); + Assert.Contains("error", result.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task DeleteRange_ShouldDeleteWebsites() + { + // Arrange + var ids = new[] { Guid.NewGuid(), Guid.NewGuid() }; + var websites = ids.Select(id => new Website { Name = "Test", VanityId = id }).ToList(); + _websiteRepoMock.Setup(r => r.GetByMultipleIdsAsync(ids)).ReturnsAsync(websites); + + // Act + var result = await _websiteService.DeleteRange(ids); + + // Assert + _websiteRepoMock.Verify(r => r.DeleteRange(websites), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("deleted", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldInsertNewWebsite() + { + // Arrange + var editModel = new WebsiteEditModel {VanityId = Guid.Empty, Name = "NewSite" }; + _websiteRepoMock.Setup(r => r.Insert(It.IsAny())).Returns(Task.CompletedTask); + + // Act + var result = await _websiteService.Save(editModel); + + // Assert + _websiteRepoMock.Verify(r => r.Insert(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public async Task Save_ShouldUpdateExistingWebsite() + { + // Arrange + var id = Guid.NewGuid(); + var editModel = new WebsiteEditModel { VanityId = id, Name = "ExistingSite" }; + var website = new Website { Name = "ExistingSite", VanityId = id }; + _websiteRepoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync(website); + + // Act + var result = await _websiteService.Save(editModel); + + // Assert + _websiteRepoMock.Verify(r => r.Update(It.IsAny()), Times.Once); + _uowMock.Verify(u => u.Save(default), Times.Once); + Assert.Contains("saved", result.Message); + Assert.False(result.IsError); + } + + [Fact] + public void SetupEditModel_ShouldReturnNewEditModel() + { + // Act + var result = _websiteService.SetupEditModel(); + + // Assert + Assert.NotNull(result); + Assert.IsType(result); + } +} \ No newline at end of file diff --git a/tests/CmsEngine.Tests/Application/Services/XmlServiceTests.cs b/tests/CmsEngine.Tests/Application/Services/XmlServiceTests.cs new file mode 100644 index 00000000..f3a16197 --- /dev/null +++ b/tests/CmsEngine.Tests/Application/Services/XmlServiceTests.cs @@ -0,0 +1,74 @@ +namespace CmsEngine.Tests.Application.Services; + +public class XmlServiceTests : BaseServiceTests +{ + public XmlServiceTests() : base() + { + } + + [Fact] + public async Task GenerateFeed_ShouldReturnRssDocument_WithPublishedPosts() + { + // Arrange + var posts = new List + { + new() { Title = "Post1", Slug = "post-1", DocumentContent = "Content1", PublishedOn = DateTime.UtcNow }, + new() { Title = "Post2", Slug = "post-2", DocumentContent = "Content2", PublishedOn = DateTime.UtcNow } + }; + + _postRepoMock.Setup(r => r.GetPublishedPostsOrderByDescending(It.IsAny>>())).ReturnsAsync(posts); + + // Act + var doc = await _xmlService.GenerateFeed(); + + // Assert + Assert.NotNull(doc); + var channel = doc.Root?.Element("channel"); + Assert.NotNull(channel); + Assert.Equal("TestSite", channel.Element("title")?.Value); + Assert.Equal("Test Tagline", channel.Element("description")?.Value); + Assert.Equal("en-us", channel.Element("language")?.Value); + Assert.Equal(2, channel.Elements("item").Count()); + } + + [Fact] + public async Task GenerateSitemap_ShouldReturnSitemapDocument_WithPostsAndPages() + { + // Arrange + var posts = new List + { + new() { Slug = "post-1", PublishedOn = new DateTime(2023, 1, 1) } + }; + var pages = new List + { + new() { Slug = "page-1", PublishedOn = new DateTime(2023, 2, 2) } + }; + + _postRepoMock.Setup(r => r.GetPublishedPostsOrderByDescending(It.IsAny>>())).ReturnsAsync(posts); + _pageRepoMock.Setup(r => r.GetOrderByDescending(It.IsAny>>())).ReturnsAsync(pages); + + // Act + var doc = await _xmlService.GenerateSitemap(); + + // Assert + Assert.NotNull(doc); + var urlset = doc.Root; + Assert.NotNull(urlset); + Assert.Equal("urlset", urlset.Name.LocalName); + Assert.Equal(2, urlset.Elements().Count()); + } + + [Theory] + [InlineData("", "", "https://test.com")] + [InlineData("", "about-me", "https://test.com/about-me")] + [InlineData("blog/post/", "random-article", "https://test.com/blog/post/random-article")] + public void FormatUrl_ShouldFormatCorrectly(string type, string slug, string expected) + { + // Act + var url = typeof(XmlService).GetMethod("FormatUrl", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .Invoke(_xmlService, new object[] { type, slug }) as string; + + // Assert + Assert.Equal(expected, url); + } +} \ No newline at end of file diff --git a/tests/CmsEngine.Tests/CmsEngine.Tests.csproj b/tests/CmsEngine.Tests/CmsEngine.Tests.csproj new file mode 100644 index 00000000..018f2bea --- /dev/null +++ b/tests/CmsEngine.Tests/CmsEngine.Tests.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/tests/CmsEngine.Tests/GlobalUsings.cs b/tests/CmsEngine.Tests/GlobalUsings.cs new file mode 100644 index 00000000..eed21546 --- /dev/null +++ b/tests/CmsEngine.Tests/GlobalUsings.cs @@ -0,0 +1,19 @@ +global using System.Linq.Expressions; +global using System.Security.Claims; +global using CmsEngine.Application.Extensions; +global using CmsEngine.Application.Extensions.Mapper; +global using CmsEngine.Application.Helpers.Email; +global using CmsEngine.Application.Models.EditModels; +global using CmsEngine.Application.Models.ViewModels; +global using CmsEngine.Application.Services; +global using CmsEngine.Application.Services.Interfaces; +global using CmsEngine.Data; +global using CmsEngine.Data.Entities; +global using CmsEngine.Data.Repositories.Interfaces; +global using CmsEngine.Tests.Models.ViewModels; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Identity; +global using Microsoft.Extensions.Caching.Memory; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Logging; +global using Moq; diff --git a/tests/CmsEngine.Tests/Models/ViewModels/TestViewModel.cs b/tests/CmsEngine.Tests/Models/ViewModels/TestViewModel.cs new file mode 100644 index 00000000..ee81c0f7 --- /dev/null +++ b/tests/CmsEngine.Tests/Models/ViewModels/TestViewModel.cs @@ -0,0 +1,6 @@ +namespace CmsEngine.Tests.Models.ViewModels; + +public class TestViewModel : BaseViewModel +{ + public string Name { get; set; } = string.Empty; +}