diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,63 @@ +############################################################################### +# Set default behavior to automatically normalize line endings. +############################################################################### +* text=auto + +############################################################################### +# Set default behavior for command prompt diff. +# +# This is need for earlier builds of msysgit that does not have it on by +# default for csharp files. +# Note: This is only used by command line +############################################################################### +#*.cs diff=csharp + +############################################################################### +# Set the merge driver for project and solution files +# +# Merging from the command prompt will add diff markers to the files if there +# are conflicts (Merging from VS is not affected by the settings below, in VS +# the diff markers are never inserted). Diff markers may cause the following +# file extensions to fail to load in VS. An alternative would be to treat +# these files as binary and thus will always conflict and require user +# intervention with every merge. To do so, just uncomment the entries below +############################################################################### +#*.sln merge=binary +#*.csproj merge=binary +#*.vbproj merge=binary +#*.vcxproj merge=binary +#*.vcproj merge=binary +#*.dbproj merge=binary +#*.fsproj merge=binary +#*.lsproj merge=binary +#*.wixproj merge=binary +#*.modelproj merge=binary +#*.sqlproj merge=binary +#*.wwaproj merge=binary + +############################################################################### +# behavior for image files +# +# image files are treated as binary by default. +############################################################################### +#*.jpg binary +#*.png binary +#*.gif binary + +############################################################################### +# diff behavior for common document formats +# +# Convert binary document formats to text before diffing them. This feature +# is only available from the command line. Turn it on by uncommenting the +# entries below. +############################################################################### +#*.doc diff=astextplain +#*.DOC diff=astextplain +#*.docx diff=astextplain +#*.DOCX diff=astextplain +#*.dot diff=astextplain +#*.DOT diff=astextplain +#*.pdf diff=astextplain +#*.PDF diff=astextplain +#*.rtf diff=astextplain +#*.RTF diff=astextplain diff --git a/.github/workflows/build-test-asyncprocessing.yml b/.github/workflows/build-test-asyncprocessing.yml new file mode 100644 index 0000000..ab3f941 --- /dev/null +++ b/.github/workflows/build-test-asyncprocessing.yml @@ -0,0 +1,22 @@ +name: Build DfE.CoreLibs.AsyncProcessing + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.AsyncProcessing/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.AsyncProcessing/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.AsyncProcessing + project_path: src/DfE.CoreLibs.AsyncProcessing + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/build-test-caching.yml b/.github/workflows/build-test-caching.yml new file mode 100644 index 0000000..b0c9c47 --- /dev/null +++ b/.github/workflows/build-test-caching.yml @@ -0,0 +1,22 @@ +name: Build DfE.CoreLibs.Caching + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Caching/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Caching/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.Caching + project_path: src/DfE.CoreLibs.Caching + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/build-test-contracts.yml b/.github/workflows/build-test-contracts.yml new file mode 100644 index 0000000..a216873 --- /dev/null +++ b/.github/workflows/build-test-contracts.yml @@ -0,0 +1,23 @@ +name: Build DfE.CoreLibs.Contracts + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Contracts/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Contracts/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.Contracts + project_path: src/DfE.CoreLibs.Contracts + run_tests: false + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/build-test-http.yml b/.github/workflows/build-test-http.yml new file mode 100644 index 0000000..a870ef9 --- /dev/null +++ b/.github/workflows/build-test-http.yml @@ -0,0 +1,22 @@ +name: Build DfE.CoreLibs.Http + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Http/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Http/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.Http + project_path: src/DfE.CoreLibs.Http + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/build-test-template.yml b/.github/workflows/build-test-template.yml new file mode 100644 index 0000000..cd9cf30 --- /dev/null +++ b/.github/workflows/build-test-template.yml @@ -0,0 +1,98 @@ +name: .NET Build and Test Template + +on: + workflow_call: + inputs: + project_name: + required: true + type: string + description: "The name of the project" + project_path: + required: true + type: string + description: "The relative path to the project directory" + run_tests: + required: false + type: boolean + default: true + description: "Flag to run or skip tests" + secrets: + SONAR_TOKEN: + required: true +env: + DOTNET_VERSION: '8.0.x' + EF_VERSION: '6.0.5' + JAVA_VERSION: '17' + +jobs: + build-and-test: + runs-on: ubuntu-latest + permissions: + packages: read + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'microsoft' + java-version: ${{ env.JAVA_VERSION }} + + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Install SonarCloud scanners + run: dotnet tool install --global dotnet-sonarscanner + + - name: Install EF for tests + run: dotnet tool install --global dotnet-ef --version ${{ env.EF_VERSION }} + + - name: Install dotnet reportgenerator + run: dotnet tool install --global dotnet-reportgenerator-globaltool + + - name: Add nuget package source + run: dotnet nuget add source --username USERNAME --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/DFE-Digital/index.json" + + - name: Restore dependencies + run: dotnet restore ${{ inputs.project_path }} + + - name: Set Test Project Path + run: | + test_project_path="${{ inputs.project_path }}" + test_project_path="${test_project_path/src\//src\/Tests\/}.Tests" + echo "test_project_path=$test_project_path" >> $GITHUB_ENV + + - name: Build, Test and Analyze + env: + CI: true + run: | + echo "run_tests is set to: ${{ inputs.run_tests }}" + if [ "${{ inputs.run_tests }}" == "true" ]; then + dotnet-sonarscanner begin /k:"DFE-Digital_rsd-core-libs" /o:"dfe-digital" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.coverageReportPaths=CoverageReport/SonarQube.xml; + else + dotnet-sonarscanner begin /k:"DFE-Digital_rsd-core-libs" /o:"dfe-digital" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io"; + fi + dotnet build --no-restore -p:CI=${CI} ${{ inputs.project_path }} + + - name: Run Tests + if: ${{ inputs.run_tests }} + run: dotnet test --verbosity normal --collect:"XPlat Code Coverage" ${{ env.test_project_path }} + + - name: Generate Code Coverage Report + if: ${{ inputs.run_tests }} + run: reportgenerator -reports:./**/coverage.cobertura.xml -targetdir:./CoverageReport -reporttypes:SonarQube + + - name: Complete Sonar Scan + run: dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \ No newline at end of file diff --git a/.github/workflows/build-test-testing.yml b/.github/workflows/build-test-testing.yml new file mode 100644 index 0000000..36c9143 --- /dev/null +++ b/.github/workflows/build-test-testing.yml @@ -0,0 +1,23 @@ +name: Build DfE.CoreLibs.Testing + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Testing/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Testing/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.Testing + project_path: src/DfE.CoreLibs.Testing + run_tests: false + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/build-test-utilities.yml b/.github/workflows/build-test-utilities.yml new file mode 100644 index 0000000..0dfeabc --- /dev/null +++ b/.github/workflows/build-test-utilities.yml @@ -0,0 +1,22 @@ +name: Build DfE.CoreLibs.Utilities + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Utilities/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Utilities/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.Utilities + project_path: src/DfE.CoreLibs.Utilities + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/nuget-package-template.yml b/.github/workflows/nuget-package-template.yml new file mode 100644 index 0000000..13d47d0 --- /dev/null +++ b/.github/workflows/nuget-package-template.yml @@ -0,0 +1,147 @@ +name: Build and Push NuGet Package Template + +on: + workflow_call: + inputs: + project_name: + required: true + type: string + description: "The name of the project" + project_path: + required: true + type: string + description: "The relative path to the project directory" + nuget_package_name: + required: true + type: string + description: "The name of the NuGet package" + +env: + DOTNET_VERSION: '8.0.x' + +jobs: + build-and-package: + runs-on: ubuntu-latest + if: ${{ github.event.workflow_run.conclusion == 'success' }} # Ensures it only runs on success + permissions: + packages: write + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Set up curl and jq + run: sudo apt-get install -y curl jq + + - name: Install GitHub CLI + run: sudo apt-get install -y gh + + - name: Check for custom version in commit message or check the feed for the latest version and increment it + id: check_custom_version + run: | + PROJECT_NAME=${{ inputs.project_name }} # Add the project name from inputs + + # Search the last 10 commits for the version update indicator + COMMIT_HASH=$(git log -n 10 --pretty=format:"%H %s" | grep -P '\(%update '"$PROJECT_NAME"' package version to \d+\.\d+\.\d+\)' | grep -oP '^\w+' | head -n 1) + + if [[ -n "$COMMIT_HASH" ]]; then + echo "Found commit with version update indicator: $COMMIT_HASH" + # Create a project-specific tag using project name and commit hash + TAG_NAME="${PROJECT_NAME}-processed-nuget-version-${COMMIT_HASH}" + + # Check if the commit is already tagged for this project + if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then + echo "This commit has already been processed for version update in $PROJECT_NAME. Skipping." + else + # Extract the version from the commit message + CUSTOM_VERSION=$(git show -s --format=%s $COMMIT_HASH | grep -oP '\(%update '"$PROJECT_NAME"' package version to \K([0-9]+\.[0-9]+\.[0-9]+)') + + if [[ -n "$CUSTOM_VERSION" ]]; then + echo "Using custom version: $CUSTOM_VERSION" + echo "NEW_VERSION=$CUSTOM_VERSION" >> $GITHUB_ENV + + # Tag the commit with the project-specific tag + git tag "$TAG_NAME" + git push origin "$TAG_NAME" + else + echo "Failed to extract version from commit message. Exiting." + exit 1 + fi + fi + fi + + if [[ -z "$CUSTOM_VERSION" ]]; then + echo "No unprocessed custom version found in the last 10 commits for $PROJECT_NAME. Proceeding to fetch and increment the latest version from the feed." + + # Fetch the latest version and increment it for the specific package + PACKAGE_ID="${{ inputs.nuget_package_name }}" + FEED_URL="https://nuget.pkg.github.com/DFE-Digital/query?q=$PACKAGE_ID" + LATEST_VERSION=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" "$FEED_URL" | jq -r '.data[0].version') + + if [[ -z "$LATEST_VERSION" || "$LATEST_VERSION" == "null" ]]; then + echo "No existing version found in the feed. Defaulting to version 1.0.0" + NEW_VERSION="1.0.0" + else + echo "Latest version is $LATEST_VERSION" + IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_VERSION" + NEW_VERSION="${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.$((VERSION_PARTS[2] + 1))" + echo "Incrementing to new version: $NEW_VERSION" + fi + + echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV + fi + + - name: Build, pack and publish + working-directory: ${{ inputs.project_path }} + run: | + dotnet build -c Release + dotnet pack -c Release -p:PackageVersion=${{ env.NEW_VERSION }} --output . + dotnet nuget push "*.nupkg" --api-key ${{ secrets.GITHUB_TOKEN }} --source https://nuget.pkg.github.com/DFE-Digital/index.json + + - name: Get Release Note + id: extract_description + run: | + # Retrieve the commit message body + COMMIT_MESSAGE=$(git log -1 --pretty=format:"%b") + + # Check if the commit message is empty + if [[ -z "$COMMIT_MESSAGE" ]]; then + echo "No commit message found. Skipping release note extraction." + DESCRIPTION="No release notes provided." + else + # Convert newlines in the commit message to a placeholder character (e.g., `~`) + SINGLE_LINE_COMMIT=$(echo "$COMMIT_MESSAGE" | tr '\n' '~') + + # Extract release note content from the single-line commit message + DESCRIPTION=$(echo "$SINGLE_LINE_COMMIT" | grep -oP '(?<=\(%release-note:)(.*?)(?=\s*%\))') + + # Replace the placeholder character `~` back with newlines + DESCRIPTION=$(echo "$DESCRIPTION" | sed 's/~/\n/g') + + # Check if the description extraction found anything + if [[ -z "$DESCRIPTION" ]]; then + DESCRIPTION="No release notes provided." + fi + fi + + echo "RELEASE_DESCRIPTION<> $GITHUB_ENV + echo "$DESCRIPTION" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TAG_NAME="${{ inputs.nuget_package_name }}-${{ env.NEW_VERSION || '1.0.0' }}" + + gh release create "$TAG_NAME" \ + --title "Release ${{ env.NEW_VERSION }} for ${{ inputs.nuget_package_name }}" \ + --notes "${{ env.RELEASE_DESCRIPTION }}" \ + --draft=false \ + --prerelease=false \ No newline at end of file diff --git a/.github/workflows/pack-asyncprocessing.yml b/.github/workflows/pack-asyncprocessing.yml new file mode 100644 index 0000000..840cd2d --- /dev/null +++ b/.github/workflows/pack-asyncprocessing.yml @@ -0,0 +1,16 @@ +name: Pack DfE.CoreLibs.AsyncProcessing + +on: + workflow_run: + workflows: ["Build DfE.CoreLibs.AsyncProcessing"] + types: + - completed + +jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.AsyncProcessing + project_path: src/DfE.CoreLibs.AsyncProcessing + nuget_package_name: DfE.CoreLibs.AsyncProcessing diff --git a/.github/workflows/pack-caching.yml b/.github/workflows/pack-caching.yml new file mode 100644 index 0000000..174df06 --- /dev/null +++ b/.github/workflows/pack-caching.yml @@ -0,0 +1,16 @@ +name: Pack DfE.CoreLibs.Caching + +on: + workflow_run: + workflows: ["Build DfE.CoreLibs.Caching"] + types: + - completed + +jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.Caching + project_path: src/DfE.CoreLibs.Caching + nuget_package_name: DfE.CoreLibs.Caching diff --git a/.github/workflows/pack-contracts.yml b/.github/workflows/pack-contracts.yml new file mode 100644 index 0000000..8ae7090 --- /dev/null +++ b/.github/workflows/pack-contracts.yml @@ -0,0 +1,16 @@ +name: Pack DfE.CoreLibs.Contracts + +on: + workflow_run: + workflows: ["Build DfE.CoreLibs.Contracts"] + types: + - completed + +jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.Contracts + project_path: src/DfE.CoreLibs.Contracts + nuget_package_name: DfE.CoreLibs.Contracts diff --git a/.github/workflows/pack-http.yml b/.github/workflows/pack-http.yml new file mode 100644 index 0000000..4557400 --- /dev/null +++ b/.github/workflows/pack-http.yml @@ -0,0 +1,16 @@ +name: Pack DfE.CoreLibs.Http + +on: + workflow_run: + workflows: ["Build DfE.CoreLibs.Http"] + types: + - completed + +jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.Http + project_path: src/DfE.CoreLibs.Http + nuget_package_name: DfE.CoreLibs.Http diff --git a/.github/workflows/pack-testing.yml b/.github/workflows/pack-testing.yml new file mode 100644 index 0000000..2201015 --- /dev/null +++ b/.github/workflows/pack-testing.yml @@ -0,0 +1,16 @@ +name: Pack DfE.CoreLibs.Testing + +on: + workflow_run: + workflows: ["Build DfE.CoreLibs.Testing"] + types: + - completed + +jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.Testing + project_path: src/DfE.CoreLibs.Testing + nuget_package_name: DfE.CoreLibs.Testing diff --git a/.github/workflows/pack-utilities.yml b/.github/workflows/pack-utilities.yml new file mode 100644 index 0000000..d38b3c9 --- /dev/null +++ b/.github/workflows/pack-utilities.yml @@ -0,0 +1,16 @@ +name: Pack DfE.CoreLibs.Utilities + +on: + workflow_run: + workflows: ["Build DfE.CoreLibs.Utilities"] + types: + - completed + +jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.Utilities + project_path: src/DfE.CoreLibs.Utilities + nuget_package_name: DfE.CoreLibs.Utilities diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9491a2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,363 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd \ No newline at end of file diff --git a/DfE.CoreLibs.sln b/DfE.CoreLibs.sln new file mode 100644 index 0000000..0fe505d --- /dev/null +++ b/DfE.CoreLibs.sln @@ -0,0 +1,87 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.10.35122.118 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Caching", "src\DfE.CoreLibs.Caching\DfE.CoreLibs.Caching.csproj", "{D88D58F0-18C4-4C3B-805B-8A483500E73E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Testing", "src\DfE.CoreLibs.Testing\DfE.CoreLibs.Testing.csproj", "{59BCCF06-99DC-4436-BFC2-074B23F71B6A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Contracts", "src\DfE.CoreLibs.Contracts\DfE.CoreLibs.Contracts.csproj", "{2542FB63-B097-4686-B172-27666445E54E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Http", "src\DfE.CoreLibs.Http\DfE.CoreLibs.Http.csproj", "{41478208-EA44-42EA-8CE1-2F5F8B6020A0}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Utilities", "src\DfE.CoreLibs.Utilities\DfE.CoreLibs.Utilities.csproj", "{653F54A6-602B-4234-B8A2-E79C4159814A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.AsyncProcessing", "src\DfE.CoreLibs.AsyncProcessing\DfE.CoreLibs.AsyncProcessing.csproj", "{0C06E8B9-E67C-4611-9FBE-894327EDF011}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Utilities.Tests", "src\Tests\DfE.CoreLibs.Utilities.Tests\DfE.CoreLibs.Utilities.Tests.csproj", "{07AE8F19-9566-4F0C-92E6-0A2BF122DCC9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Http.Tests", "src\Tests\DfE.CoreLibs.Http.Tests\DfE.CoreLibs.Http.Tests.csproj", "{69529D73-DD34-43A2-9D06-F3783F68F05C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DfE.CoreLibs.Caching.Tests", "src\Tests\DfE.CoreLibs.Caching.Tests\DfE.CoreLibs.Caching.Tests.csproj", "{807147EB-9B76-42F6-B249-A0F0CF3C3462}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DfE.CoreLibs.AsyncProcessing.Tests", "src\Tests\DfE.CoreLibs.AsyncProcessing.Tests\DfE.CoreLibs.AsyncProcessing.Tests.csproj", "{5ABF8802-0C35-42D3-B2BB-83BD7159124F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Release|Any CPU.Build.0 = Release|Any CPU + {59BCCF06-99DC-4436-BFC2-074B23F71B6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {59BCCF06-99DC-4436-BFC2-074B23F71B6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {59BCCF06-99DC-4436-BFC2-074B23F71B6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {59BCCF06-99DC-4436-BFC2-074B23F71B6A}.Release|Any CPU.Build.0 = Release|Any CPU + {2542FB63-B097-4686-B172-27666445E54E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2542FB63-B097-4686-B172-27666445E54E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2542FB63-B097-4686-B172-27666445E54E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2542FB63-B097-4686-B172-27666445E54E}.Release|Any CPU.Build.0 = Release|Any CPU + {41478208-EA44-42EA-8CE1-2F5F8B6020A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {41478208-EA44-42EA-8CE1-2F5F8B6020A0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {41478208-EA44-42EA-8CE1-2F5F8B6020A0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {41478208-EA44-42EA-8CE1-2F5F8B6020A0}.Release|Any CPU.Build.0 = Release|Any CPU + {653F54A6-602B-4234-B8A2-E79C4159814A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {653F54A6-602B-4234-B8A2-E79C4159814A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {653F54A6-602B-4234-B8A2-E79C4159814A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {653F54A6-602B-4234-B8A2-E79C4159814A}.Release|Any CPU.Build.0 = Release|Any CPU + {0C06E8B9-E67C-4611-9FBE-894327EDF011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C06E8B9-E67C-4611-9FBE-894327EDF011}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C06E8B9-E67C-4611-9FBE-894327EDF011}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C06E8B9-E67C-4611-9FBE-894327EDF011}.Release|Any CPU.Build.0 = Release|Any CPU + {07AE8F19-9566-4F0C-92E6-0A2BF122DCC9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {07AE8F19-9566-4F0C-92E6-0A2BF122DCC9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {07AE8F19-9566-4F0C-92E6-0A2BF122DCC9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {07AE8F19-9566-4F0C-92E6-0A2BF122DCC9}.Release|Any CPU.Build.0 = Release|Any CPU + {69529D73-DD34-43A2-9D06-F3783F68F05C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {69529D73-DD34-43A2-9D06-F3783F68F05C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {69529D73-DD34-43A2-9D06-F3783F68F05C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {69529D73-DD34-43A2-9D06-F3783F68F05C}.Release|Any CPU.Build.0 = Release|Any CPU + {807147EB-9B76-42F6-B249-A0F0CF3C3462}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {807147EB-9B76-42F6-B249-A0F0CF3C3462}.Debug|Any CPU.Build.0 = Debug|Any CPU + {807147EB-9B76-42F6-B249-A0F0CF3C3462}.Release|Any CPU.ActiveCfg = Release|Any CPU + {807147EB-9B76-42F6-B249-A0F0CF3C3462}.Release|Any CPU.Build.0 = Release|Any CPU + {5ABF8802-0C35-42D3-B2BB-83BD7159124F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5ABF8802-0C35-42D3-B2BB-83BD7159124F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5ABF8802-0C35-42D3-B2BB-83BD7159124F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5ABF8802-0C35-42D3-B2BB-83BD7159124F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {07AE8F19-9566-4F0C-92E6-0A2BF122DCC9} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} + {69529D73-DD34-43A2-9D06-F3783F68F05C} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} + {807147EB-9B76-42F6-B249-A0F0CF3C3462} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} + {5ABF8802-0C35-42D3-B2BB-83BD7159124F} = {3F89DCAD-8EC7-41ED-A08F-A9EFAE263EB4} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {01D11FBC-6C66-43E4-8F1F-46B105EDD95C} + EndGlobalSection +EndGlobal diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..52b184f --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ + +DfE Core Libraries +================== + +This repository consists of a .NET solution containing multiple class libraries, with each library published as a standalone NuGet package. The libraries follow the naming convention: `DfE.CoreLibs.{library_name}`. + +Deployment and versioning process +-------------------------------------- + +![Nuget Package Deployment](./nuget-deployment.png) + + +Adding a New Library to the Repository +-------------------------------------- + +To add a new library to this repository and automatically publish it as a NuGet package, follow these steps: + +1. **Create a new library** in the `src` folder in the root of the solution. +2. **Copy the two YAML workflow files** used for other libraries (e.g., from `Caching`) into your new library directory, and modify them as needed to match your new library. + +### File 1: `build-test-{library_name}.yml` + +For example, if your new library is called "FileService," name the file `build-test-FileService.yml`. + +#### Example Content (Replace with your library name): + +```yaml + name: Build DfE.CoreLibs.FileService + + on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.FileService/**' + pull_request: + branches: + - main + paths: + - 'src/DfE.CoreLibs.FileService/**' + + jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.FileService + project_path: src/DfE.CoreLibs.FileService + run_tests: false + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} +``` + + +Make sure to: + +* Replace `DfE.CoreLibs.FileService` with your new library name. +* Ensure the path to the new library is correct. + +### File 2: `pack-{library_name}.yml` + +For example, name the file `pack-FileService.yml` for your new library. + +#### Example Content (Replace with your library name): + +```yaml + name: Pack DfE.CoreLibs.FileService + + on: + workflow_run: + workflows: ["Build DfE.CoreLibs.FileService"] + types: + - completed + + jobs: + build-and-package: + if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.event != 'pull_request' }} + uses: ./.github/workflows/nuget-package-template.yml + with: + project_name: DfE.CoreLibs.FileService + project_path: src/DfE.CoreLibs.FileService + nuget_package_name: DfE.CoreLibs.FileService + +``` + +Workflows Explanation +--------------------- + +* **Build and Test Workflow** (`build-test-{library_name}.yml`): This workflow is responsible for building and testing your library. +* **Pack Workflow** (`pack-{library_name}.yml`): This workflow handles versioning and packaging of your library, but it only runs after the build and test workflow successfully completes. + +Versioning and Auto-Publishing +------------------------------ + +* **Initial Versioning:** The first time your library is published, the version will start at `1.0.0`. +* **Automatic Increment:** With subsequent changes, the patch version will increment automatically (e.g., `1.0.1`, `1.0.2`, and so on). +* **Custom Version Bumps:** To bump the **minor** or **major** version of your library, follow these steps: + 1. Make the necessary changes in your library. + 2. Commit your changes with a message like the following: + + (%update {Project_Name} package version to {version_number}) + + Example: + + (%update DfE.CoreLibs.FileService package version to 1.1.0) + + +The packaging workflow will then automatically set the version to `1.1.0` and increment the patch part (`1.1.x`) with each further change. + +Release and Release Note +------------------------------ + +Each time a package is published succesfully, a new tag and release is created in the repository. + +* **Custom Release Note:** To add a Release Note to the release, simply include the following to your commit messages: + + (%release-note: {note} %) + + Example: + + (%release-note: Example Message to be Added in the Release Note %) + + diff --git a/nuget-deployment.png b/nuget-deployment.png new file mode 100644 index 0000000..765ef50 Binary files /dev/null and b/nuget-deployment.png differ diff --git a/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj b/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj new file mode 100644 index 0000000..7d101d8 --- /dev/null +++ b/src/DfE.CoreLibs.AsyncProcessing/DfE.CoreLibs.AsyncProcessing.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + readme.md + DfE.CoreLibs.AsyncProcessing + A library for managing asynchronous background processing, including task scheduling, service creation, and execution. + DFE-Digital + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceEvent.cs b/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceEvent.cs new file mode 100644 index 0000000..684186c --- /dev/null +++ b/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceEvent.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace DfE.CoreLibs.AsyncProcessing.Interfaces +{ + public interface IBackgroundServiceEvent : INotification + { + } +} diff --git a/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceEventHandler.cs b/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceEventHandler.cs new file mode 100644 index 0000000..de1ed14 --- /dev/null +++ b/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceEventHandler.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace DfE.CoreLibs.AsyncProcessing.Interfaces +{ + public interface IBackgroundServiceEventHandler : INotificationHandler where TEvent : IBackgroundServiceEvent + { + } +} diff --git a/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceFactory.cs b/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceFactory.cs new file mode 100644 index 0000000..2ebeb7c --- /dev/null +++ b/src/DfE.CoreLibs.AsyncProcessing/Interfaces/IBackgroundServiceFactory.cs @@ -0,0 +1,8 @@ +namespace DfE.CoreLibs.AsyncProcessing.Interfaces +{ + public interface IBackgroundServiceFactory + { + void EnqueueTask(Func> taskFunc, Func? eventFactory = null) + where TEvent : IBackgroundServiceEvent; + } +} diff --git a/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..c97a796 --- /dev/null +++ b/src/DfE.CoreLibs.AsyncProcessing/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using DfE.CoreLibs.AsyncProcessing.Interfaces; +using DfE.CoreLibs.AsyncProcessing.Services; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddBackgroundService(this IServiceCollection services) + { + services.AddSingleton(); + services.AddHostedService(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs new file mode 100644 index 0000000..85850f8 --- /dev/null +++ b/src/DfE.CoreLibs.AsyncProcessing/Services/BackgroundServiceFactory.cs @@ -0,0 +1,61 @@ +using System.Collections.Concurrent; +using DfE.CoreLibs.AsyncProcessing.Interfaces; +using MediatR; + +namespace DfE.CoreLibs.AsyncProcessing.Services +{ + public class BackgroundServiceFactory(IMediator mediator) : Microsoft.Extensions.Hosting.BackgroundService, IBackgroundServiceFactory + { + private readonly ConcurrentDictionary>> _taskQueues = new(); + private readonly ConcurrentDictionary _semaphores = new(); + + public void EnqueueTask(Func> taskFunc, Func? eventFactory = null) + where TEvent : IBackgroundServiceEvent + { + var taskType = taskFunc.GetType(); + var queue = _taskQueues.GetOrAdd(taskType, new ConcurrentQueue>()); + _semaphores.GetOrAdd(taskType, new SemaphoreSlim(1, 1)); + + queue.Enqueue(async () => + { + var result = await taskFunc(); + + if (eventFactory != null) + { + var taskCompletedEvent = eventFactory.Invoke(result); + await mediator.Publish(taskCompletedEvent); + } + }); + + _ = StartProcessingQueue(taskType); + } + + private async Task StartProcessingQueue(Type taskType) + { + var queue = _taskQueues[taskType]; + var semaphore = _semaphores[taskType]; + + await semaphore.WaitAsync(); + + try + { + while (queue.TryDequeue(out var taskToProcess)) + { + await taskToProcess(); + } + } + finally + { + semaphore.Release(); + } + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + await Task.Delay(1000, stoppingToken); + } + } + } +} diff --git a/src/DfE.CoreLibs.AsyncProcessing/readme.md b/src/DfE.CoreLibs.AsyncProcessing/readme.md new file mode 100644 index 0000000..cfd19d2 --- /dev/null +++ b/src/DfE.CoreLibs.AsyncProcessing/readme.md @@ -0,0 +1,84 @@ +# DfE.CoreLibs.BackgroundService + +This library provides a robust framework for implementing long-running background tasks in .NET applications. It simplifies the development of background services by offering reusable components that streamline task scheduling, execution, and error handling. Ideal for any project requiring background processing, it ensures reliability and scalability across different environments. + +## Installation + +To install the DfE.CoreLibs.BackgroundService Library, use the following command in your .NET project: + +```sh +dotnet add package DfE.CoreLibs.BackgroundService +``` + +## Usage + +**Usage in a Command Handler** + +1. **Service Registration:** You use a background service factory to enqueue tasks. Register the factory in your `Program.cs`: + + ```csharp + public void ConfigureServices(IServiceCollection services) + { + services.AddBackgroundService(); + } + ``` + + +2. **Implementation in the Handler:** You enqueue tasks using `IBackgroundServiceFactory` directly inside a command handler, optionally you can pass in an event to be raised when the task is completed, as shown in your code: + + ```csharp + public class CreateReportCommandHandler : IRequestHandler + { + private readonly IBackgroundServiceFactory _backgroundServiceFactory; + + public CreateReportCommandHandler(IBackgroundServiceFactory backgroundServiceFactory) + { + _backgroundServiceFactory = backgroundServiceFactory; + } + + public Task Handle(CreateReportCommand request, CancellationToken cancellationToken) + { + var taskName = "Create_Report_Task1"; + + _backgroundServiceFactory.EnqueueTask( + async () => await (new CreateReportExampleTask()).RunAsync(taskName), + result => new CreateReportExampleTaskCompletedEvent(taskName, result) + ); + + return Task.FromResult(true); + } + } + ``` + +3. **Events:** The background service triggers events when a task is completed. For example: + + ```csharp + public class CreateReportExampleTaskCompletedEvent : IBackgroundServiceEvent + { + public string TaskName { get; } + public string Message { get; } + + public CreateReportExampleTaskCompletedEvent(string taskName, string message) + { + TaskName = taskName; + Message = message; + } + } + ``` + +4. **Event Handlers:** These events are processed by event handlers. Here's an example of how you handle task completion events: + + ```csharp + public class SimpleTaskCompletedEventHandler : IBackgroundServiceEventHandler + { + public Task Handle(CreateReportExampleTaskCompletedEvent notification, CancellationToken cancellationToken) + { + Console.WriteLine($"Event received for Task: {notification.TaskName}, Message: {notification.Message}"); + return Task.CompletedTask; + } + } + ``` + +This setup allows you to enqueue tasks in the background, fire events when tasks complete, and handle those events using a custom event handler architecture. + +* * * \ No newline at end of file diff --git a/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj new file mode 100644 index 0000000..1b2ae5f --- /dev/null +++ b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + readme.md + DfE.CoreLibs.Caching + This caching library offers a unified, efficient caching solution for .NET projects. It provides a simple, reusable abstraction over different caching mechanisms, enabling developers to easily implement in-memory and distributed caching strategies, improving the performance and scalability of their applications. + DFE-Digital + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/DfE.CoreLibs.Caching/Helpers/CacheKeyHelper.cs b/src/DfE.CoreLibs.Caching/Helpers/CacheKeyHelper.cs new file mode 100644 index 0000000..f78450e --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Helpers/CacheKeyHelper.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace DfE.CoreLibs.Caching.Helpers +{ + public static class CacheKeyHelper + { + /// + /// Generates a hashed cache key for any given input string. + /// + /// The input string to be hashed. + /// A hashed string that can be used as a cache key. + public static string GenerateHashedCacheKey(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + throw new ArgumentException("Input cannot be null or empty", nameof(input)); + } + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + return BitConverter.ToString(bytes).Replace("-", "").ToLower(); + } + + /// + /// Generates a hashed cache key for a collection of strings by concatenating them. + /// + /// A collection of strings to be concatenated and hashed. + /// A hashed string that can be used as a cache key. + public static string GenerateHashedCacheKey(IEnumerable inputs) + { + if (inputs == null || !inputs.Any()) + { + throw new ArgumentException("Input collection cannot be null or empty", nameof(inputs)); + } + + var concatenatedInput = string.Join(",", inputs); + + return GenerateHashedCacheKey(concatenatedInput); + } + } + +} diff --git a/src/DfE.CoreLibs.Caching/Interfaces/ICacheService.cs b/src/DfE.CoreLibs.Caching/Interfaces/ICacheService.cs new file mode 100644 index 0000000..e4ad242 --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Interfaces/ICacheService.cs @@ -0,0 +1,9 @@ +namespace DfE.CoreLibs.Caching.Interfaces +{ + public interface ICacheService where TCacheType : ICacheType + { + Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName); + void Remove(string cacheKey); + Type CacheType { get; } + } +} diff --git a/src/DfE.CoreLibs.Caching/Interfaces/ICacheType.cs b/src/DfE.CoreLibs.Caching/Interfaces/ICacheType.cs new file mode 100644 index 0000000..9700f11 --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Interfaces/ICacheType.cs @@ -0,0 +1,4 @@ +namespace DfE.CoreLibs.Caching.Interfaces +{ + public interface ICacheType; +} diff --git a/src/DfE.CoreLibs.Caching/Interfaces/IMemoryCacheType.cs b/src/DfE.CoreLibs.Caching/Interfaces/IMemoryCacheType.cs new file mode 100644 index 0000000..31723bf --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Interfaces/IMemoryCacheType.cs @@ -0,0 +1,4 @@ +namespace DfE.CoreLibs.Caching.Interfaces +{ + public interface IMemoryCacheType : ICacheType; +} diff --git a/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..7bf7eb4 --- /dev/null +++ b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using DfE.CoreLibs.Caching.Interfaces; +using DfE.CoreLibs.Caching.Services; +using DfE.CoreLibs.Caching.Settings; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddServiceCaching( + this IServiceCollection services, IConfiguration config) + { + services.Configure(config.GetSection("CacheSettings")); + services.AddSingleton, MemoryCacheService>(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Caching/Services/MemoryCacheService.cs b/src/DfE.CoreLibs.Caching/Services/MemoryCacheService.cs new file mode 100644 index 0000000..6226e32 --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Services/MemoryCacheService.cs @@ -0,0 +1,52 @@ +using DfE.CoreLibs.Caching.Interfaces; +using DfE.CoreLibs.Caching.Settings; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DfE.CoreLibs.Caching.Services +{ + public class MemoryCacheService( + IMemoryCache memoryCache, + ILogger logger, + IOptions cacheSettings) + : ICacheService + { + private readonly MemoryCacheSettings _cacheSettings = cacheSettings.Value.Memory; + public Type CacheType => typeof(IMemoryCacheType); + + public async Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName) + { + if (memoryCache.TryGetValue(cacheKey, out T? cachedValue)) + { + logger.LogInformation("Cache hit for key: {CacheKey}", cacheKey); + return cachedValue!; + } + + logger.LogInformation("Cache miss for key: {CacheKey}. Fetching from source...", cacheKey); + var result = await fetchFunction(); + + if (Equals(result, default(T))) return result; + var cacheDuration = GetCacheDurationForMethod(methodName); + memoryCache.Set(cacheKey, result, cacheDuration); + logger.LogInformation("Cached result for key: {CacheKey} for duration: {CacheDuration}", cacheKey, cacheDuration); + + return result; + } + + public void Remove(string cacheKey) + { + memoryCache.Remove(cacheKey); + logger.LogInformation("Cache removed for key: {CacheKey}", cacheKey); + } + + private TimeSpan GetCacheDurationForMethod(string methodName) + { + if (_cacheSettings.Durations.TryGetValue(methodName, out int durationInSeconds)) + { + return TimeSpan.FromSeconds(durationInSeconds); + } + return TimeSpan.FromSeconds(_cacheSettings.DefaultDurationInSeconds); + } + } +} diff --git a/src/DfE.CoreLibs.Caching/Settings/CacheSettings.cs b/src/DfE.CoreLibs.Caching/Settings/CacheSettings.cs new file mode 100644 index 0000000..85cf1e7 --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Settings/CacheSettings.cs @@ -0,0 +1,13 @@ +namespace DfE.CoreLibs.Caching.Settings +{ + public class CacheSettings + { + public MemoryCacheSettings Memory { get; set; } = new(); + } + + public class MemoryCacheSettings + { + public int DefaultDurationInSeconds { get; set; } = 5; + public Dictionary Durations { get; set; } = new(); + } +} diff --git a/src/DfE.CoreLibs.Caching/readme.md b/src/DfE.CoreLibs.Caching/readme.md new file mode 100644 index 0000000..3233aaa --- /dev/null +++ b/src/DfE.CoreLibs.Caching/readme.md @@ -0,0 +1,78 @@ +# DfE.CoreLibs.Caching + +This caching library offers a unified, efficient caching solution for .NET projects. It provides a simple, reusable abstraction over different caching mechanisms, enabling developers to easily implement in-memory and distributed caching strategies, improving the performance and scalability of their applications. + +## Installation + +To install the DfE.CoreLibs.Caching Library, use the following command in your .NET project: + +```sh +dotnet add package DfE.CoreLibs.Caching +``` + +## Usage + +**Usage in Handlers** + +1. **Service Registration:** You use `ICacheService` in your handlers to store and retrieve data from memory to avoid unnecessary processing and database queries. Here's how you register the caching service: + + ```csharp + public void ConfigureServices(IServiceCollection services) + { + services.AddServiceCaching(config); + } + ``` + + +2. **Usage in Handlers:** Here's an example of how caching is used in one of your query handlers: + + ```csharp + public class GetPrincipalBySchoolQueryHandler( + ISchoolRepository schoolRepository, + IMapper mapper, + ICacheService cacheService) + : IRequestHandler + { + public async Task Handle(GetPrincipalBySchoolQuery request, CancellationToken cancellationToken) + { + var cacheKey = $"Principal_{CacheKeyHelper.GenerateHashedCacheKey(request.SchoolName)}"; + + var methodName = nameof(GetPrincipalBySchoolQueryHandler); + + return await cacheService.GetOrAddAsync(cacheKey, async () => + { + var principal= await schoolRepository + .GetPrincipalBySchoolAsync(request.SchoolName, cancellationToken); + + var result = mapper.Map(principal); + + return result; + }, methodName); + } + } + ``` + +In this case, the query handler checks if the principals are cached by generating a unique cache key. If the data is not cached, it retrieves the data from the repository, caches it, and returns it. + +### Cache Duration Based on Method Name + +The caching service dynamically determines the cache duration based on the method name. This is particularly useful when you want to apply different caching durations to different query handlers. +In this example, the cache duration for `GetPrincipalBySchoolQueryHandler` is retrieved from the configuration using the method name. If no specific duration is defined for the method, it will fall back to the default cache duration. + +#### Example of Cache Settings in appsettings.json + +Here is the configuration for cache durations in the `appsettings.json` file: + + ```csharp + "CacheSettings": { + "Memory": { + "DefaultDurationInSeconds": 60, + "Durations": { + "GetPrincipalBySchoolQueryHandler": 86400 + } + } + ``` + +This setup ensures that the `GetPrincipalBySchoolQueryHandler` cache duration is set to 24 hours (86400 seconds), while other handlers will use the default duration of 60 seconds if no specific duration is configured. + +* * * \ No newline at end of file diff --git a/src/DfE.CoreLibs.Contracts/Academies/V1/EducationalPerformance/SchoolAbsenceDataDto.cs b/src/DfE.CoreLibs.Contracts/Academies/V1/EducationalPerformance/SchoolAbsenceDataDto.cs new file mode 100644 index 0000000..92cbba3 --- /dev/null +++ b/src/DfE.CoreLibs.Contracts/Academies/V1/EducationalPerformance/SchoolAbsenceDataDto.cs @@ -0,0 +1,29 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Contracts.Academies.V1.EducationalPerformance +{ + /// + /// Absence Data Response + /// + [ExcludeFromCodeCoverage] + public class SchoolAbsenceDataDto + { + /// + /// Acdemic Year + /// + public string Year { get; set; } + + /// + ///Percentage of possible mornings or afternoons recorded as an absence from school for whatever reason, + ///whether authorised or unauthorised, across the full academic year. + /// + public string? OverallAbsence { get; set; } + + /// + ///The percentage of pupils missing 10% or more of the mornings or afternoons they could attend, + ///meaning that if a pupil’s overall rate of absence is 10% or higher across the full academic + ///year they will be classified as persistently absent. + /// + public string? PersistentAbsence { get; set; } + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Contracts/Academies/V4/AddressDto.cs b/src/DfE.CoreLibs.Contracts/Academies/V4/AddressDto.cs new file mode 100644 index 0000000..4f282d9 --- /dev/null +++ b/src/DfE.CoreLibs.Contracts/Academies/V4/AddressDto.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Contracts.Academies.V4; + +[Serializable] +[ExcludeFromCodeCoverage] +public class AddressDto +{ + public string Street { get; set; } + + public string Town { get; set; } + + public string County { get; set; } + + public string Postcode { get; set; } + + public string Locality { get; set; } + + public string Additional { get; set; } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Contracts/Academies/V4/Establishments/EstablishmentDto.cs b/src/DfE.CoreLibs.Contracts/Academies/V4/Establishments/EstablishmentDto.cs new file mode 100644 index 0000000..9325112 --- /dev/null +++ b/src/DfE.CoreLibs.Contracts/Academies/V4/Establishments/EstablishmentDto.cs @@ -0,0 +1,81 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Contracts.Academies.V4.Establishments; + +[Serializable] +[ExcludeFromCodeCoverage] +public class EstablishmentDto +{ + + public string Ukprn { get; set; } + public string Urn { get; set; } + public string Name { get; set; } + public string LocalAuthorityCode { get; set; } + public string LocalAuthorityName { get; set; } + public string OfstedRating { get; set; } + public string OfstedLastInspection { get; set; } + public string StatutoryLowAge { get; set; } + public string StatutoryHighAge { get; set; } + public string SchoolCapacity { get; set; } + public string Pfi { get; set; } + public string EstablishmentNumber { get; set; } + public string Pan { get; set; } + public string Deficit { get; set; } + public string ViabilityIssue { get; set; } + public string GiasLastChangedDate { get; set; } + public string NoOfBoys { get; set; } + public string NoOfGirls { get; set; } + public string SenUnitCapacity { get; set; } + public string SenUnitOnRoll { get; set; } + public string ReligousEthos { get; set; } + + public string HeadteacherTitle { get; set; } + public string HeadteacherFirstName { get; set; } + public string HeadteacherLastName { get; set; } + public string HeadteacherPreferredJobTitle { get; set; } + + public NameAndCodeDto Diocese { get; set; } + public NameAndCodeDto EstablishmentType { get; set; } + public NameAndCodeDto Gor { get; set; } + public NameAndCodeDto PhaseOfEducation { get; set; } + public NameAndCodeDto ReligiousCharacter { get; set; } + public NameAndCodeDto ParliamentaryConstituency { get; set; } + public CensusDto Census { get; set; } + public MisEstablishmentDto MISEstablishment { get; set; } + public AddressDto Address { get; set; } +} + +[Serializable] +public class NameAndCodeDto +{ + public string Name { get; set; } + public string Code { get; set; } +} + + +[Serializable] +public class MisEstablishmentDto +{ + public string DateOfLatestSection8Inspection { get; set; } + public string InspectionEndDate { get; set; } + + public string OverallEffectiveness { get; set; } + public string QualityOfEducation { get; set; } + public string BehaviourAndAttitudes { get; set; } + public string PersonalDevelopment { get; set; } + public string EffectivenessOfLeadershipAndManagement { get; set; } + + public string EarlyYearsProvision { get; set; } + public string SixthFormProvision { get; set; } + public string Weblink { get; set; } +} + +[Serializable] +public class CensusDto +{ + public string NumberOfPupils { get; set; } + public string PercentageFsm { get; set; } + public string PercentageFsmLastSixYears { get; set; } + public string PercentageEnglishAsSecondLanguage { get; set; } + public string PercentageSen { get; set; } +} diff --git a/src/DfE.CoreLibs.Contracts/Academies/V4/PagedDataResponse.cs b/src/DfE.CoreLibs.Contracts/Academies/V4/PagedDataResponse.cs new file mode 100644 index 0000000..a1ac376 --- /dev/null +++ b/src/DfE.CoreLibs.Contracts/Academies/V4/PagedDataResponse.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Contracts.Academies.V4; + +[Serializable] +[ExcludeFromCodeCoverage] +public class PagedDataResponse where TResponse : class +{ + + public IEnumerable Data { get; set; } + public PagingResponse Paging { get; set; } + + public PagedDataResponse() => Data = new List(); + + public PagedDataResponse(IEnumerable data, PagingResponse pagingResponse) + { + Data = data; + Paging = pagingResponse; + } + + public PagedDataResponse(TResponse data) => Data = new List { data }; + +} + +[Serializable] +[ExcludeFromCodeCoverage] +public class PagingResponse +{ + public int Page { get; set; } + public int RecordCount { get; set; } + public string NextPageUrl { get; set; } +} diff --git a/src/DfE.CoreLibs.Contracts/Academies/V4/Trusts/TrustDto.cs b/src/DfE.CoreLibs.Contracts/Academies/V4/Trusts/TrustDto.cs new file mode 100644 index 0000000..22db583 --- /dev/null +++ b/src/DfE.CoreLibs.Contracts/Academies/V4/Trusts/TrustDto.cs @@ -0,0 +1,21 @@ +using DfE.CoreLibs.Contracts.Academies.V4.Establishments; +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Contracts.Academies.V4.Trusts; + +[Serializable] +[ExcludeFromCodeCoverage] +public class TrustDto +{ + public string Name { get; set; } + + public string Ukprn { get; set; } + public NameAndCodeDto Type { get; set; } + + public string CompaniesHouseNumber { get; set; } + + public string ReferenceNumber { get; set; } + + public AddressDto Address { get; set; } + +} diff --git a/src/DfE.CoreLibs.Contracts/DfE.CoreLibs.Contracts.csproj b/src/DfE.CoreLibs.Contracts/DfE.CoreLibs.Contracts.csproj new file mode 100644 index 0000000..4d48ef9 --- /dev/null +++ b/src/DfE.CoreLibs.Contracts/DfE.CoreLibs.Contracts.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + readme.md + DfE.CoreLibs.Contracts + A library providing reusable contract definitions for use across different domains and services. + DFE-Digital + + + + + + + + + PreserveNewest + + + + diff --git a/src/DfE.CoreLibs.Contracts/readme.md b/src/DfE.CoreLibs.Contracts/readme.md new file mode 100644 index 0000000..a80081e --- /dev/null +++ b/src/DfE.CoreLibs.Contracts/readme.md @@ -0,0 +1 @@ +# DfE.CoreLibs.Contracts diff --git a/src/DfE.CoreLibs.Http/DfE.CoreLibs.Http.csproj b/src/DfE.CoreLibs.Http/DfE.CoreLibs.Http.csproj new file mode 100644 index 0000000..fd8f03e --- /dev/null +++ b/src/DfE.CoreLibs.Http/DfE.CoreLibs.Http.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + readme.md + DfE.CoreLibs.Http + A library offering HTTP-related utilities and middleware, for building robust web services. + DFE-Digital + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/src/DfE.CoreLibs.Http/Interfaces/ICorrelationContext.cs b/src/DfE.CoreLibs.Http/Interfaces/ICorrelationContext.cs new file mode 100644 index 0000000..4dec3c9 --- /dev/null +++ b/src/DfE.CoreLibs.Http/Interfaces/ICorrelationContext.cs @@ -0,0 +1,19 @@ +namespace DfE.CoreLibs.Http.Interfaces; + +/// +/// Provides access to the current correlation id. You should register this as a scoped / per web request +/// dependency in your IoC/DI container. +/// +public interface ICorrelationContext +{ + /// + /// Returns the current correlation id if it has been set + /// + public Guid CorrelationId { get; } + + /// + /// Used by the middleware to store the current correlation id. Do not call this method yourself. + /// + /// + public void SetContext(Guid correlationId); +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/CorrelationContext.cs b/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/CorrelationContext.cs new file mode 100644 index 0000000..0a4485e --- /dev/null +++ b/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/CorrelationContext.cs @@ -0,0 +1,20 @@ +using DfE.CoreLibs.Http.Interfaces; + +namespace DfE.CoreLibs.Http.Middlewares.CorrelationId; + +/// +public class CorrelationContext : ICorrelationContext +{ + /// + public Guid CorrelationId { get; private set; } + + /// + public void SetContext(Guid correlationId) + { + if (correlationId == Guid.Empty) + { + throw new ArgumentException("Guid cannot be empty", nameof(correlationId)); + } + CorrelationId = correlationId; + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/CorrelationIdMiddleware.cs b/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/CorrelationIdMiddleware.cs new file mode 100644 index 0000000..44a9fba --- /dev/null +++ b/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/CorrelationIdMiddleware.cs @@ -0,0 +1,75 @@ +using DfE.CoreLibs.Http.Interfaces; +using Microsoft.Extensions.Logging; +using System.Net; +using Microsoft.AspNetCore.Http; + +namespace DfE.CoreLibs.Http.Middlewares.CorrelationId; + +/// +/// Middleware that checks incoming requests for a correlation and causation id header. If not found then default values will be created. +/// Saves these values in the correlationContext instance. Be sure to register correlation context as scoped or the equivalent in you ioc container. +/// Header used in requests is 'x-correlationId' +/// +public class CorrelationIdMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public CorrelationIdMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + // ReSharper disable once UnusedMember.Global + // Invoked by asp.net + public Task Invoke(HttpContext httpContext, ICorrelationContext correlationContext) + { + Guid thisCorrelationId; + + // correlation id. An ID that spans many requests + if (httpContext.Request.Headers.ContainsKey(Keys.HeaderKey) + && !string.IsNullOrWhiteSpace(httpContext.Request.Headers[Keys.HeaderKey])) + { + if (!Guid.TryParse(httpContext.Request.Headers[Keys.HeaderKey], out thisCorrelationId)) + { + thisCorrelationId = Guid.NewGuid(); + _logger.LogWarning("Detected header x-correlationId, but value cannot be parsed to a GUID. Other values are not supported. Generated a new one: {CorrelationId}", thisCorrelationId); + } + else + { + _logger.LogInformation("CorrelationIdMiddleware:Invoke - x-correlationId detected in request headers: {CorrelationId}", thisCorrelationId); + } + } + else + { + thisCorrelationId = Guid.NewGuid(); + _logger.LogWarning("CorrelationIdMiddleware:Invoke - x-correlationId not detected in request headers. Generated a new one: {CorrelationId}", thisCorrelationId); + } + + if (thisCorrelationId == Guid.Empty) + { + var result = new + { + StatusCode = (int)HttpStatusCode.BadRequest, + Message = $"Bad Request. {Keys.HeaderKey} header cannot be an empty GUID" + }; + + + httpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + httpContext.Response.ContentType = "text/json"; + return httpContext.Response.WriteAsync(result.ToString()); + } + + + httpContext.Request.Headers[Keys.HeaderKey] = thisCorrelationId.ToString(); + + correlationContext.SetContext(thisCorrelationId); + + httpContext.Response.Headers[Keys.HeaderKey] = thisCorrelationId.ToString(); + using (_logger.BeginScope("x-correlationId: {x-correlationId}", correlationContext.CorrelationId.ToString())) + { + return _next(httpContext); + } + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/Keys.cs b/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/Keys.cs new file mode 100644 index 0000000..f85373b --- /dev/null +++ b/src/DfE.CoreLibs.Http/Middlewares/CorrelationId/Keys.cs @@ -0,0 +1,17 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DfE.CoreLibs.Http.Tests")] + +namespace DfE.CoreLibs.Http.Middlewares.CorrelationId; + +/// +/// The keys used by the correlation id middleware. +/// +internal static class Keys +{ + /// + /// The header key use to detect incoming correlation ids, and to send them in responses. + /// Use this key if you are making subsequent requests so that correlation flows between services + /// + public const string HeaderKey = "x-correlationId"; +} diff --git a/src/DfE.CoreLibs.Http/readme.md b/src/DfE.CoreLibs.Http/readme.md new file mode 100644 index 0000000..328ab93 --- /dev/null +++ b/src/DfE.CoreLibs.Http/readme.md @@ -0,0 +1 @@ +# DfE.CoreLibs.Http diff --git a/src/DfE.CoreLibs.Testing/AutoFixture/Attributes/CustomAutoDataAttribute.cs b/src/DfE.CoreLibs.Testing/AutoFixture/Attributes/CustomAutoDataAttribute.cs new file mode 100644 index 0000000..da662b9 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/AutoFixture/Attributes/CustomAutoDataAttribute.cs @@ -0,0 +1,19 @@ +using System.Diagnostics.CodeAnalysis; +using AutoFixture.Xunit2; +using DfE.CoreLibs.Testing.AutoFixture.Customizations; +using DfE.CoreLibs.Testing.Helpers; + +namespace DfE.CoreLibs.Testing.AutoFixture.Attributes +{ + [ExcludeFromCodeCoverage] + [AttributeUsage(AttributeTargets.Method)] + public class CustomAutoDataAttribute(params Type[] customizations) + : AutoDataAttribute(() => FixtureFactoryHelper.ConfigureFixtureFactory(CombineCustomizations(customizations))) + { + private static Type[] CombineCustomizations(Type[] customizations) + { + var defaultCustomizations = new[] { typeof(NSubstituteCustomization) }; + return defaultCustomizations.Concat(customizations).ToArray(); + } + } +} diff --git a/src/DfE.CoreLibs.Testing/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs b/src/DfE.CoreLibs.Testing/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs new file mode 100644 index 0000000..7e9e854 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/AutoFixture/Attributes/InlineCustomAutoDataAttribute.cs @@ -0,0 +1,10 @@ +using System.Diagnostics.CodeAnalysis; +using AutoFixture.Xunit2; + +namespace DfE.CoreLibs.Testing.AutoFixture.Attributes +{ + [ExcludeFromCodeCoverage] + [AttributeUsage(AttributeTargets.Method)] + public class InlineCustomAutoDataAttribute(object[] values, params Type[] customizations) + : InlineAutoDataAttribute(new CustomAutoDataAttribute(customizations), values); +} diff --git a/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/AutoMapperCustomization.cs b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/AutoMapperCustomization.cs new file mode 100644 index 0000000..c5d16f9 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/AutoMapperCustomization.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.CodeAnalysis; +using AutoFixture; +using AutoMapper; + +namespace DfE.CoreLibs.Testing.AutoFixture.Customizations +{ + [ExcludeFromCodeCoverage] + public class AutoMapperCustomization : ICustomization where TProfile : Profile + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => composer.FromFactory(() => + { + var profiles = typeof(TProfile).Assembly + .GetTypes() + .Where(t => typeof(Profile).IsAssignableFrom(t) && !t.IsAbstract) + .ToList(); + + var config = new MapperConfiguration(cfg => + { + foreach (var profileInstance in profiles.Select(profileType => (Profile)Activator.CreateInstance(profileType)!)) + { + cfg.AddProfile(profileInstance); + } + }); + + return config.CreateMapper(); + })); + } + } +} diff --git a/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/DateOnlyCustomization.cs b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/DateOnlyCustomization.cs new file mode 100644 index 0000000..23fa4a1 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/DateOnlyCustomization.cs @@ -0,0 +1,16 @@ +using AutoFixture; +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Testing.AutoFixture.Customizations +{ + [ExcludeFromCodeCoverage] + public class DateOnlyCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => + composer.FromFactory(() => + DateOnly.FromDateTime(fixture.Create()))); + } + } +} diff --git a/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/HttpContextCustomization.cs b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/HttpContextCustomization.cs new file mode 100644 index 0000000..4be7b49 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/HttpContextCustomization.cs @@ -0,0 +1,15 @@ +using AutoFixture; +using Microsoft.AspNetCore.Http; +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Testing.AutoFixture.Customizations +{ + [ExcludeFromCodeCoverage] + public class HttpContextCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Register(() => new DefaultHttpContext()); + } + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/NSubstituteCustomization.cs b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/NSubstituteCustomization.cs new file mode 100644 index 0000000..24bf519 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/NSubstituteCustomization.cs @@ -0,0 +1,15 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Testing.AutoFixture.Customizations +{ + [ExcludeFromCodeCoverage] + public class NSubstituteCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(new AutoNSubstituteCustomization()); + } + } +} diff --git a/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/OmitCircularReferenceCustomization.cs b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/OmitCircularReferenceCustomization.cs new file mode 100644 index 0000000..7018f61 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/AutoFixture/Customizations/OmitCircularReferenceCustomization.cs @@ -0,0 +1,17 @@ +using AutoFixture; +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Testing.AutoFixture.Customizations +{ + [ExcludeFromCodeCoverage] + public class OmitCircularReferenceCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Behaviors.OfType().ToList() + .ForEach(b => fixture.Behaviors.Remove(b)); + + fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + } + } +} diff --git a/src/DfE.CoreLibs.Testing/DfE.CoreLibs.Testing.csproj b/src/DfE.CoreLibs.Testing/DfE.CoreLibs.Testing.csproj new file mode 100644 index 0000000..1b23e63 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/DfE.CoreLibs.Testing.csproj @@ -0,0 +1,44 @@ + + + + net8.0 + enable + enable + readme.md + DfE.CoreLibs.Testing + Designed to enhance test automation, this library provides essential utilities and frameworks for unit and integration testing in .NET. It includes tools for mocking, assertions, and common test scenarios, helping developers write cleaner, more efficient tests that improve the overall quality and stability of their applications. + DFE-Digital + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/src/DfE.CoreLibs.Testing/Helpers/DbContextHelper.cs b/src/DfE.CoreLibs.Testing/Helpers/DbContextHelper.cs new file mode 100644 index 0000000..22f432b --- /dev/null +++ b/src/DfE.CoreLibs.Testing/Helpers/DbContextHelper.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Testing.Helpers +{ + [ExcludeFromCodeCoverage] + public static class DbContextHelper + { + public static void CreateDbContext( + IServiceCollection services, + DbConnection connection, + Action? seedTestData = null) where TContext : DbContext + { + ConfigureDbContext(services, connection); + InitializeDbContext(services, seedTestData); + } + + public static void ConfigureDbContext( + IServiceCollection services, + DbConnection connection) where TContext : DbContext + { + services.AddDbContext((sp, options) => + { + options.UseSqlite(connection); + }); + } + + private static void InitializeDbContext( + IServiceCollection services, + Action? seedTestData) where TContext : DbContext + { + var serviceProvider = services.BuildServiceProvider(); + using var scope = serviceProvider.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + + var relationalDatabaseCreator = dbContext.Database.GetService(); + if (!dbContext.Database.CanConnect()) + { + relationalDatabaseCreator.Create(); + } + else + { + relationalDatabaseCreator.CreateTables(); + } + + seedTestData?.Invoke(dbContext); + } + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Testing/Helpers/FixtureFactoryHelper.cs b/src/DfE.CoreLibs.Testing/Helpers/FixtureFactoryHelper.cs new file mode 100644 index 0000000..3ffe30a --- /dev/null +++ b/src/DfE.CoreLibs.Testing/Helpers/FixtureFactoryHelper.cs @@ -0,0 +1,22 @@ +using AutoFixture; +using System.Diagnostics.CodeAnalysis; + +namespace DfE.CoreLibs.Testing.Helpers +{ + [ExcludeFromCodeCoverage] + public static class FixtureFactoryHelper + { + public static IFixture ConfigureFixtureFactory(Type[] customizations) + { + var fixture = new Fixture(); + + foreach (var customizationType in customizations) + { + var customization = (ICustomization)Activator.CreateInstance(customizationType)!; + fixture.Customize(customization); + } + + return fixture; + } + } +} diff --git a/src/DfE.CoreLibs.Testing/Mocks/Authentication/MockJwtBearerHandler.cs b/src/DfE.CoreLibs.Testing/Mocks/Authentication/MockJwtBearerHandler.cs new file mode 100644 index 0000000..ba6d133 --- /dev/null +++ b/src/DfE.CoreLibs.Testing/Mocks/Authentication/MockJwtBearerHandler.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +namespace DfE.CoreLibs.Testing.Mocks.Authentication +{ +#pragma warning disable CS0618 + [ExcludeFromCodeCoverage] + public class MockJwtBearerHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISystemClock clock, + IEnumerable claims) + : AuthenticationHandler(options, logger, encoder, clock) + { + protected override Task HandleAuthenticateAsync() + { + var identity = new ClaimsIdentity(claims, "mock"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "mock"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } + } +#pragma warning restore CS0618 + +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Testing/Mocks/WebApplicationFactory/CustomWebApplicationDbContextFactory.cs b/src/DfE.CoreLibs.Testing/Mocks/WebApplicationFactory/CustomWebApplicationDbContextFactory.cs new file mode 100644 index 0000000..0fddc4e --- /dev/null +++ b/src/DfE.CoreLibs.Testing/Mocks/WebApplicationFactory/CustomWebApplicationDbContextFactory.cs @@ -0,0 +1,78 @@ +using DfE.CoreLibs.Testing.Helpers; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using System.Data.Common; +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; + +namespace DfE.CoreLibs.Testing.Mocks.WebApplicationFactory +{ + [ExcludeFromCodeCoverage] + public class CustomWebApplicationDbContextFactory : WebApplicationFactory + where TProgram : class + { + public List? TestClaims { get; set; } = new(); + public Dictionary>? SeedData { get; set; } + public Action? ExternalServicesConfiguration { get; set; } + public Action? ExternalHttpClientConfiguration { get; set; } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + RemoveDbContextAndConnectionServices(services); + + var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + services.AddSingleton(connection); + + foreach (var entry in SeedData ?? new Dictionary>()) + { + var dbContextType = entry.Key; + var seedAction = entry.Value; + var createDbContextMethod = typeof(DbContextHelper).GetMethod(nameof(DbContextHelper.CreateDbContext)) + ?.MakeGenericMethod(dbContextType); + createDbContextMethod?.Invoke(null, new object[] { services, connection, seedAction }); + } + + ExternalServicesConfiguration?.Invoke(services); + services.AddSingleton>(sp => TestClaims ?? new()); + }); + + builder.UseEnvironment("Development"); + } + + protected override void ConfigureClient(HttpClient client) + { + ExternalHttpClientConfiguration?.Invoke(client); + base.ConfigureClient(client); + } + + public TDbContext GetDbContext() where TDbContext : DbContext + { + var scopeFactory = Services.GetRequiredService(); + var scope = scopeFactory.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + private static void RemoveDbContextAndConnectionServices(IServiceCollection services) + { + var dbContextDescriptors = services + .Where(d => d.ServiceType.IsGenericType && d.ServiceType.GetGenericTypeDefinition() == typeof(DbContextOptions<>)) + .ToList(); + foreach (var dbContextDescriptor in dbContextDescriptors) + { + services.Remove(dbContextDescriptor); + } + + var dbConnectionDescriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbConnection)); + if (dbConnectionDescriptor != null) + { + services.Remove(dbConnectionDescriptor); + } + } + } +} diff --git a/src/DfE.CoreLibs.Testing/Mocks/WebApplicationFactory/CustomWebApplicationFactory.cs b/src/DfE.CoreLibs.Testing/Mocks/WebApplicationFactory/CustomWebApplicationFactory.cs new file mode 100644 index 0000000..e170bee --- /dev/null +++ b/src/DfE.CoreLibs.Testing/Mocks/WebApplicationFactory/CustomWebApplicationFactory.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using System.Diagnostics.CodeAnalysis; +using System.Security.Claims; + +namespace DfE.CoreLibs.Testing.Mocks.WebApplicationFactory +{ + [ExcludeFromCodeCoverage] + public class CustomWebApplicationFactory : WebApplicationFactory + where TProgram : class + { + public List? TestClaims { get; set; } = []; + public Action? ExternalServicesConfiguration { get; set; } + public Action? ExternalHttpClientConfiguration { get; set; } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + ExternalServicesConfiguration?.Invoke(services); + + services.AddSingleton>(sp => TestClaims ?? []); + }); + + builder.UseEnvironment("Development"); + } + + protected override void ConfigureClient(HttpClient client) + { + ExternalHttpClientConfiguration?.Invoke(client); + + base.ConfigureClient(client); + } + } +} diff --git a/src/DfE.CoreLibs.Testing/readme.md b/src/DfE.CoreLibs.Testing/readme.md new file mode 100644 index 0000000..ddf80ec --- /dev/null +++ b/src/DfE.CoreLibs.Testing/readme.md @@ -0,0 +1,87 @@ +# DfE.CoreLibs.Testing + +Designed to enhance test automation, this library provides essential utilities and frameworks for unit and integration testing in .NET. It includes tools for mocking, assertions, and common test scenarios, helping developers write cleaner, more efficient tests that improve the overall quality and stability of their applications. + +## Installation + +To install the DfE.CoreLibs.Testing Library, use the following command in your .NET project: + +```sh +dotnet add package DfE.CoreLibs.Testing +``` + +## Usage + +### Usage of Customization Attributes + +In your tests, you can use `CustomAutoData` to easily inject customizations like `AutoMapperCustomization`, this Customization scans your assembly for profiles and registers them automatically. + +```csharp + [Theory] + [CustomAutoData( + typeof(PrincipalCustomization), + typeof(SchoolCustomization), + typeof(AutoMapperCustomization))] + public async Task Handle_ShouldReturnMemberOfParliament_WhenSchoolExists( + [Frozen] ISchoolRepository mockSchoolRepository, + [Frozen] ICacheService mockCacheService, + GetPrincipalBySchoolQueryHandler handler, + GetPrincipalBySchoolQuery query, + Domain.Entities.Schools.School school, + IFixture fixture) + { + // Arrange + var expectedMp = fixture.Customize(new PrincipalCustomization() + { + FirstName = school.NameDetails.NameListAs!.Split(",")[1].Trim(), + LastName = school.NameDetails.NameListAs.Split(",")[0].Trim(), + SchoolName = school.SchoolName, + }).Create(); + + // Act + var result = await handler.Handle(query, default); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedMp.FirstName, result.FirstName); + Assert.Equal(expectedMp.LastName, result.LastName); + } +``` + + +### CustomWebApplicationDbContextFactory + +You can create custom factory customizations and use them like the following example, which demonstrates testing with a custom web application factory: + +```csharp + [Theory] + [CustomAutoData(typeof(CustomWebApplicationDbContextFactoryCustomization))] + public async Task GetPrincipalBySchoolAsync_ShouldReturnPrincipal_WhenSchoolExists( + CustomWebApplicationDbContextFactory factory, + ISchoolsClient schoolsClient) + { + factory.TestClaims = [new Claim(ClaimTypes.Role, "API.Read")]; + + // Arrange + var dbContext = factory.GetDbContext(); + + await dbContext.Schools + .Where(x => x.SchoolName == "Test School 1") + .ExecuteUpdateAsync(x => x.SetProperty(p => p.SchoolName, "NewSchoolName")); + + var schoolName = Uri.EscapeDataString("NewSchoolName"); + + // Act + var result = await schoolsClient.GetPrincipalBySchoolAsync(schoolName); + + // Assert + Assert.NotNull(result); + Assert.Equal("NewSchoolName", result.SchoolName); + } +``` + +This demonstrates how you can test your queries and database context interactions using a custom web application factory and test claims. + +For detailed examples, please refer to the [GitHub DDD-CA-Template repository](https://github.com/DFE-Digital/rsd-ddd-clean-architecture). + +* * * \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/DfE.CoreLibs.Utilities.csproj b/src/DfE.CoreLibs.Utilities/DfE.CoreLibs.Utilities.csproj new file mode 100644 index 0000000..19189fc --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/DfE.CoreLibs.Utilities.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + readme.md + DfE.CoreLibs.Utilities + A library offering HTTP-related utilities and middleware, for building robust web services. + DFE-Digital + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/Extensions/BooleanExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/BooleanExtensions.cs new file mode 100644 index 0000000..d6eda25 --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/Extensions/BooleanExtensions.cs @@ -0,0 +1,61 @@ +namespace DfE.CoreLibs.Utilities.Extensions; + +/// +/// The boolean extensions. +/// +public static class BooleanExtensions +{ + /// + /// Returns a boolean value as Yes or No strings + /// + /// + /// + public static string ToYesNoString(this bool value) + { + return value ? "Yes" : "No"; + } + + /// + /// Returns a boolean value as Yes or No strings + /// + /// + /// + public static string ToYesNoString(this bool? value) + { + if (value.HasValue) + { + return value.Value ? "Yes" : "No"; + } + + return ""; + } + + /// + /// Returns a boolean value as Deficit or Surplus strings + /// TRUE = Deficit + /// FALSE = Surplus + /// + /// + /// + public static string ToSurplusDeficitString(this bool value) + { + return value ? "Deficit" : "Surplus"; + } + + /// + /// Returns a boolean value as Deficit or Surplus strings + /// TRUE = Deficit + /// FALSE = Surplus + /// + /// + /// + public static string ToSurplusDeficitString(this bool? value) + { + if (value.HasValue) + { + return value.Value ? "Deficit" : "Surplus"; + } + + return string.Empty; + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/Extensions/DateTimeExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/DateTimeExtensions.cs new file mode 100644 index 0000000..f626648 --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/Extensions/DateTimeExtensions.cs @@ -0,0 +1,67 @@ +namespace DfE.CoreLibs.Utilities.Extensions; + +/// +/// The date time extensions. +/// +public static class DateTimeExtensions +{ + /// + /// Returns a date in standard UK format (dd/MM/yyyy) + /// + /// + /// + public static string ToUkDateString(this DateTime dateTime) + { + return dateTime.ToString("dd/MM/yyyy"); + } + + /// + /// Returns a date string of either 'd MMMM yyyy' or 'dddd d MMMM yyyy' if includeDayOfWeek is true. + /// + /// + /// + /// + public static string ToDateString(this DateTime? dateTime, bool includeDayOfWeek = false) + { + if (!dateTime.HasValue) + { + return string.Empty; + } + + return ToDateString(dateTime.Value, includeDayOfWeek); + } + + /// + /// Returns a date string of either 'd MMMM yyyy' or 'dddd d MMMM yyyy' if includeDayOfWeek is true. + /// + /// + /// + /// + public static string ToDateString(this DateTime dateTime, bool includeDayOfWeek = false) + { + if (includeDayOfWeek) + { + return dateTime.ToString("dddd d MMMM yyyy"); + } + + return dateTime.ToString("d MMMM yyyy"); + } + + /// + /// Returns the date that is the first of the month, from the given month + /// + /// + /// + /// + public static DateTime FirstOfMonth(this DateTime thisMonth, int monthsToAdd = 0) + { + int month = (thisMonth.Month + monthsToAdd) % 12; + if (month == 0) + { + month = 12; + } + + int yearsToAdd = (thisMonth.Month + monthsToAdd - 1) / 12; + return new DateTime(thisMonth.Year + yearsToAdd, month, 1); + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/Extensions/DecimalExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/DecimalExtensions.cs new file mode 100644 index 0000000..f429210 --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/Extensions/DecimalExtensions.cs @@ -0,0 +1,55 @@ +using System.Globalization; + +namespace DfE.CoreLibs.Utilities.Extensions; + +/// +/// The decimal extensions. +/// +public static class DecimalExtensions +{ + /// + /// Returns a decimal as a en-GB money string. + /// If includePoundSign = true then a '£' will be prefixed and commas will be used as a thousands separator + /// + /// + /// + /// + public static string ToMoneyString(this decimal value, bool includePoundSign = false) + { + return string.Format(CultureInfo.CreateSpecificCulture("en-GB"), includePoundSign ? "{0:C2}" : "{0:F2}", value); + } + + /// + /// Returns a decimal as a en-GB money string. + /// If includePoundSign = true then a '£' will be prefixed and commas will be used as a thousands separator + /// + /// + /// + /// + public static string ToMoneyString(this decimal? value, bool includePoundSign = false) + { + string format = includePoundSign ? "{0:C2}" : "{0:F2}"; + return value.HasValue ? string.Format(CultureInfo.CreateSpecificCulture("en-GB"), format, value) : string.Empty; + } + + /// + /// Returns a decimal value as a percentage + /// + /// + /// + public static string ToPercentage(this decimal value) + { + return $"{value:G0}%"; + } + + /// + /// Applies a general format of 'G0' to a decimal and returns the result as a string + /// If decimal is null then string.empty is returned. + /// + /// + /// + public static string ToSafeString(this decimal? value) + { + return value.HasValue ? value.Value.ToString("G0") : string.Empty; + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/Extensions/EnumExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..bff4568 --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/Extensions/EnumExtensions.cs @@ -0,0 +1,33 @@ +using System.ComponentModel; +using System.Reflection; + +namespace DfE.CoreLibs.Utilities.Extensions; + +/// +/// The enum extensions. +/// +public static class EnumExtensions +{ + /// + /// Returns the description associated with an Enum + /// + /// + /// + /// + public static string ToDescription(this T? source) + { + if (source == null) + { + return string.Empty; + } + string description = source.ToString(); + + FieldInfo fi = source.GetType().GetField(description); + + DescriptionAttribute[] attributes = (DescriptionAttribute[])fi?.GetCustomAttributes(typeof(DescriptionAttribute), false); + + return attributes is { Length: > 0 } + ? attributes[0].Description + : description; + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/Extensions/IntegerExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/IntegerExtensions.cs new file mode 100644 index 0000000..dcd4f2b --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/Extensions/IntegerExtensions.cs @@ -0,0 +1,28 @@ +namespace DfE.CoreLibs.Utilities.Extensions; + +/// +/// The integer extensions. +/// +public static class IntegerExtensions +{ + /// + /// Returns string representing an integer value as a percentage of. + /// + /// + /// + /// + public static string AsPercentageOf(this int? part, int? whole) + { + if (!whole.HasValue || !part.HasValue) + { + return ""; + } + + if (whole.Value == 0) + { + throw new ArgumentOutOfRangeException(nameof(whole), "The value of the whole must be greater than zero"); + } + + return $"{100d / whole * part:F0}%"; + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/Extensions/ObjectExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/ObjectExtensions.cs new file mode 100644 index 0000000..1f0b079 --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/Extensions/ObjectExtensions.cs @@ -0,0 +1,18 @@ +namespace DfE.CoreLibs.Utilities.Extensions; + +/// +/// The object extensions. +/// +public static class ObjectExtensions +{ + /// + /// Returns to string from an object if the instance is not null. Returns default if the object isnull + /// + /// + /// + /// + public static string? ToStringOrDefault(this object? obj, string? @default = null) + { + return obj?.ToString() ?? @default; + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs b/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs new file mode 100644 index 0000000..14e052c --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/Extensions/StringExtensions.cs @@ -0,0 +1,171 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace DfE.CoreLibs.Utilities.Extensions; + +/// +/// The string extensions. +/// +public static class StringExtensions +{ + /// + /// Splits a string up by detecting pascal casing and inserting a space before each capital letter excluding the first. + /// + /// + /// + /// + public static string SplitPascalCase(this T source) + { + return source == null + ? string.Empty + : Regex.Replace(source.ToString() ?? string.Empty, "[A-Z]", " $0", RegexOptions.None, + TimeSpan.FromSeconds(1)).Trim(); + } + + /// + /// Converts a string to sentence case, ignoring acronyms. + /// + /// The string to convert. + /// Whether or not Acronyms should be detected and ignored. Defaults to true. + /// A string + public static string ToSentenceCase(this string input, bool ignoreAcronyms = true) + { + if (string.IsNullOrWhiteSpace(input)) + { + return input; + } + + string[] words = input.Split(' '); + bool firstNonAcronymCapitalized = false; + + for (int i = 0; i < words.Length; i++) + { + // Not an acronym + if (ignoreAcronyms is false || IsAcronym(words[i]) is false) + { + words[i] = words[i].ToLowerInvariant(); + + if (firstNonAcronymCapitalized is false) + { + words[i] = char.ToUpperInvariant(words[i][0]) + words[i][1..]; + firstNonAcronymCapitalized = true; + } + } + } + + return string.Join(' ', words); + } + + /// + /// Extension method that converts "Yes" and "No" strings to bool values. + /// "Yes" is converted to true and "No" is converted to false. + /// The comparison is case-insensitive. + /// If the input string does not match "Yes" or "No", an ArgumentException will be thrown. + /// + public static bool ToBool(this string str) + { + return str.ToLower() switch + { + "yes" => true, + "no" => false, + _ => throw new ArgumentException("The string must be either 'Yes' or 'No'.") + }; + } + + /// + /// Returns true/false if the word is detected as being an acronym + /// + /// + /// + public static bool IsAcronym(string word) + { + return !string.IsNullOrEmpty(word) && word.Length >= 2 + && char.IsUpper(word[0]) + && char.IsUpper(word[^1]); + } + + /// + /// Checks a string to see if it contains exclusively capital letters + /// + /// The string to check. + /// A string + public static bool IsAllCaps(string word) + { + return !string.IsNullOrEmpty(word) && word.All(char.IsUpper); + } + + /// + /// Applies title casing to a string + /// + /// + /// + public static string ToTitleCase(this string str) + { + var textInfo = CultureInfo.CurrentCulture.TextInfo; + return textInfo.ToTitleCase(str.ToLower()); + } + + /// + /// Returns true if IsNullOrWhiteSpace == true + /// + /// + /// + public static bool IsEmpty(this string input) + { + return string.IsNullOrWhiteSpace(input); + } + + /// + /// Returns true if IsEmpty == false. + /// + /// + /// + public static bool IsPresent(this string input) + { + return input.IsEmpty() is false; + } + + /// + /// Removes spaces throughout a string AND returns ToLowerInvariant on the string + /// + /// + /// + public static string SquishToLower(this string input) + { + return input.Replace(" ", "").ToLowerInvariant(); + } + + + /// + /// Converts the first character of the string to uppercase, and returns the whole string + /// + /// The input. + /// A string. + public static string ToFirstUpper(this string input) + { + string lowered = input.ToLower(); + return $"{char.ToUpper(lowered[0])}{lowered[1..]}"; + } + + /// + /// Hyphenates a string. + /// + /// The str. + /// A string. + public static string ToHyphenated(this string str) + { + var whitespaceRegex = new Regex(@"\s+", RegexOptions.None, TimeSpan.FromSeconds(1)); + return whitespaceRegex.Replace(str, "-"); + } + + /// + /// Removes non alphanumeric and white space characters from a string. + /// + /// The str. + /// A string. + public static string RemoveNonAlphanumericOrWhiteSpace(this string str) + { + var notAlphanumericWhiteSpaceOrHyphen = new Regex(@"[^\w\s-]", RegexOptions.None, TimeSpan.FromSeconds(1)); + return notAlphanumericWhiteSpaceOrHyphen.Replace(str, string.Empty); + } +} \ No newline at end of file diff --git a/src/DfE.CoreLibs.Utilities/readme.md b/src/DfE.CoreLibs.Utilities/readme.md new file mode 100644 index 0000000..de2cb54 --- /dev/null +++ b/src/DfE.CoreLibs.Utilities/readme.md @@ -0,0 +1,10 @@ +# # DfE.CoreLibs.Utilities + +## What does this do ? +This package includes a set of utilities and extension methods. + +## How to use it + +1) Install the package. +2) Import the DfE.CoreLibs.Utilities namespace wherever you need to use one of the extensions or utilities. +3) Call the extension or utility you need. \ No newline at end of file diff --git a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/DfE.CoreLibs.AsyncProcessing.Tests.csproj b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/DfE.CoreLibs.AsyncProcessing.Tests.csproj new file mode 100644 index 0000000..1e1cfcd --- /dev/null +++ b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/DfE.CoreLibs.AsyncProcessing.Tests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs new file mode 100644 index 0000000..5d86e91 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.AsyncProcessing.Tests/Services/BackgroundServiceFactoryTests.cs @@ -0,0 +1,109 @@ +using DfE.CoreLibs.AsyncProcessing.Interfaces; +using DfE.CoreLibs.AsyncProcessing.Services; +using DfE.CoreLibs.Testing.AutoFixture.Attributes; +using MediatR; +using NSubstitute; + +namespace DfE.CoreLibs.AsyncProcessing.Tests.Services +{ + public class BackgroundServiceFactoryTests + { + private readonly IMediator _mediator; + private readonly BackgroundServiceFactory _backgroundServiceFactory; + + public BackgroundServiceFactoryTests() + { + _mediator = Substitute.For(); + _backgroundServiceFactory = new BackgroundServiceFactory(_mediator); + } + + [Theory] + [CustomAutoData] + public async Task EnqueueTask_ShouldProcessTaskInQueue( + Func> taskFunc, + IBackgroundServiceEvent eventMock) + { + // Arrange + Func eventFactory = _ => eventMock; + + var semaphore = new SemaphoreSlim(0, 1); + bool taskExecuted = false; + + Func> wrappedTaskFunc = async () => + { + taskExecuted = true; + semaphore.Release(); + return await taskFunc(); + }; + + // Act + _backgroundServiceFactory.EnqueueTask(wrappedTaskFunc, eventFactory); + + // Ensure the task gets processed + await semaphore.WaitAsync(1000); + + // Assert + Assert.True(taskExecuted, "Task in the queue should have been processed."); + } + + [Theory] + [CustomAutoData] + public async Task EnqueueTask_ShouldPublishEvent_WhenEventFactoryIsProvided( + int taskResult, + IBackgroundServiceEvent eventMock) + { + // Arrange + Func eventFactory = _ => eventMock; + + // Act + _backgroundServiceFactory.EnqueueTask(() => Task.FromResult(taskResult), eventFactory); + + // Trigger processing + await Task.Delay(100); + + // Assert + await _mediator.Received(1).Publish(eventMock, Arg.Any()); + } + + [Theory] + [CustomAutoData] + public async Task StartProcessingQueue_ShouldProcessTasksSequentially( + Func> taskFunc, + IBackgroundServiceEvent eventMock) + { + // Arrange + Func eventFactory = _ => eventMock; + + int taskCount = 0; + Func> wrappedTaskFunc = async () => + { + Interlocked.Increment(ref taskCount); + return await taskFunc(); + }; + + _backgroundServiceFactory.EnqueueTask(wrappedTaskFunc, eventFactory); + _backgroundServiceFactory.EnqueueTask(wrappedTaskFunc, eventFactory); + + // Act + await Task.Delay(100); // Allow time for processing + + // Assert + Assert.Equal(2, taskCount); + } + + [Fact] + public async Task ExecuteAsync_ShouldStopProcessing_WhenCancellationRequested() + { + // Arrange + using var cts = new CancellationTokenSource(); + var task = _backgroundServiceFactory.StartAsync(cts.Token); + + // Act + await cts.CancelAsync(); + await task; + + // Assert + Assert.True(task.IsCompletedSuccessfully); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Caching.Tests/DfE.CoreLibs.Caching.Tests.csproj b/src/Tests/DfE.CoreLibs.Caching.Tests/DfE.CoreLibs.Caching.Tests.csproj new file mode 100644 index 0000000..67fe5d6 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Caching.Tests/DfE.CoreLibs.Caching.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/DfE.CoreLibs.Caching.Tests/Helpers/CacheKeyHelperTests.cs b/src/Tests/DfE.CoreLibs.Caching.Tests/Helpers/CacheKeyHelperTests.cs new file mode 100644 index 0000000..4cf2402 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Caching.Tests/Helpers/CacheKeyHelperTests.cs @@ -0,0 +1,66 @@ +using DfE.CoreLibs.Caching.Helpers; + +namespace DfE.CoreLibs.Caching.Tests.Helpers +{ + public class CacheKeyHelperTests + { + [Fact] + public void GenerateHashedCacheKey_ShouldThrowArgumentException_WhenInputIsNullOrEmpty() + { + // Arrange, Act & Assert + Assert.Throws(() => CacheKeyHelper.GenerateHashedCacheKey(string.Empty)); + Assert.Throws(() => CacheKeyHelper.GenerateHashedCacheKey((string)null!)); + } + + [Fact] + public void GenerateHashedCacheKey_ShouldReturnConsistentHash_WhenGivenSameInput() + { + // Arrange + var input = "test-input"; + + // Act + var result1 = CacheKeyHelper.GenerateHashedCacheKey(input); + var result2 = CacheKeyHelper.GenerateHashedCacheKey(input); + + // Assert + Assert.Equal(result1, result2); + } + + [Fact] + public void GenerateHashedCacheKey_ShouldThrowArgumentException_WhenInputCollectionIsNullOrEmpty() + { + // Arrange, Act & Assert + Assert.Throws(() => CacheKeyHelper.GenerateHashedCacheKey((IEnumerable)null!)); + Assert.Throws(() => CacheKeyHelper.GenerateHashedCacheKey(new List())); + } + + [Fact] + public void GenerateHashedCacheKey_ShouldReturnDifferentHashes_ForDifferentInputs() + { + // Arrange + var input1 = "input-1"; + var input2 = "input-2"; + + // Act + var result1 = CacheKeyHelper.GenerateHashedCacheKey(input1); + var result2 = CacheKeyHelper.GenerateHashedCacheKey(input2); + + // Assert + Assert.NotEqual(result1, result2); + } + + [Fact] + public void GenerateHashedCacheKey_ForCollection_ShouldReturnConsistentHash_WhenGivenSameInputs() + { + // Arrange + var inputs = new List { "input-1", "input-2", "input-3" }; + + // Act + var result1 = CacheKeyHelper.GenerateHashedCacheKey(inputs); + var result2 = CacheKeyHelper.GenerateHashedCacheKey(inputs); + + // Assert + Assert.Equal(result1, result2); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Caching.Tests/Services/MemoryCacheServiceTests.cs b/src/Tests/DfE.CoreLibs.Caching.Tests/Services/MemoryCacheServiceTests.cs new file mode 100644 index 0000000..fe53004 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Caching.Tests/Services/MemoryCacheServiceTests.cs @@ -0,0 +1,104 @@ +using AutoFixture; +using DfE.CoreLibs.Caching.Services; +using DfE.CoreLibs.Caching.Settings; +using DfE.CoreLibs.Testing.AutoFixture.Attributes; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; + +namespace DfE.CoreLibs.Caching.Tests.Services +{ + public class MemoryCacheServiceTests + { + private readonly IFixture _fixture; + private readonly IMemoryCache _memoryCache; + private readonly ILogger _logger; + private readonly IOptions _options; + private readonly MemoryCacheService _cacheService; + private readonly MemoryCacheSettings _cacheSettings; + + public MemoryCacheServiceTests() + { + _fixture = new Fixture(); + _memoryCache = Substitute.For(); + _logger = Substitute.For>(); + + _cacheSettings = new MemoryCacheSettings { DefaultDurationInSeconds = 5, Durations = new Dictionary { { "TestMethod", 10 } } }; + var settings = new CacheSettings { Memory = _cacheSettings }; + _options = Options.Create(settings); + + _cacheService = new MemoryCacheService(_memoryCache, _logger, _options); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldReturnCachedValue_WhenCacheKeyExists(string cacheKey, string methodName) + { + // Arrange + var cachedValue = _fixture.Create(); + _memoryCache.TryGetValue(cacheKey, out Arg.Any()!).Returns(x => + { + x[1] = cachedValue; + return true; + }); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(cachedValue), methodName); + + // Assert + Assert.Equal(cachedValue, result); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Cache hit for key: {cacheKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public async Task GetOrAddAsync_ShouldFetchAndCacheValue_WhenCacheKeyDoesNotExist(string cacheKey, string methodName) + { + // Arrange + var expectedValue = _fixture.Create(); + _memoryCache.TryGetValue(cacheKey, out Arg.Any()).Returns(false); + + // Act + var result = await _cacheService.GetOrAddAsync(cacheKey, () => Task.FromResult(expectedValue), methodName); + + // Assert + Assert.Equal(expectedValue, result); + _memoryCache.Received(1).Set(cacheKey, expectedValue, TimeSpan.FromSeconds(_cacheSettings.DefaultDurationInSeconds)); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Cache miss for key: {cacheKey}")), + Arg.Any(), + Arg.Any>()!); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Cached result for key: {cacheKey}")), + Arg.Any(), + Arg.Any>()!); + } + + [Theory] + [CustomAutoData()] + public void Remove_ShouldRemoveValueFromCache_WhenCalled(string cacheKey) + { + // Act + _cacheService.Remove(cacheKey); + + // Assert + _memoryCache.Received(1).Remove(cacheKey); + _logger.Received(1).Log( + LogLevel.Information, + Arg.Any(), + Arg.Is(v => v.ToString()!.Contains($"Cache removed for key: {cacheKey}")), + Arg.Any(), + Arg.Any>()!); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Http.Tests/DfE.CoreLibs.Http.Tests.csproj b/src/Tests/DfE.CoreLibs.Http.Tests/DfE.CoreLibs.Http.Tests.csproj new file mode 100644 index 0000000..4a57d81 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Http.Tests/DfE.CoreLibs.Http.Tests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/DfE.CoreLibs.Http.Tests/Middlewares/CorrelationIdMiddlewareTests.cs b/src/Tests/DfE.CoreLibs.Http.Tests/Middlewares/CorrelationIdMiddlewareTests.cs new file mode 100644 index 0000000..f43b423 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Http.Tests/Middlewares/CorrelationIdMiddlewareTests.cs @@ -0,0 +1,119 @@ +using AutoFixture; +using AutoFixture.Xunit2; +using DfE.CoreLibs.Http.Interfaces; +using DfE.CoreLibs.Http.Middlewares.CorrelationId; +using DfE.CoreLibs.Testing.AutoFixture.Attributes; +using DfE.CoreLibs.Testing.AutoFixture.Customizations; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.Net; + +namespace DfE.CoreLibs.Http.Tests.Middlewares +{ + public class CorrelationIdMiddlewareTests + { + private readonly RequestDelegate _nextDelegate; + private readonly ILogger _logger; + private readonly ICorrelationContext _correlationContext; + private readonly CorrelationIdMiddleware _middleware; + + public CorrelationIdMiddlewareTests() + { + _nextDelegate = Substitute.For(); + _logger = Substitute.For>(); + _correlationContext = Substitute.For(); + _middleware = new CorrelationIdMiddleware(_nextDelegate, _logger); + } + + [Theory] + [CustomAutoData(typeof(HttpContextCustomization))] + public async Task Invoke_ShouldSetNewCorrelationId_WhenHeaderNotPresent( + HttpContext context) + { + // Arrange + context.Request.Headers.Remove(Keys.HeaderKey); + context.Response.Body = new System.IO.MemoryStream(); + + _nextDelegate.Invoke(context).Returns(Task.CompletedTask); + + // Act + await _middleware.Invoke(context, _correlationContext); + + // Assert + Assert.True(Guid.TryParse(context.Response.Headers[Keys.HeaderKey], out var correlationId)); + Assert.NotEqual(Guid.Empty, correlationId); + _correlationContext.Received(1).SetContext(Arg.Any()); + } + + [Theory] + [CustomAutoData(typeof(HttpContextCustomization))] + public async Task Invoke_ShouldRetainExistingCorrelationId_WhenHeaderPresent([Frozen] IFixture fixture, + DefaultHttpContext context) + { + // Arrange + var existingCorrelationId = fixture.Create(); + context.Request.Headers[Keys.HeaderKey] = existingCorrelationId.ToString(); + context.Response.Body = new System.IO.MemoryStream(); + + _nextDelegate.Invoke(context).Returns(Task.CompletedTask); + + // Act + await _middleware.Invoke(context, _correlationContext); + + // Assert + Assert.Equal(existingCorrelationId.ToString(), context.Response.Headers[Keys.HeaderKey]); + _correlationContext.Received(1).SetContext(existingCorrelationId); + } + + [Theory] + [CustomAutoData(typeof(HttpContextCustomization))] + public async Task Invoke_ShouldReturnBadRequest_WhenCorrelationIdIsEmpty(DefaultHttpContext context) + { + // Arrange + context.Request.Headers[Keys.HeaderKey] = Guid.Empty.ToString(); + context.Response.Body = new System.IO.MemoryStream(); + + _nextDelegate.Invoke(context).Returns(Task.CompletedTask); + + // Act + await _middleware.Invoke(context, _correlationContext); + + // Assert + Assert.Equal((int)HttpStatusCode.BadRequest, context.Response.StatusCode); + Assert.Contains("Bad Request", ReadResponseBody(context)); + _correlationContext.DidNotReceive().SetContext(Arg.Any()); + } + + [Theory] + [CustomAutoData(typeof(HttpContextCustomization))] + public async Task Invoke_ShouldLogNewGuidWarning_WhenHeaderCannotBeParsed(DefaultHttpContext context) + { + // Arrange + context.Request.Headers[Keys.HeaderKey] = "invalid-guid"; + context.Response.Body = new System.IO.MemoryStream(); + + _nextDelegate.Invoke(context).Returns(Task.CompletedTask); + + // Act + await _middleware.Invoke(context, _correlationContext); + + // Assert + _logger.Received(1).Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(v => v.ToString().Contains("Detected header x-correlationId, but value cannot be parsed to a GUID")), + Arg.Any(), + Arg.Any>()!); + + _correlationContext.Received(1).SetContext(Arg.Any()); + } + + private static string ReadResponseBody(HttpContext context) + { + context.Response.Body.Seek(0, System.IO.SeekOrigin.Begin); + using var reader = new System.IO.StreamReader(context.Response.Body); + return reader.ReadToEnd(); + } + } +} diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/DfE.CoreLibs.Utilities.Tests.csproj b/src/Tests/DfE.CoreLibs.Utilities.Tests/DfE.CoreLibs.Utilities.Tests.csproj new file mode 100644 index 0000000..cdceed7 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/DfE.CoreLibs.Utilities.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/BooleanExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/BooleanExtensionsTests.cs new file mode 100644 index 0000000..0094c87 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/BooleanExtensionsTests.cs @@ -0,0 +1,76 @@ +using DfE.CoreLibs.Utilities.Extensions; +using FluentAssertions; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +/// +/// The boolean extensions tests. +/// +public class BooleanExtensionsTests +{ + [Fact] + public void ToYesNoString_WithFalse_ReturnsNo() + { + false.ToYesNoString().Should().Be("No"); + } + + [Fact] + public void ToYesNoString_WithTrue_ReturnsYes() + { + true.ToYesNoString().Should().Be("Yes"); + } + + [Fact] + public void ToYesNoString_WithNullableFalse_ReturnsNo() + { + bool? input = false; + input.ToYesNoString().Should().Be("No"); + } + + [Fact] + public void ToYesNoString_WithNullableTrue_ReturnsYes() + { + bool? input = true; + input.ToYesNoString().Should().Be("Yes"); + } + + [Fact] + public void ToYesNoString_WithNullableBool_ReturnsEmptyString() + { + bool? input = null; + input.ToYesNoString().Should().Be(string.Empty); + } + + [Fact] + public void ToSurplusDeficitString_WithFalse_ReturnsSurplus() + { + false.ToSurplusDeficitString().Should().Be("Surplus"); + } + + [Fact] + public void ToSurplusDeficitString_WithTrue_ReturnsDeficit() + { + true.ToSurplusDeficitString().Should().Be("Deficit"); + } + + [Fact] + public void ToSurplusDeficitString_WithNullableFalse_ReturnsSurplus() + { + bool? input = false; + input.ToSurplusDeficitString().Should().Be("Surplus"); + } + + [Fact] + public void ToSurplusDeficitString_WithNullableTrue_ReturnsDeficit() + { + bool? input = true; + input.ToSurplusDeficitString().Should().Be("Deficit"); + } + + [Fact] + public void ToSurplusDeficitString_WithNullableBool_ReturnsEmptyString() + { + bool? input = null; + input.ToSurplusDeficitString().Should().Be(string.Empty); + } +} \ No newline at end of file diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/DateTimeExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/DateTimeExtensionsTests.cs new file mode 100644 index 0000000..527558b --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/DateTimeExtensionsTests.cs @@ -0,0 +1,70 @@ +using DfE.CoreLibs.Utilities.Extensions; +using FluentAssertions; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +/// +/// The date time extensions tests. +/// +public class DateTimeExtensionsTests +{ + public static IEnumerable DataForFirstOfMonthTests => + new List + { + new object[] { new DateTime(2021, 10, 31), 6, new DateTime(2022, 4, 1) }, + new object[] { new DateTime(2021, 1, 1), 0, new DateTime(2021, 1, 1) }, + new object[] { new DateTime(2020, 2, 29), 12, new DateTime(2021, 2, 1) }, + new object[] { new DateTime(2020, 2, 29), -1, new DateTime(2020, 1, 1) }, + new object[] { new DateTime(2020, 12, 29), -1, new DateTime(2020, 11, 1) }, + new object[] { new DateTime(2020, 12, 29), 3, new DateTime(2021, 3, 1) }, + new object[] { new DateTime(2020, 12, 29), 0, new DateTime(2020, 12, 1) }, + new object[] { new DateTime(2020, 11, 25), 13, new DateTime(2021, 12, 1) } + + }; + + [Theory] + [InlineData(2020, 12, 31, "31/12/2020")] + [InlineData(1689, 4, 3, "03/04/1689")] + public void ToUkDateString_ReturnsFormattedDate(int year, int month, int day, string output) + { + new DateTime(year, month, day).ToUkDateString().Should().Be(output); + } + + [Theory] + [InlineData(2020, 12, 31, false, "31 December 2020")] + [InlineData(1689, 4, 3, false, "3 April 1689")] + [InlineData(2021, 11, 1, true, "Monday 1 November 2021")] + [InlineData(2020, 2, 29, true, "Saturday 29 February 2020")] + public void ToDateString_ReturnsFormattedDate(int year, int month, int day, bool includeDayOfWeek, string output) + { + new DateTime(year, month, day).ToDateString(includeDayOfWeek).Should().Be(output); + } + + [Theory] + [MemberData(nameof(DataForFirstOfMonthTests))] + public void FirstOfMonth_ReturnsCorrectDateTime(DateTime input, int monthsToAdd, DateTime expected) + { + input.FirstOfMonth(monthsToAdd).Should().Be(expected); + } + + + [Theory] + [InlineData(2020, 12, 31, false, "31 December 2020")] + [InlineData(1689, 4, 3, false, "3 April 1689")] + [InlineData(2021, 11, 1, true, "Monday 1 November 2021")] + [InlineData(2020, 2, 29, true, "Saturday 29 February 2020")] + public void ToDateString_WithNullableDate_ReturnsFormattedDate(int year, int month, int day, bool includeDayOfWeek, string output) + { + DateTime? input = new DateTime(year, month, day); + input.ToDateString(includeDayOfWeek).Should().Be(output); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ToDateString_WithNullDate_ReturnsEmptyString(bool includeDayOfWeek) + { + DateTime? input = null; + input.ToDateString(includeDayOfWeek).Should().Be(string.Empty); + } +} \ No newline at end of file diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/DecimalExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/DecimalExtensionsTests.cs new file mode 100644 index 0000000..863a62e --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/DecimalExtensionsTests.cs @@ -0,0 +1,91 @@ +using DfE.CoreLibs.Utilities.Extensions; +using FluentAssertions; +using FluentAssertions.Execution; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +public class DecimalExtensionsTests +{ + [Fact] + public void ToPercentage_Returns_PercentageString() + { + var input = new decimal(1.5); + input.ToPercentage().Should().Be("1.5%"); + } + + + [Theory] + [InlineData("1100", "1100")] + [InlineData("1100.1", "1100.1")] + [InlineData("1100.100", "1100.1")] + [InlineData("1100.1000", "1100.1")] + [InlineData("1100.1001", "1100.1001")] + [InlineData("1100.1001000", "1100.1001")] + public void ToSafeString_Formats_Decimals_Correctly(string inputDecimal, string expectation) + { + decimal? i = Convert.ToDecimal(inputDecimal); + i.ToSafeString().Should().Be(expectation); + } + + [Fact] + public void ToSafeString_With_Null_Decimal_Returns_EmptyString() + { + decimal? input = null; + input.ToSafeString().Should().Be(string.Empty); + } + + [Theory] + [InlineData("1100", false, "1100.00")] + [InlineData("1100", true, "£1,100.00")] + [InlineData("1100.1", false, "1100.10")] + [InlineData("1100.1", true, "£1,100.10")] + [InlineData("1100.100", false, "1100.10")] + [InlineData("1100.100", true, "£1,100.10")] + [InlineData("1100.1000", false, "1100.10")] + [InlineData("1100.1000", true, "£1,100.10")] + [InlineData("1100.1001", false, "1100.10")] + [InlineData("1100.1001", true, "£1,100.10")] + [InlineData("1100.1001000", false, "1100.10")] + [InlineData("1100.1001000", true, "£1,100.10")] + + public void ToMoneyString_With_NullableDecimal_Formats_String_Correctly(string inputDecimal, bool includePoundSign, string expectation) + { + decimal? i = Convert.ToDecimal(inputDecimal); + using var scope = new AssertionScope(); + scope.AddReportable(nameof(inputDecimal), inputDecimal); + scope.AddReportable(nameof(includePoundSign), includePoundSign.ToString); + i.ToMoneyString(includePoundSign).Should().Be(expectation); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ToMoneyString_With_NullDecimal_Returns_EmptyString(bool includePoundSign) + { + decimal? i = null; + i.ToMoneyString(includePoundSign).Should().Be(string.Empty); + } + + [Theory] + [InlineData("1100", false, "1100.00")] + [InlineData("1100", true, "£1,100.00")] + [InlineData("1100.1", false, "1100.10")] + [InlineData("1100.1", true, "£1,100.10")] + [InlineData("1100.100", false, "1100.10")] + [InlineData("1100.100", true, "£1,100.10")] + [InlineData("1100.1000", false, "1100.10")] + [InlineData("1100.1000", true, "£1,100.10")] + [InlineData("1100.1001", false, "1100.10")] + [InlineData("1100.1001", true, "£1,100.10")] + [InlineData("1100.1001000", false, "1100.10")] + [InlineData("1100.1001000", true, "£1,100.10")] + + public void ToMoneyString_With_Decimal_Formats_String_Correctly(string inputDecimal, bool includePoundSign, string expectation) + { + decimal i = Convert.ToDecimal(inputDecimal); + using var scope = new AssertionScope(); + scope.AddReportable(nameof(inputDecimal), inputDecimal); + scope.AddReportable(nameof(includePoundSign), includePoundSign.ToString); + i.ToMoneyString(includePoundSign).Should().Be(expectation); + } +} diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/EnumExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/EnumExtensionsTests.cs new file mode 100644 index 0000000..7676fa5 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/EnumExtensionsTests.cs @@ -0,0 +1,26 @@ +using DfE.CoreLibs.Utilities.Extensions; +using FluentAssertions; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +/// +/// The enum extensions tests. +/// +public class EnumExtensionsTests +{ + [Theory] + [InlineData(ExampleEnum.DescriptionWithSpaces, "Regional Director for the region")] + [InlineData(ExampleEnum.DescriptionWithOneWord, "DescriptionWithOneWord")] + [InlineData(ExampleEnum.WhiteSpaceDescription, " ")] + [InlineData(ExampleEnum.NullDescription, default)] + [InlineData(ExampleEnum.EmptyDescription, "")] + public void Should_return_the_description_of_enum(ExampleEnum input, string expectedDescription) => + input.ToDescription().Should().Be(expectedDescription); + + [Theory] + [InlineData(ExampleEnum.NoDescription, "NoDescription")] + [InlineData(null, "")] + public void Should_return_the_name_of_enum_when_no_description(ExampleEnum? input, string expectedDescription) => + input.ToDescription().Should().Be(expectedDescription); + //} +} \ No newline at end of file diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/ExampleEnum.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/ExampleEnum.cs new file mode 100644 index 0000000..f4d9cb5 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/ExampleEnum.cs @@ -0,0 +1,16 @@ +using System.ComponentModel; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +/// +/// The example enum. +/// +public enum ExampleEnum +{ + [Description("Regional Director for the region")] DescriptionWithSpaces, + [Description("DescriptionWithOneWord")] DescriptionWithOneWord, + [Description("")] EmptyDescription, + [Description(" ")] WhiteSpaceDescription, + [Description(default)] NullDescription, + NoDescription, +} \ No newline at end of file diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/GlobalSuppressions.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/GlobalSuppressions.cs new file mode 100644 index 0000000..c7af867 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/GlobalSuppressions.cs @@ -0,0 +1,47 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("DocumentationHeader", "ConstFieldDocumentationHeader:The field must have a documentation header.", Justification = "", Scope = "member", Target = "~F:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.AcronymSentence")] +[assembly: SuppressMessage("DocumentationHeader", "ConstFieldDocumentationHeader:The field must have a documentation header.", Justification = "", Scope = "member", Target = "~F:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.LowerCase")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DateTimeExtensionsTests.FirstOfMonth_ReturnsCorrectDateTime(System.DateTime,System.Int32,System.DateTime)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DateTimeExtensionsTests.ToDateString_ReturnsFormattedDate(System.Int32,System.Int32,System.Int32,System.Boolean,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DateTimeExtensionsTests.ToDateString_WithNullableDate_ReturnsFormattedDate(System.Int32,System.Int32,System.Int32,System.Boolean,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DateTimeExtensionsTests.ToDateString_WithNullDate_ReturnsEmptyString(System.Boolean)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DateTimeExtensionsTests.ToUkDateString_ReturnsFormattedDate(System.Int32,System.Int32,System.Int32,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DecimalExtensionsTests.ToMoneyString_With_Decimal_Formats_String_Correctly(System.String,System.Boolean,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DecimalExtensionsTests.ToMoneyString_With_NullableDecimal_Formats_String_Correctly(System.String,System.Boolean,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DecimalExtensionsTests.ToMoneyString_With_NullDecimal_Returns_EmptyString(System.Boolean)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DecimalExtensionsTests.ToPercentage_Returns_PercentageString")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DecimalExtensionsTests.ToSafeString_Formats_Decimals_Correctly(System.String,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.DecimalExtensionsTests.ToSafeString_With_Null_Decimal_Returns_EmptyString")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.EnumExtensionsTests.Should_Return_EmptyString_When_Enum_Description_IsNullOrWhitespace(System.Nullable{DfE.CoreLibs.Utilities.Tests.ExampleEnum},System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.EnumExtensionsTests.Should_return_the_description_of_enum(DfE.CoreLibs.Utilities.Tests.ExampleEnum,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.EnumExtensionsTests.Should_return_the_name_of_enum_when_no_description(System.Nullable{DfE.CoreLibs.Utilities.Tests.ExampleEnum},System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.All_Acronyms_When_IgnoreAcronyms_False_SentenceCases_The_Original_String")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.All_Acronyms_When_IgnoreAcronyms_True_Returns_Original_String")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.IsAcronym_ShouldReturnExpectedResult(System.String,System.Boolean)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.IsAllCaps_ShouldReturnExpectedResult(System.String,System.Boolean)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.IsEmpty_Detects_IsNullOrWhitespace(System.String,System.Boolean)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.IsPresent_Returns_Opposite_Of_IsEmpty(System.String,System.Boolean)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.RemoveNonAlphanumericOrWhiteSpace_Strips_UnwantedCharacters")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.SplitPascalCase_Converts_String(System.String,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.SquishToLower_Removes_Spaces_And_Lowercases(System.String,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToBool_Should_ReturnFalse_When_StringIsNo")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToBool_Should_ReturnTrue_When_StringIsYes")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToBool_Should_ThrowArgumentException_When_StringIsNotYesOrNo")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToFirstUpper_Uppercases_First_Character_And_Returns_Whole_String")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToHyphenated_GivenString_ShouldConvert(System.String,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToSentenceCase_When_String_IsNullOrWhitespace_Returns_EmptyString(System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToSentenceCase_WithAcronyms_ReturnsCorrectly")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToSentenceCase_WithAcronyms_When_IgnoreAcronyms_IsFalse_Returns_SentenceCase")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToSentenceCase_WithAcronyms_When_IgnoreAcronyms_IsTrue_Returns_SentenceCase")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToSentenceCase_WithLowerCase_ReturnsCorrectly")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.StringExtensionsTests.ToTitleCase_GivenString_ShouldConvert(System.String,System.String)")] +[assembly: SuppressMessage("DocumentationHeader", "PropertyDocumentationHeader:The property must have a documentation header.", Justification = "", Scope = "member", Target = "~P:DfE.CoreLibs.Utilities.Tests.DateTimeExtensionsTests.DataForFirstOfMonthTests")] +[assembly: SuppressMessage("DocumentationHeader", "ClassDocumentationHeader:The class must have a documentation header.", Justification = "", Scope = "type", Target = "~T:DfE.CoreLibs.Utilities.Tests.DecimalExtensionsTests")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.ObjectExtensionTests.ToStringOrDefault_WithDefault_Returns_StringArgument")] +[assembly: SuppressMessage("DocumentationHeader", "MethodDocumentationHeader:The method must have a documentation header.", Justification = "", Scope = "member", Target = "~M:DfE.CoreLibs.Utilities.Tests.ObjectExtensionTests.ToStringOrDefault_WithNoDefault_Returns_StringDefault")] diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/IntegerExtensions.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/IntegerExtensions.cs new file mode 100644 index 0000000..bc56f6a --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/IntegerExtensions.cs @@ -0,0 +1,47 @@ +using DfE.CoreLibs.Utilities.Extensions; +using FluentAssertions; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +/// +/// The integer extensions tests. +/// + +public class IntegerExtensionsTests +{ + [Fact] + public void AsPercentageOf_With_Null_Part_Returns_EmptyString() + { + int? part = null; + int? whole = 100; + part.AsPercentageOf(whole).Should().Be(string.Empty); + } + + [Fact] + public void AsPercentageOf_With_Null_Whole_Returns_EmptyString() + { + int? part = 50; + int? whole = null; + part.AsPercentageOf(whole).Should().Be(string.Empty); + } + + [Theory] + [InlineData(50, 100, "50%")] + [InlineData(100, 50, "200%")] + [InlineData(-50, 100, "-50%")] + [InlineData(-50, -100, "50%")] + public void AsPercentageOf_With_Part_And_Whole_Returns_Percentage(int? part, int whole, string expectation) + { + part.AsPercentageOf(whole).Should().Be(expectation); + } + + [Fact] + public void AsPercentageOf_With_Part_And_Zero_Whole_Throws_Exception() + { + int? part = -50; + int? whole = 0; + + Action act = () => part.AsPercentageOf(whole); + act.Should().Throw(); + } +} diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/ObjectExtensionTests.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/ObjectExtensionTests.cs new file mode 100644 index 0000000..4c67527 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/ObjectExtensionTests.cs @@ -0,0 +1,30 @@ +using DfE.CoreLibs.Utilities.Extensions; +using FluentAssertions; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +/// +/// The object extension tests. +/// +public class ObjectExtensionTests +{ + [Fact] + public void ToStringOrDefault_WithNoDefault_Returns_StringDefault() + { + string? str = null; + str.ToStringOrDefault().Should().Be(default(string)); + + int? i = null; + i.ToStringOrDefault().Should().Be(default(string)); + } + + [Fact] + public void ToStringOrDefault_WithDefault_Returns_StringArgument() + { + string? str = null; + str.ToStringOrDefault("MyDefault").Should().Be("MyDefault"); + + int? i = null; + i.ToStringOrDefault("MyDefault").Should().Be("MyDefault"); + } +} diff --git a/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/StringExtensionsTests.cs b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/StringExtensionsTests.cs new file mode 100644 index 0000000..99286d6 --- /dev/null +++ b/src/Tests/DfE.CoreLibs.Utilities.Tests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,210 @@ +using DfE.CoreLibs.Utilities.Extensions; +using FluentAssertions; + +namespace DfE.CoreLibs.Utilities.Tests.Extensions; + +/// +/// The string extensions tests. +/// + +public class StringExtensionsTests +{ + private const string AcronymSentence = "DAO is CaptuRed CoRrectLy with MAT in DfE"; + private const string LowerCase = "this is lower case"; + + [Fact] + public void ToSentenceCase_WithAcronyms_ReturnsCorrectly() + { + AcronymSentence.ToSentenceCase().Should().Be("DAO Is captured correctly with MAT in DfE"); + } + + [Fact] + public void ToSentenceCase_WithLowerCase_ReturnsCorrectly() + { + LowerCase.ToSentenceCase().Should().Be("This is lower case"); + } + + [Fact] + public void ToSentenceCase_WithAcronyms_When_IgnoreAcronyms_IsTrue_Returns_SentenceCase() + { + AcronymSentence.ToSentenceCase(ignoreAcronyms:true).Should().Be("DAO Is captured correctly with MAT in DfE"); + } + + [Fact] + public void ToSentenceCase_WithAcronyms_When_IgnoreAcronyms_IsFalse_Returns_SentenceCase() + { + AcronymSentence.ToSentenceCase(ignoreAcronyms: false).Should().Be("Dao is captured correctly with mat in dfe"); + } + + [Theory] + [InlineData("")] + [InlineData(default)] + [InlineData(" ")] + public void ToSentenceCase_When_String_IsNullOrWhitespace_Returns_EmptyString(string input) + { + input.ToSentenceCase().Should().Be(input); + } + + [Fact] + public void All_Acronyms_When_IgnoreAcronyms_True_Returns_Original_String() + { + "DAO WTF LOL ROFL BRB".ToSentenceCase().Should().Be("DAO WTF LOL ROFL BRB"); + } + + [Fact] + public void All_Acronyms_When_IgnoreAcronyms_False_SentenceCases_The_Original_String() + { + "DAO WTF LOL ROFL BRB".ToSentenceCase(false) + .Should().Be("Dao wtf lol rofl brb"); + } + + [Theory] + [InlineData("MAT", true)] + [InlineData("TEAM", true)] + [InlineData("DAO-", false)] + [InlineData("DfE", true)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData(null, false)] + public void IsAcronym_ShouldReturnExpectedResult(string word, bool expected) + { + // Act + var result = StringExtensions.IsAcronym(word); + + // Assert + result.Should().Be(expected); + } + + [Theory] + [InlineData("MAT", true)] + [InlineData("TEAM", true)] + [InlineData("Mat", false)] + [InlineData("Hello", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void IsAllCaps_ShouldReturnExpectedResult(string word, bool expected) + { + // Act + var result = StringExtensions.IsAllCaps(word); + + // Assert + result.Should().Be(expected); + } + [Fact] + public void ToBool_Should_ReturnTrue_When_StringIsYes() + { + // Arrange + string yes = "Yes"; + + // Act + bool result = yes.ToBool(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ToBool_Should_ReturnFalse_When_StringIsNo() + { + // Arrange + string no = "No"; + + // Act + bool result = no.ToBool(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ToBool_Should_ThrowArgumentException_When_StringIsNotYesOrNo() + { + // Arrange + string notYesOrNo = "Maybe"; + + // Act + Action act = () => notYesOrNo.ToBool(); + + // Assert + act.Should().Throw().WithMessage("The string must be either 'Yes' or 'No'."); + } + + [Fact] + public void ToFirstUpper_Uppercases_First_Character_And_Returns_Whole_String() + { + string input = "this_is_a_test"; + + string result = input.ToFirstUpper(); + _ = result.Should().Be("This_is_a_test"); + } + + [Theory] + [InlineData(null, null)] + [InlineData("", "")] + [InlineData("All Title Case", "All Title Case")] + [InlineData("all lower case", "All Lower Case")] + [InlineData("ALL UPPER CASE", "All Upper Case")] + [InlineData("a title", "A Title")] + public void ToTitleCase_GivenString_ShouldConvert(string givenString, string expectedStringAsTitleCase) + { + var result = givenString?.ToTitleCase(); + Assert.Equal(expectedStringAsTitleCase, result); + } + + [Theory] + [InlineData("some text", "some-text")] + [InlineData("some text", "some-text")] + [InlineData("some\ttext", "some-text")] + public void ToHyphenated_GivenString_ShouldConvert(string input, string expectedOutput) + { + var result = input.ToHyphenated(); + Assert.Equal(expectedOutput, result); + } + + [Fact] + public void RemoveNonAlphanumericOrWhiteSpace_Strips_UnwantedCharacters() + { + const string text = "some text-with-punctuation_and'numbers99][()"; + + var result = text.RemoveNonAlphanumericOrWhiteSpace(); + + Assert.Equal("some text-with-punctuation_andnumbers99", result); + } + + [Theory] + [InlineData("GoverningBodyResolutiondde880c3-09bb-4940-83a2-e5591bf9a6bb", "Governing Body Resolutiondde880c3-09bb-4940-83a2-e5591bf9a6bb")] + [InlineData("Consultation4adf7b8f-aaea-41db-aacd-846396989519", "Consultation4adf7b8f-aaea-41db-aacd-846396989519")] + public void SplitPascalCase_Converts_String(string input, string expectation) + { + input.SplitPascalCase().Should().Be(expectation); + } + + [Theory] + [InlineData("Sponsored conversion", "sponsoredconversion")] + [InlineData(" Voluntary conver sion ", "voluntaryconversion")] + [InlineData("Form a MAT", "formamat")] + public void SquishToLower_Removes_Spaces_And_Lowercases(string input, string expectation) + { + input.SquishToLower().Should().Be(expectation); + } + + [Theory] + [InlineData("", true)] + [InlineData(default, true)] + [InlineData(" ", true)] + [InlineData("-", false)] + public void IsEmpty_Detects_IsNullOrWhitespace(string str, bool expectation) + { + str.IsEmpty().Should().Be(expectation); + } + + [Theory] + [InlineData("", false)] + [InlineData(default, false)] + [InlineData(" ", false)] + [InlineData("-", true)] + public void IsPresent_Returns_Opposite_Of_IsEmpty(string str, bool expectation) + { + str.IsPresent().Should().Be(expectation); + } +}