diff --git a/.bot/README.md b/.bot/README.md new file mode 100644 index 00000000..2cfca899 --- /dev/null +++ b/.bot/README.md @@ -0,0 +1,10 @@ +# .bot Workspace + +This folder is reserved for local-only AI working material such as: + +- brainstorm notes +- draft implementation plans +- design alternatives +- temporary agent state + +Keep this folder out of source control. Move only finalized, non-confidential guidance into `AGENTS.md` or `.github/copilot-instructions.md`. diff --git a/.docfx/Dockerfile.docfx b/.docfx/Dockerfile.docfx index ca808866..1719a33f 100644 --- a/.docfx/Dockerfile.docfx +++ b/.docfx/Dockerfile.docfx @@ -1,4 +1,4 @@ -ARG NGINX_VERSION=1.30.0-alpine +ARG NGINX_VERSION=1.31.0-alpine FROM --platform=$BUILDPLATFORM nginx:${NGINX_VERSION} AS base RUN rm -rf /usr/share/nginx/html/* diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..71ab5e53 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +# Build artefacts — rebuilt inside the container +**/bin/ +**/obj/ + +# Version control +.git +.github + +# IDE state +.vs +**/*.user + +# Previous test results +TestResults/ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d014a766..4cd6ee6c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -180,6 +180,28 @@ Internal classes and methods must be validated by exercising the public API that - Public entry points provide sufficient coverage of internal code paths. - The internal implementation exists solely as a helper or utility for public-facing functionality. +## 10. ExcludeFromCodeCoverage Prohibition + +**Do not use `ExcludeFromCodeCoverage` attribute on any code.** This includes: + +- Test classes or test methods +- Production code +- Configuration code +- Any other code path + +### Rationale + +- Excluding code from coverage hides gaps and creates false confidence in test completeness. +- If a code path cannot or should not be tested, refactor the code to eliminate that path rather than hiding it from metrics. +- Every executable line should be covered by tests or be genuinely unreachable (dead code to be removed). + +### Alternative Approaches + +- **Untestable code paths**: Refactor to separate concerns and eliminate the untestable path. +- **External dependencies**: Use test doubles (fakes, stubs, spies) instead of excluding from coverage. +- **Configuration-only code**: Move to configuration files or extract into testable methods. +- **Generated or third-party code**: These should not be in the primary codebase; use NuGet packages or dedicated vendor folders if necessary. + --- description: 'Writing Performance Tests' applyTo: "tuning/**, **/*Benchmark*.cs" diff --git a/.github/prompts/nuget.prompt.md b/.github/prompts/nuget.prompt.md deleted file mode 100644 index 98381c02..00000000 --- a/.github/prompts/nuget.prompt.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -mode: agent -description: 'Prompt for populating PackageReleaseNotes.txt files under .nuget/**' -params: - version: '10.0.0' ---- - -Purpose: deterministic, low-analysis instructions so automated runs prepend a single, consistent release block. - -Behavior (exact): -- For every file matching `.nuget/**/PackageReleaseNotes.txt`: - 1. Read the file and find the first line that starts with `Availability:` (case-sensitive). - 2. If found, capture the remainder of that line as `previous-tfm` and prepend the exact template shown below (substituting `{{version}}` and `{{previous-tfm}}`). - 3. If not found within the first 3 lines, do nothing for that file. - 4. Apply the template exactly as shown, preserving all whitespace and blank lines - PER FILE - DO NOT ASSUME CONTENT IS THE SAME ACROSS FILES. - 5. Save the file in-place - do not open PRs or create branches. - 6. Continue to the next file until all matching files have been processed. - 7. Do not assume that each file are the same - process each file independently. - -Exact template to prepend: -``` -Version: {{version}} -Availability: {{previous-tfm}} - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -``` -Notes: -- Do not attempt to infer versions or parse changelogs — use the provided `params.version` value. -- Keep edits minimal: only prepend the template block; preserve the rest of the file unchanged for human interference. -- DO NOT RUN ANY SORT OF GIT COMMANDS - once the files are saved, you are done. - -Example run command (agent): -`run: /nuget version={{version}}` diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 66f7dd22..13146357 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -129,6 +129,25 @@ jobs: build: true # needed for xunitv3 download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + test_mac: + name: call-test-mac + needs: [build, prepare_test] + strategy: + fail-fast: false + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + project: ${{ fromJson(needs.prepare_test.outputs.json) }} + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + runs-on: ${{ matrix.arch == 'ARM64' && 'macos-26' || 'macos-26-intel' }} + configuration: ${{ matrix.configuration }} + projects: ${{ matrix.project }} + build-switches: -p:SkipSignAssembly=true + restore: true + build: true # needed for xunitv3 + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + integration_test: if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} name: ⚗️ Integration Test - Azure and AWS @@ -240,7 +259,7 @@ jobs: sonarcloud: if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} name: call-sonarcloud - needs: [init, build, test_linux, test_windows, integration_test, integration_test_rabbitmq, integration_test_nats] + needs: [init, build, test_linux, test_windows, test_mac, integration_test, integration_test_rabbitmq, integration_test_nats] uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@v3 with: organization: geekle @@ -251,7 +270,7 @@ jobs: codecov: if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} name: call-codecov - needs: [init, build, test_linux, test_windows, integration_test, integration_test_rabbitmq, integration_test_nats] + needs: [init, build, test_linux, test_windows, test_mac, integration_test, integration_test_rabbitmq, integration_test_nats] uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 with: repository: codebeltnet/savvyio @@ -260,7 +279,7 @@ jobs: codeql: if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} name: call-codeql - needs: [init, build, test_linux, test_windows, integration_test, integration_test_rabbitmq, integration_test_nats] + needs: [init, build, test_linux, test_windows, test_mac, integration_test, integration_test_rabbitmq, integration_test_nats] uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@v3 permissions: security-events: write @@ -268,7 +287,7 @@ jobs: deploy: if: github.event_name != 'pull_request' name: call-nuget - needs: [build, pack, test_linux, test_windows, integration_test, integration_test_rabbitmq, integration_test_nats, sonarcloud, codecov, codeql] + needs: [build, pack, test_linux, test_windows, test_mac, integration_test, integration_test_rabbitmq, integration_test_nats, sonarcloud, codecov, codeql] uses: codebeltnet/jobs-nuget-push/.github/workflows/default.yml@v3 with: version: ${{ needs.build.outputs.version }} diff --git a/.gitignore b/.gitignore index 64149ed0..fbec20d3 100644 --- a/.gitignore +++ b/.gitignore @@ -240,3 +240,7 @@ ModelManifest.xml .ionide /test/Savvyio.Extensions.SimpleQueueService.Tests/appsettings.json /test/Savvyio.FunctionalTests/appsettings.json + +# Bot workspace (local-only AI agent ideation, PRDs, and agentic loop state) +.bot/* +!.bot/README.md diff --git a/.nuget/Savvyio.App/PackageReleaseNotes.txt b/.nuget/Savvyio.App/PackageReleaseNotes.txt index 63d9ec27..8978113d 100644 --- a/.nuget/Savvyio.App/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.App/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Commands.Messaging/PackageReleaseNotes.txt b/.nuget/Savvyio.Commands.Messaging/PackageReleaseNotes.txt index df2ececf..37a82f7e 100644 --- a/.nuget/Savvyio.Commands.Messaging/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Commands.Messaging/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Commands/PackageReleaseNotes.txt b/.nuget/Savvyio.Commands/PackageReleaseNotes.txt index 40816c44..06a4b15b 100644 --- a/.nuget/Savvyio.Commands/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Commands/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Core/PackageReleaseNotes.txt b/.nuget/Savvyio.Core/PackageReleaseNotes.txt index 629736a9..894d0131 100644 --- a/.nuget/Savvyio.Core/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Core/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Domain.EventSourcing/PackageReleaseNotes.txt b/.nuget/Savvyio.Domain.EventSourcing/PackageReleaseNotes.txt index 6d6c7a02..5104f6ff 100644 --- a/.nuget/Savvyio.Domain.EventSourcing/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Domain.EventSourcing/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Domain/PackageReleaseNotes.txt b/.nuget/Savvyio.Domain/PackageReleaseNotes.txt index 9020beef..fe665f7a 100644 --- a/.nuget/Savvyio.Domain/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Domain/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.EventDriven.Messaging/PackageReleaseNotes.txt b/.nuget/Savvyio.EventDriven.Messaging/PackageReleaseNotes.txt index b22b6a49..0f389ebf 100644 --- a/.nuget/Savvyio.EventDriven.Messaging/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.EventDriven.Messaging/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.EventDriven/PackageReleaseNotes.txt b/.nuget/Savvyio.EventDriven/PackageReleaseNotes.txt index ea0e3802..f6b9030b 100644 --- a/.nuget/Savvyio.EventDriven/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.EventDriven/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.Dapper/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.Dapper/PackageReleaseNotes.txt index 21908de0..3ee8834e 100644 --- a/.nuget/Savvyio.Extensions.Dapper/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.Dapper/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DapperExtensions/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DapperExtensions/PackageReleaseNotes.txt index 612891ce..014e7e03 100644 --- a/.nuget/Savvyio.Extensions.DapperExtensions/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DapperExtensions/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED DapperExtensions.StrongNameReference upgraded to 1.11.0; Dapper.StrongName upgraded to 2.1.79; all other dependencies upgraded to latest compatible versions + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.Dapper/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.Dapper/PackageReleaseNotes.txt index 0e1209ad..a02cf539 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.Dapper/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.Dapper/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.DapperExtensions/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.DapperExtensions/PackageReleaseNotes.txt index 11c3a2e2..4bf910e7 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.DapperExtensions/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.DapperExtensions/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.Domain/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.Domain/PackageReleaseNotes.txt index 989e13fa..2010d8d5 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.Domain/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.Domain/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt index 3205231d..f5678a62 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain/PackageReleaseNotes.txt index 4543531e..4bd5ee93 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.EFCore.Domain/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.EFCore/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.EFCore/PackageReleaseNotes.txt index 7c169dfd..6c10c902 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.EFCore/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.EFCore/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.NATS/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.NATS/PackageReleaseNotes.txt index 867bf843..4ca6fb0e 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.NATS/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.NATS/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED NATS.Client libraries upgraded to 2.8.0 for improved stability and compatibility; all other dependencies upgraded to latest compatible versions + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json/PackageReleaseNotes.txt index 495a5516..95f81f05 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.Newtonsoft.Json/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.QueueStorage/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.QueueStorage/PackageReleaseNotes.txt index 29d745ac..0150dfeb 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.QueueStorage/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.QueueStorage/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.RabbitMQ/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.RabbitMQ/PackageReleaseNotes.txt index 3761ec23..cfc0bfb5 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.RabbitMQ/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.RabbitMQ/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.SimpleQueueService/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.SimpleQueueService/PackageReleaseNotes.txt index 0c0ddd75..5d6e660c 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.SimpleQueueService/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.SimpleQueueService/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection.Text.Json/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection.Text.Json/PackageReleaseNotes.txt index 46e7bc16..05918ef2 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection.Text.Json/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection.Text.Json/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.DependencyInjection/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.DependencyInjection/PackageReleaseNotes.txt index c338f9b4..fbddfb23 100644 --- a/.nuget/Savvyio.Extensions.DependencyInjection/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.DependencyInjection/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.Dispatchers/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.Dispatchers/PackageReleaseNotes.txt index 545ef985..290fc2ef 100644 --- a/.nuget/Savvyio.Extensions.Dispatchers/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.Dispatchers/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt index 8f7ab407..48384be6 100644 --- a/.nuget/Savvyio.Extensions.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.EFCore.Domain.EventSourcing/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.EFCore.Domain/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.EFCore.Domain/PackageReleaseNotes.txt index 98677b53..ea4156fe 100644 --- a/.nuget/Savvyio.Extensions.EFCore.Domain/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.EFCore.Domain/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.EFCore/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.EFCore/PackageReleaseNotes.txt index f3fa845a..33e3febc 100644 --- a/.nuget/Savvyio.Extensions.EFCore/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.EFCore/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +# Bug Fixes +- FIXED EfCoreRepository.GetByIdAsync now correctly uses object array for id parameter to match Entity Framework Core API contract + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.NATS/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.NATS/PackageReleaseNotes.txt index b64963e1..76e38cb3 100644 --- a/.nuget/Savvyio.Extensions.NATS/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.NATS/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED NATS.Client libraries upgraded to 2.8.0 for improved stability and compatibility; all other dependencies upgraded to latest compatible versions + +# Improvements +- EXTENDED NatsCommandQueue and NatsEventBus with protected virtual methods (PublishMessageAsync, CreateConsumerAsync, CreateJetStreamContext, FetchMessagesAsync, SubscribeMessagesAsync) and ReceivedNatsMessage inner classes for improved testability and extensibility + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt index 88002dd1..6249ecc1 100644 --- a/.nuget/Savvyio.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.QueueStorage/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.QueueStorage/PackageReleaseNotes.txt index 327015d5..b59295dd 100644 --- a/.nuget/Savvyio.Extensions.QueueStorage/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.QueueStorage/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs); Azure.Identity now uses TFM-specific versions (1.17.2 for .NET 9, 1.21.0 for .NET 10) to resolve transitive dependency conflicts + +# Improvements +- EXTENDED AzureQueue and AzureEventBus with protected constructors accepting injected Azure SDK clients (QueueServiceClient, QueueClient, EventGridPublisherClient) for improved testability without hitting real endpoints + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.RabbitMQ/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.RabbitMQ/PackageReleaseNotes.txt index c539ac69..a9cb8bd9 100644 --- a/.nuget/Savvyio.Extensions.RabbitMQ/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.RabbitMQ/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +# Improvements +- CHANGED RabbitMqCommandQueueOptions now defaults Durable to true (was false) to comply with RabbitMQ 4.x deprecation of transient_nonexcl_queues; consumers requiring transient behavior can opt-out explicitly + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.SimpleQueueService/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.SimpleQueueService/PackageReleaseNotes.txt index d17f7aca..79d3939b 100644 --- a/.nuget/Savvyio.Extensions.SimpleQueueService/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.SimpleQueueService/PackageReleaseNotes.txt @@ -1,3 +1,12 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +# Improvements +- EXTENDED AmazonMessage, AmazonCommandQueue, and AmazonEventBus with protected virtual factory methods (CreateSimpleQueueServiceClient, CreateSimpleNotificationServiceClient) for improved testability and reduced credential/endpoint branching duplication + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Extensions.Text.Json/PackageReleaseNotes.txt b/.nuget/Savvyio.Extensions.Text.Json/PackageReleaseNotes.txt index a4ad7a4d..e1181c00 100644 --- a/.nuget/Savvyio.Extensions.Text.Json/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Extensions.Text.Json/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Messaging/PackageReleaseNotes.txt b/.nuget/Savvyio.Messaging/PackageReleaseNotes.txt index 89fa562c..a28536f2 100644 --- a/.nuget/Savvyio.Messaging/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Messaging/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/.nuget/Savvyio.Queries/PackageReleaseNotes.txt b/.nuget/Savvyio.Queries/PackageReleaseNotes.txt index 09d4233d..13b99fdd 100644 --- a/.nuget/Savvyio.Queries/PackageReleaseNotes.txt +++ b/.nuget/Savvyio.Queries/PackageReleaseNotes.txt @@ -1,3 +1,9 @@ +Version: 5.0.7 +Availability: .NET 10 and .NET 9 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + Version: 5.0.6 Availability: .NET 10 and .NET 9 diff --git a/CHANGELOG.md b/CHANGELOG.md index ee3e6db2..0f4c942d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), For more details, please refer to `PackageReleaseNotes.txt` on a per assembly basis in the `.nuget` folder. +## [5.0.7] - 2026-05-26 + +This is a patch release focused on Azure.Identity compatibility across target frameworks, RabbitMQ queue durability correction, comprehensive test coverage expansion across multiple extensions, testability improvements with protected virtual methods and constructors for extensibility, dependency updates including LocalStack, NATS.Client, and Microsoft utility packages, and test reliability hardening for distributed mediator scenarios. + +### Added + +- Comprehensive test coverage across EventDriven, Extensions.Dapper, Extensions.DependencyInjection, Extensions.Dispatchers, Extensions.Newtonsoft.Json, Extensions.QueueStorage, Extensions.RabbitMQ, Extensions.Text.Json, and Domain.EventSourcing modules, +- RabbitMqCommandQueue and RabbitMqEventBus integration tests with detailed command and event messaging scenarios, +- NATS extension tests including NatsCommandQueue and NatsEventBus coverage, +- Amazon SQS/SNS extension tests with AmazonMessage and related queue/bus scenarios, +- EFCore DomainEventDispatcher extension tests and related data store/repository coverage, +- DateTime and DateTimeOffset converter tests for Text.Json serialization, +- Converter tests for Newtonsoft.Json including ValueObjectConverter and AggregateRootConverter, +- AssemblyContext unit tests covering current domain assembly filtering and custom filter callbacks. + +### Changed + +- Azure.Identity consolidated to 1.21.0 for all target frameworks (previously split as 1.17.2 for net9, 1.21.0 for net10) to resolve transitive dependency conflicts introduced in v5.0.4, +- RabbitMqCommandQueueOptions now defaults Durable to true (was false) to comply with RabbitMQ 4.x deprecation of transient_nonexcl_queues, +- LocalStack Docker image upgraded from 4.13.1 to 4.14.0 for integration testing, +- NATS.Client versions bumped to latest, +- Microsoft testing and logging packages updated to latest minor versions, +- DocFX build environment nginx updated from 1.30.0 to 1.31.0, +- Codebelt and Cuemon utility libraries updated to latest compatible versions, +- AWS CLI Docker image updated to version 2.34.53, +- NatsCommandQueue and NatsEventBus now expose protected virtual methods (PublishMessageAsync, CreateConsumerAsync, CreateJetStreamContext, FetchMessagesAsync, SubscribeMessagesAsync) and ReceivedNatsMessage inner classes for improved testability and extensibility, +- AzureQueue and AzureEventBus now expose protected constructors accepting injected Azure SDK clients for testability without hitting real endpoints, +- AmazonMessage, AmazonCommandQueue, and AmazonEventBus now expose protected virtual factory methods (CreateSimpleQueueServiceClient, CreateSimpleNotificationServiceClient) for improved testability and reduced credential/endpoint branching duplication. + +### Fixed + +- RabbitMQ command queue configuration no longer produces deprecated transient_nonexcl_queues when using default options, +- GetByIdAsync method now correctly uses object array for id parameter to match API contract, +- DistributedMediatorTest now uses unique email addresses and enhanced retry logic with configurable visibility timeouts to improve test reliability in parallel and cross-platform environments. + +### Removed + +- NuGet prompt file (`.github/prompts/nuget.prompt.md`) used for package release notes generation. + ## [5.0.6] - 2026-04-18 This is a service update that focuses on package dependencies. @@ -990,7 +1029,9 @@ Noticeable highlights: - QueryHandler class in the Savvyio.Queries namespace that defines a generic and consistent way of handling Query objects that implements the IQuery interface - SavvyioOptionsExtensions class in the Savvyio.Queries namespace that consist of extension methods for the SavvyioOptions class: AddQueryHandler, AddQueryDispatcher -[Unreleased]: https://github.com/codebeltnet/savvyio/compare/v5.0.5...HEAD +[Unreleased]: https://github.com/codebeltnet/savvyio/compare/v5.0.7...HEAD +[5.0.7]: https://github.com/codebeltnet/savvyio/compare/v5.0.6...v5.0.7 +[5.0.6]: https://github.com/codebeltnet/savvyio/compare/v5.0.5...v5.0.6 [5.0.5]: https://github.com/codebeltnet/savvyio/compare/v5.0.4...v5.0.5 [5.0.4]: https://github.com/codebeltnet/savvyio/compare/v5.0.3...v5.0.4 [5.0.3]: https://github.com/codebeltnet/savvyio/compare/v5.0.2...v5.0.3 diff --git a/Directory.Packages.props b/Directory.Packages.props index ef789298..0313e52a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,51 +4,49 @@ true - - + + + - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - + + + - - + + - - - - + + + - - - - - - + + + \ No newline at end of file diff --git a/Dockerfile.localstack b/Dockerfile.localstack index a25b034d..f6343dfd 100644 --- a/Dockerfile.localstack +++ b/Dockerfile.localstack @@ -1,5 +1,5 @@ # Use the LocalStack base image -FROM localstack/localstack:4.13.1 +FROM localstack/localstack:4.14.0 # Expose the port for LocalStack EXPOSE 4566 diff --git a/Dockerfile.tests b/Dockerfile.tests new file mode 100644 index 00000000..644c40dc --- /dev/null +++ b/Dockerfile.tests @@ -0,0 +1,54 @@ +# syntax=docker/dockerfile:1.7 + +ARG DOTNET_VERSION=10.0 +ARG UBUNTU_FLAVOR=noble + +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/sdk:${DOTNET_VERSION}-${UBUNTU_FLAVOR} + +ARG DOTNET_VERSION + +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + DOTNET_NOLOGO=1 \ + DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 \ + NUGET_XMLDOC_MODE=skip \ + CI=false + +WORKDIR /work + +# The .NET 10 SDK can build net9.0, but it does not include the net9.0 runtime. +# Your local test projects target both net10.0 and net9.0 when CI != true. +RUN if [ "${DOTNET_VERSION}" = "10.0" ]; then \ + apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates git \ + && curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \ + && chmod +x /tmp/dotnet-install.sh \ + && /tmp/dotnet-install.sh --channel 9.0 --runtime dotnet --install-dir /usr/share/dotnet \ + && rm -rf /tmp/dotnet-install.sh /var/lib/apt/lists/*; \ + else \ + apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/*; \ + fi + +COPY . . + +ENTRYPOINT ["/bin/bash", "-lc"] + +CMD [ "\ + set -euo pipefail; \ + project=\"${TEST_PROJECT:-Savvyio.slnx}\"; \ + configuration=\"${CONFIGURATION:-Debug}\"; \ + framework=\"${TEST_FRAMEWORK:-}\"; \ + filter=\"${TEST_FILTER:-}\"; \ + echo \"Architecture: $(uname -m)\"; \ + echo \"Project: $project\"; \ + echo \"Configuration: $configuration\"; \ + echo \"Framework: ${framework:-all}\"; \ + dotnet --info; \ + dotnet restore \"$project\"; \ + args=(dotnet test \"$project\" --no-restore --configuration \"$configuration\" -p:SkipSignAssembly=true --logger \"console;verbosity=normal\"); \ + if [ -n \"$framework\" ]; then args+=(--framework \"$framework\"); fi; \ + if [ -n \"$filter\" ]; then args+=(--filter \"$filter\"); fi; \ + printf 'Command: '; printf '%q ' \"${args[@]}\"; echo; \ + \"${args[@]}\" \ +" ] diff --git a/docker-compose.yml b/docker-compose.yml index 96792cc8..cbb26967 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: localstack: - image: localstack/localstack:4.13.1 + image: localstack/localstack:4.14.0 environment: - SERVICES=sns,sqs - DEBUG=0 @@ -10,7 +10,7 @@ services: ports: - "4566:4566" awscli: - image: amazon/aws-cli:2.33.5 + image: amazon/aws-cli:2.34.53 environment: - AWS_DEFAULT_REGION=eu-west-1 - AWS_REGION=eu-west-1 diff --git a/src/Savvyio.Extensions.EFCore/EfCoreRepository.cs b/src/Savvyio.Extensions.EFCore/EfCoreRepository.cs index 20f361d1..094ced42 100644 --- a/src/Savvyio.Extensions.EFCore/EfCoreRepository.cs +++ b/src/Savvyio.Extensions.EFCore/EfCoreRepository.cs @@ -62,7 +62,7 @@ public virtual void AddRange(IEnumerable entities) public virtual Task GetByIdAsync(TKey id, Action setup = null) { var options = setup.Configure(); - return Set.FindAsync(new[] { id }, options.CancellationToken).AsTask(); + return Set.FindAsync(new object[] { id }, options.CancellationToken).AsTask(); } /// diff --git a/src/Savvyio.Extensions.NATS/Commands/NatsCommandQueue.cs b/src/Savvyio.Extensions.NATS/Commands/NatsCommandQueue.cs index 289eb649..965da06c 100644 --- a/src/Savvyio.Extensions.NATS/Commands/NatsCommandQueue.cs +++ b/src/Savvyio.Extensions.NATS/Commands/NatsCommandQueue.cs @@ -51,13 +51,13 @@ public NatsCommandQueue(IMarshaller marshaller, NatsCommandQueueOptions options) /// A that represents the asynchronous operation. public async Task SendAsync(IEnumerable> messages, Action setup = null) { - var js = NatsClient.CreateJetStreamContext(); + var context = CreateJetStreamContext(); foreach (var message in messages) { - await js.PublishAsync(_options.Subject, (await Marshaller.Serialize(message).ToByteArrayAsync().ConfigureAwait(false)).ToBase64String(), headers: new NatsHeaders() + await PublishMessageAsync(context, _options.Subject, (await Marshaller.Serialize(message).ToByteArrayAsync().ConfigureAwait(false)).ToBase64String(), new NatsHeaders() { { "type", message.GetType().ToFullNameIncludingAssemblyName() } - }); + }).ConfigureAwait(false); } } @@ -75,17 +75,14 @@ public async IAsyncEnumerable> ReceiveAsync(Action(opts: new NatsJSFetchOpts() + await foreach (var message in FetchMessagesAsync(consumer, new NatsJSFetchOpts() { Expires = _options.Expires == TimeSpan.Zero ? null : _options.Expires, MaxMsgs = _options.MaxMessages, @@ -96,7 +93,7 @@ public async IAsyncEnumerable> ReceiveAsync(Action; deserialized!.Properties.Add(nameof(CancellationToken), options.CancellationToken); - deserialized.Properties.Add(nameof(NatsJSMsg), message); + deserialized.Properties.Add(nameof(ReceivedNatsMessage), message); deserialized.Acknowledged += OnMessageAcknowledgedAsync; if (_options.AutoAcknowledge) { @@ -115,9 +112,101 @@ public async IAsyncEnumerable> ReceiveAsync(Action)e.Properties[nameof(NatsJSMsg)]; - await message.AckAsync(cancellationToken: ct).ConfigureAwait(false); + var message = (ReceivedNatsMessage)e.Properties[nameof(ReceivedNatsMessage)]; + await message.AcknowledgeAsync(ct).ConfigureAwait(false); if (sender is IAcknowledgeable ack) { ack.Acknowledged -= OnMessageAcknowledgedAsync; } } + + /// + /// Publishes a serialized message to NATS JetStream. + /// + /// The JetStream context to use for publishing. + /// The subject to publish to. + /// The serialized message payload. + /// The message headers. + /// A task that represents the asynchronous operation. + protected virtual async Task PublishMessageAsync(INatsJSContext context, string subject, string message, NatsHeaders headers) + { + await context.PublishAsync(subject, message, headers: headers).ConfigureAwait(false); + } + + /// + /// Creates or updates the stream and consumer used by receive operations. + /// + /// The stream configuration. + /// The consumer configuration. + /// The cancellation token of the asynchronous operation. + /// A consumer that can fetch messages. + protected virtual async Task CreateConsumerAsync(StreamConfig streamConfig, ConsumerConfig consumerConfig, CancellationToken cancellationToken) + { + var context = CreateJetStreamContext(); + await context.CreateOrUpdateStreamAsync(streamConfig, cancellationToken).ConfigureAwait(false); + return await context.CreateOrUpdateConsumerAsync(streamConfig.Name, consumerConfig, cancellationToken).ConfigureAwait(false); + } + + /// + /// Creates a NATS JetStream context used by send and receive operations. + /// + /// An for JetStream operations. + protected virtual INatsJSContext CreateJetStreamContext() + { + return NatsClient.CreateJetStreamContext(); + } + + /// + /// Fetches messages from the specified NATS JetStream consumer. + /// + /// The consumer to fetch messages from. + /// The fetch options. + /// The cancellation token of the asynchronous operation. + /// An asynchronous sequence of received NATS messages. + protected virtual async IAsyncEnumerable FetchMessagesAsync(INatsJSConsumer consumer, NatsJSFetchOpts options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var message in consumer.FetchAsync(opts: options, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + yield return new ReceivedNatsMessage(message.Headers, message.Data, ct => message.AckAsync(cancellationToken: ct).AsTask()); + } + } + + /// + /// Represents a NATS message received from JetStream. + /// + protected sealed class ReceivedNatsMessage + { + private readonly Func _acknowledgeAsync; + + /// + /// Initializes a new instance of the class. + /// + /// The NATS message headers. + /// The NATS message payload. + /// The delegate used to acknowledge the message. + public ReceivedNatsMessage(NatsHeaders headers, string data, Func acknowledgeAsync) + { + Headers = headers; + Data = data; + _acknowledgeAsync = acknowledgeAsync; + } + + /// + /// Gets the NATS message headers. + /// + public NatsHeaders Headers { get; } + + /// + /// Gets the NATS message payload. + /// + public string Data { get; } + + /// + /// Acknowledges the message. + /// + /// The cancellation token of the asynchronous operation. + /// A task that represents the asynchronous operation. + public Task AcknowledgeAsync(CancellationToken cancellationToken) + { + return _acknowledgeAsync(cancellationToken); + } + } } } diff --git a/src/Savvyio.Extensions.NATS/EventDriven/NatsEventBus.cs b/src/Savvyio.Extensions.NATS/EventDriven/NatsEventBus.cs index e41f5abe..ed03bfd2 100644 --- a/src/Savvyio.Extensions.NATS/EventDriven/NatsEventBus.cs +++ b/src/Savvyio.Extensions.NATS/EventDriven/NatsEventBus.cs @@ -7,6 +7,7 @@ using Savvyio.EventDriven; using Savvyio.Messaging; using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -48,10 +49,10 @@ public NatsEventBus(IMarshaller marshaller, NatsEventBusOptions options) : base( public async Task PublishAsync(IMessage message, Action setup = null) { Validator.ThrowIfInvalidConfigurator(setup, out var options); - await NatsClient.PublishAsync(_options.Subject, (await Marshaller.Serialize(message).ToByteArrayAsync().ConfigureAwait(false)).ToBase64String(), headers: new NatsHeaders() + await PublishMessageAsync(_options.Subject, (await Marshaller.Serialize(message).ToByteArrayAsync().ConfigureAwait(false)).ToBase64String(), new NatsHeaders() { { "type", message.GetType().ToFullNameIncludingAssemblyName() } - }, cancellationToken: options.CancellationToken); + }, options.CancellationToken).ConfigureAwait(false); } /// @@ -63,7 +64,7 @@ public async Task PublishAsync(IMessage message, Action, CancellationToken, Task> asyncHandler, Action setup = null) { Validator.ThrowIfInvalidConfigurator(setup, out var options); - await foreach (var message in NatsClient.SubscribeAsync(_options.Subject, opts: new NatsSubOpts() + await foreach (var message in SubscribeMessagesAsync(_options.Subject, new NatsSubOpts() { }, cancellationToken: options.CancellationToken)) { @@ -72,5 +73,60 @@ public async Task SubscribeAsync(Func, CancellationT await asyncHandler.Invoke(deserialized, options.CancellationToken).ConfigureAwait(false); } } + + /// + /// Publishes a serialized message to NATS. + /// + /// The subject to publish to. + /// The serialized message payload. + /// The message headers. + /// The cancellation token of the asynchronous operation. + /// A task that represents the asynchronous operation. + protected virtual async Task PublishMessageAsync(string subject, string message, NatsHeaders headers, CancellationToken cancellationToken) + { + await NatsClient.PublishAsync(subject, message, headers: headers, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Subscribes to NATS messages. + /// + /// The subject to subscribe to. + /// The subscription options. + /// The cancellation token of the asynchronous operation. + /// An asynchronous sequence of received NATS messages. + protected virtual async IAsyncEnumerable SubscribeMessagesAsync(string subject, NatsSubOpts options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + await foreach (var message in NatsClient.SubscribeAsync(subject, opts: options, cancellationToken: cancellationToken).ConfigureAwait(false)) + { + yield return new ReceivedNatsMessage(message.Headers, message.Data); + } + } + + /// + /// Represents a NATS message received from a subscription. + /// + protected sealed class ReceivedNatsMessage + { + /// + /// Initializes a new instance of the class. + /// + /// The NATS message headers. + /// The NATS message payload. + public ReceivedNatsMessage(NatsHeaders headers, string data) + { + Headers = headers; + Data = data; + } + + /// + /// Gets the NATS message headers. + /// + public NatsHeaders Headers { get; } + + /// + /// Gets the NATS message payload. + /// + public string Data { get; } + } } } diff --git a/src/Savvyio.Extensions.QueueStorage/AzureQueue.cs b/src/Savvyio.Extensions.QueueStorage/AzureQueue.cs index 2478029f..4e35b5d8 100644 --- a/src/Savvyio.Extensions.QueueStorage/AzureQueue.cs +++ b/src/Savvyio.Extensions.QueueStorage/AzureQueue.cs @@ -45,6 +45,41 @@ public abstract class AzureQueue where TRequest : IRequest return marshaller.Deserialize(base64Components[1].FromBase64().ToStream(), type) as IMessage; }; + /// + /// Initializes a new instance of the class with testable Azure Storage Queue clients. + /// + /// The marshaller used for serializing and deserializing messages. + /// The used to configure this instance. + /// The queue service client to use. + /// The queue client to use. + /// The function delegate to format messages for sending. If null, a default formatter is used. + /// The function delegate to format messages for receiving. If null, a default formatter is used. + /// + /// cannot be null - or - + /// cannot be null - or - + /// cannot be null - or - + /// cannot be null. + /// + /// + /// are not in a valid state. + /// + protected AzureQueue(IMarshaller marshaller, AzureQueueOptions options, QueueServiceClient serviceClient, QueueClient client, Func, IMarshaller, string> sendMessageFormatter = null, Func> receiveMessageFormatter = null) + { + Validator.ThrowIfNull(marshaller); + Validator.ThrowIfInvalidOptions(options); + Validator.ThrowIfNull(serviceClient); + Validator.ThrowIfNull(client); + + _marshaller = marshaller; + _options = options; + _serviceClient = serviceClient; + _client = client; + _sendMessageFormatter = sendMessageFormatter ?? _sendMessageFormatter; + _receiveMessageFormatter = receiveMessageFormatter ?? _receiveMessageFormatter; + + options.SetConfiguredClient(_client); + } + /// /// Initializes a new instance of the class. /// diff --git a/src/Savvyio.Extensions.QueueStorage/EventDriven/AzureEventBus.cs b/src/Savvyio.Extensions.QueueStorage/EventDriven/AzureEventBus.cs index f20f9087..7c4a5aea 100644 --- a/src/Savvyio.Extensions.QueueStorage/EventDriven/AzureEventBus.cs +++ b/src/Savvyio.Extensions.QueueStorage/EventDriven/AzureEventBus.cs @@ -3,6 +3,7 @@ using System.Threading; using System.Threading.Tasks; using Azure.Messaging.EventGrid; +using Azure.Storage.Queues; using Cuemon; using Cuemon.Extensions; using Cuemon.Extensions.IO; @@ -36,13 +37,7 @@ public class AzureEventBus : AzureQueue, IPublishSubscribeCha /// The to use for serializing and deserializing implementations to messages. /// The for configuring . /// The used to configure this instance. - public AzureEventBus(IMarshaller marshaller, AzureQueueOptions azureQueueOptions, AzureEventBusOptions azureEventBusOptions) : base(marshaller, azureQueueOptions, receiveMessageFormatter: (rawMessage, serializer) => - { - var json = rawMessage.MessageText; - var jsonStream = json.FromBase64().ToStream(); - var type = JsonNode.Parse(jsonStream.ToEncodedString(o => o.LeaveOpen = true))!.Root[CloudEventTypeExtensionAttribute]!.GetValue(); - return serializer.Deserialize(jsonStream, Type.GetType(type)!) as IMessage; - }) + public AzureEventBus(IMarshaller marshaller, AzureQueueOptions azureQueueOptions, AzureEventBusOptions azureEventBusOptions) : base(marshaller, azureQueueOptions, receiveMessageFormatter: FormatCloudEventQueueMessage) { Validator.ThrowIfInvalidOptions(azureEventBusOptions); @@ -64,6 +59,24 @@ public AzureEventBus(IMarshaller marshaller, AzureQueueOptions azureQueueOptions _options = azureEventBusOptions; } + /// + /// Initializes a new instance of the class with testable Azure clients. + /// + /// The to use for serializing and deserializing implementations to messages. + /// The for configuring . + /// The used to configure this instance. + /// The queue service client to use. + /// The queue client to use. + /// The event grid publisher client to use. + protected AzureEventBus(IMarshaller marshaller, AzureQueueOptions azureQueueOptions, AzureEventBusOptions azureEventBusOptions, QueueServiceClient queueServiceClient, QueueClient queueClient, EventGridPublisherClient eventGridClient) : base(marshaller, azureQueueOptions, queueServiceClient, queueClient, receiveMessageFormatter: FormatCloudEventQueueMessage) + { + Validator.ThrowIfInvalidOptions(azureEventBusOptions); + + _marshaller = marshaller; + _client = eventGridClient; + _options = azureEventBusOptions; + } + /// /// Publishes the specified asynchronous using Publish-Subscribe Channel/Pub-Sub MEP. /// @@ -137,5 +150,13 @@ public Uri GetHealthCheckTarget() { return new Uri(_options.TopicEndpoint, "/api/health"); } + + private static IMessage FormatCloudEventQueueMessage(Azure.Storage.Queues.Models.QueueMessage rawMessage, IMarshaller serializer) + { + var json = rawMessage.MessageText; + var jsonStream = json.FromBase64().ToStream(); + var type = JsonNode.Parse(jsonStream.ToEncodedString(o => o.LeaveOpen = true))!.Root[CloudEventTypeExtensionAttribute]!.GetValue(); + return serializer.Deserialize(jsonStream, Type.GetType(type)!) as IMessage; + } } } diff --git a/src/Savvyio.Extensions.RabbitMQ/Commands/RabbitMqCommandQueueOptions.cs b/src/Savvyio.Extensions.RabbitMQ/Commands/RabbitMqCommandQueueOptions.cs index 120305bf..f9386fd6 100644 --- a/src/Savvyio.Extensions.RabbitMQ/Commands/RabbitMqCommandQueueOptions.cs +++ b/src/Savvyio.Extensions.RabbitMQ/Commands/RabbitMqCommandQueueOptions.cs @@ -29,7 +29,7 @@ public class RabbitMqCommandQueueOptions : RabbitMqMessageOptions /// /// /// - /// false + /// true /// /// /// @@ -43,6 +43,7 @@ public class RabbitMqCommandQueueOptions : RabbitMqMessageOptions /// public RabbitMqCommandQueueOptions() { + Durable = true; } /// diff --git a/src/Savvyio.Extensions.SimpleQueueService/AmazonMessage.cs b/src/Savvyio.Extensions.SimpleQueueService/AmazonMessage.cs index 59ed4139..7067e71f 100644 --- a/src/Savvyio.Extensions.SimpleQueueService/AmazonMessage.cs +++ b/src/Savvyio.Extensions.SimpleQueueService/AmazonMessage.cs @@ -71,9 +71,7 @@ protected AmazonMessage(IMarshaller marshaller, AmazonMessageOptions options) /// A task that represents the asynchronous operation. The task result contains a sequence of whose generic type argument is . protected virtual async IAsyncEnumerable> RetrieveMessagesAsync([EnumeratorCancellation] CancellationToken cancellationToken) { - var sqs = Options.ClientConfigurations.IsValid() - ? new AmazonSQSClient(Options.Credentials, Options.ClientConfigurations.SimpleQueueService()) - : new AmazonSQSClient(Options.Credentials, Options.Endpoint); + var sqs = CreateSimpleQueueServiceClient(); if (Options.ReceiveContext.UseApproximateNumberOfMessages) { @@ -176,5 +174,16 @@ private async Task RemoveProcessedMessagesAsync(IAmazonSQS sqs, IEnumerable + /// Creates the AWS SQS client used by send and receive operations. + /// + /// An client configured from . + protected virtual IAmazonSQS CreateSimpleQueueServiceClient() + { + return Options.ClientConfigurations.IsValid() + ? new AmazonSQSClient(Options.Credentials, Options.ClientConfigurations.SimpleQueueService()) + : new AmazonSQSClient(Options.Credentials, Options.Endpoint); + } } } diff --git a/src/Savvyio.Extensions.SimpleQueueService/Commands/AmazonCommandQueue.cs b/src/Savvyio.Extensions.SimpleQueueService/Commands/AmazonCommandQueue.cs index 9979cfcb..52d1d7c0 100644 --- a/src/Savvyio.Extensions.SimpleQueueService/Commands/AmazonCommandQueue.cs +++ b/src/Savvyio.Extensions.SimpleQueueService/Commands/AmazonCommandQueue.cs @@ -55,9 +55,7 @@ public override async Task SendAsync(IEnumerable> messages, A var tasks = new List(); while (batches.HasPartitions) { - var sqs = Options.ClientConfigurations.IsValid() - ? new AmazonSQSClient(Options.Credentials, Options.ClientConfigurations.SimpleQueueService()) - : new AmazonSQSClient(Options.Credentials, Options.Endpoint); + var sqs = CreateSimpleQueueServiceClient(); var batchRequest = new SendMessageBatchRequest { @@ -102,10 +100,7 @@ public override IAsyncEnumerable> ReceiveAsync(ActionAn client instance used to probe the health status of the queue service. public IAmazonSQS GetHealthCheckTarget() { - var sqs = Options.ClientConfigurations.IsValid() - ? new AmazonSQSClient(Options.Credentials, Options.ClientConfigurations.SimpleQueueService()) - : new AmazonSQSClient(Options.Credentials, Options.Endpoint); - return sqs; + return CreateSimpleQueueServiceClient(); } } } diff --git a/src/Savvyio.Extensions.SimpleQueueService/EventDriven/AmazonEventBus.cs b/src/Savvyio.Extensions.SimpleQueueService/EventDriven/AmazonEventBus.cs index a7145b90..a1bb1263 100644 --- a/src/Savvyio.Extensions.SimpleQueueService/EventDriven/AmazonEventBus.cs +++ b/src/Savvyio.Extensions.SimpleQueueService/EventDriven/AmazonEventBus.cs @@ -46,9 +46,7 @@ public AmazonEventBus(IMarshaller marshaller, AmazonEventBusOptions options) : b public override async Task PublishAsync(IMessage @event, Action setup = null) { var options = setup.Configure(); - var sns = Options.ClientConfigurations.IsValid() - ? new AmazonSimpleNotificationServiceClient(Options.Credentials, Options.ClientConfigurations.SimpleNotificationService()) - : new AmazonSimpleNotificationServiceClient(Options.Credentials, Options.Endpoint); + var sns = CreateSimpleNotificationServiceClient(); var request = new PublishRequest { TopicArn = @event.Source, @@ -111,10 +109,18 @@ private async Task InvokeHandlerAsync(Func, Cancella /// An client instance used to probe the health status of the notification service. public IAmazonSimpleNotificationService GetHealthCheckTarget() { - var sns = Options.ClientConfigurations.IsValid() + return CreateSimpleNotificationServiceClient(); + } + + /// + /// Creates the AWS SNS client used by publish operations. + /// + /// An client configured from . + protected virtual IAmazonSimpleNotificationService CreateSimpleNotificationServiceClient() + { + return Options.ClientConfigurations.IsValid() ? new AmazonSimpleNotificationServiceClient(Options.Credentials, Options.ClientConfigurations.SimpleNotificationService()) : new AmazonSimpleNotificationServiceClient(Options.Credentials, Options.Endpoint); - return sns; } } } diff --git a/test/Savvyio.Core.Tests/Reflection/AssemblyContextTest.cs b/test/Savvyio.Core.Tests/Reflection/AssemblyContextTest.cs new file mode 100644 index 00000000..48f23786 --- /dev/null +++ b/test/Savvyio.Core.Tests/Reflection/AssemblyContextTest.cs @@ -0,0 +1,185 @@ +using System; +using System.Linq; +using System.Reflection; +using Codebelt.Extensions.Xunit; +using Savvyio.Reflection; +using Xunit; + +namespace Savvyio.Reflection +{ + public class AssemblyContextTest : Test + { + public AssemblyContextTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CurrentDomainAssemblies_ShouldReturnNonEmptyList() + { + var assemblies = AssemblyContext.CurrentDomainAssemblies; + + Assert.NotNull(assemblies); + Assert.NotEmpty(assemblies); + } + + [Fact] + public void CurrentDomainAssemblies_ShouldNotContainSavvyioCoreAssembly() + { + var savvyioCoreAssembly = typeof(AssemblyContext).Assembly; + + Assert.DoesNotContain(savvyioCoreAssembly, AssemblyContext.CurrentDomainAssemblies); + } + + [Fact] + public void CurrentDomainAssemblies_ShouldNotContainSystemOrMicrosoftAssemblies() + { + foreach (var assembly in AssemblyContext.CurrentDomainAssemblies) + { + Assert.False(assembly.FullName?.StartsWith(nameof(System), StringComparison.Ordinal), + $"Assembly '{assembly.FullName}' should have been filtered out."); + Assert.False(assembly.FullName?.StartsWith(nameof(Microsoft), StringComparison.Ordinal), + $"Assembly '{assembly.FullName}' should have been filtered out."); + } + } + + [Fact] + public void AssemblyFilterCallback_ShouldReturnDefaultNonNullDelegate() + { + var callback = AssemblyContext.AssemblyFilterCallback; + + Assert.NotNull(callback); + } + + [Fact] + public void AssemblyFilterCallback_ShouldAcceptCustomDelegate() + { + var original = AssemblyContext.AssemblyFilterCallback; + try + { + Func custom = _ => true; + AssemblyContext.AssemblyFilterCallback = custom; + + Assert.Same(custom, AssemblyContext.AssemblyFilterCallback); + } + finally + { + AssemblyContext.AssemblyFilterCallback = original; + } + } + + [Fact] + public void AssemblyFilterCallback_ShouldThrowArgumentNullException_WhenAssignedNull() + { + Assert.Throws(() => AssemblyContext.AssemblyFilterCallback = null); + } + + [Fact] + public void AssemblyDependenciesCallback_ShouldReturnDefaultNonNullDelegate() + { + var callback = AssemblyContext.AssemblyDependenciesCallback; + + Assert.NotNull(callback); + } + + [Fact] + public void AssemblyDependenciesCallback_ShouldAcceptCustomDelegate() + { + var original = AssemblyContext.AssemblyDependenciesCallback; + try + { + Func> custom = a => Enumerable.Repeat(a, 1); + AssemblyContext.AssemblyDependenciesCallback = custom; + + Assert.Same(custom, AssemblyContext.AssemblyDependenciesCallback); + } + finally + { + AssemblyContext.AssemblyDependenciesCallback = original; + } + } + + [Fact] + public void AssemblyDependenciesCallback_ShouldThrowArgumentNullException_WhenAssignedNull() + { + Assert.Throws(() => AssemblyContext.AssemblyDependenciesCallback = null); + } + + [Fact] + public void AssemblyDependenciesFilterCallback_ShouldReturnDefaultNonNullDelegate() + { + var callback = AssemblyContext.AssemblyDependenciesFilterCallback; + + Assert.NotNull(callback); + } + + [Fact] + public void AssemblyDependenciesFilterCallback_ShouldAcceptCustomDelegate() + { + var original = AssemblyContext.AssemblyDependenciesFilterCallback; + try + { + Func custom = _ => true; + AssemblyContext.AssemblyDependenciesFilterCallback = custom; + + Assert.Same(custom, AssemblyContext.AssemblyDependenciesFilterCallback); + } + finally + { + AssemblyContext.AssemblyDependenciesFilterCallback = original; + } + } + + [Fact] + public void AssemblyDependenciesFilterCallback_ShouldThrowArgumentNullException_WhenAssignedNull() + { + Assert.Throws(() => AssemblyContext.AssemblyDependenciesFilterCallback = null); + } + + [Fact] + public void AssemblyFilterCallback_DefaultFilter_ShouldIncludeNonSystemAssembly() + { + var callback = AssemblyContext.AssemblyFilterCallback; + var savvyioAssembly = typeof(AssemblyContext).Assembly; + + Assert.True(callback(savvyioAssembly)); + } + + [Fact] + public void AssemblyFilterCallback_DefaultFilter_ShouldExcludeSystemAssembly() + { + var callback = AssemblyContext.AssemblyFilterCallback; + var systemAssembly = typeof(string).Assembly; + + Assert.False(callback(systemAssembly)); + } + + [Fact] + public void AssemblyDependenciesFilterCallback_DefaultFilter_ShouldIncludeNonSystemAssemblyName() + { + var callback = AssemblyContext.AssemblyDependenciesFilterCallback; + var assemblyName = typeof(AssemblyContext).Assembly.GetName(); + + Assert.True(callback(assemblyName)); + } + + [Fact] + public void AssemblyDependenciesFilterCallback_DefaultFilter_ShouldExcludeSystemAssemblyName() + { + var callback = AssemblyContext.AssemblyDependenciesFilterCallback; + var assemblyName = typeof(string).Assembly.GetName(); + + Assert.False(callback(assemblyName)); + } + + [Fact] + public void AssemblyDependenciesCallback_DefaultCallback_ShouldYieldAtLeastTheInputAssembly() + { + var callback = AssemblyContext.AssemblyDependenciesCallback; + var assembly = typeof(AssemblyContext).Assembly; + + var result = callback(assembly).ToList(); + + Assert.Contains(assembly, result); + } + } +} diff --git a/test/Savvyio.Domain.EventSourcing.Tests/EfCoreModelBuilderExtensionsTest.cs b/test/Savvyio.Domain.EventSourcing.Tests/EfCoreModelBuilderExtensionsTest.cs new file mode 100644 index 00000000..4b517ff7 --- /dev/null +++ b/test/Savvyio.Domain.EventSourcing.Tests/EfCoreModelBuilderExtensionsTest.cs @@ -0,0 +1,44 @@ +using System; +using Codebelt.Extensions.Xunit; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Savvyio.Assets.Domain.EventSourcing; +using Savvyio.Extensions.EFCore; +using Xunit; + +namespace Savvyio.Extensions.EFCore.Domain.EventSourcing +{ + public class EfCoreModelBuilderExtensionsTest : Test + { + public EfCoreModelBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ModelBuilderExtensions_ShouldApplyConfiguredEventSourcingSchema() + { + var source = new EfCoreDataSource(new EfCoreDataSourceOptions + { + ContextConfigurator = b => b.UseInMemoryDatabase("event-sourcing-" + Guid.NewGuid()), + ModelConstructor = mb => mb.AddEventSourcing(o => + { + o.TableName = "TraceEvents"; + o.CompositePrimaryKeyIdColumnName = "aggregate_id"; + o.CompositePrimaryKeyVersionColumnName = "aggregate_version"; + o.TimestampColumnName = "recorded_at"; + o.TypeColumnName = "aggregate_type"; + o.PayloadColumnName = "body"; + }) + }); + + var schema = source.DbContext.Model.ToDebugString(MetadataDebugStringOptions.LongDefault); + + Assert.Contains("Relational:TableName: TraceEvents", schema); + Assert.Contains("Relational:ColumnName: aggregate_id", schema); + Assert.Contains("Relational:ColumnName: aggregate_version", schema); + Assert.Contains("Relational:ColumnName: recorded_at", schema); + Assert.Contains("Relational:ColumnName: aggregate_type", schema); + Assert.Contains("Relational:ColumnName: body", schema); + } + } +} diff --git a/test/Savvyio.Domain.EventSourcing.Tests/EfCoreTracedAggregateEntityOptionsTest.cs b/test/Savvyio.Domain.EventSourcing.Tests/EfCoreTracedAggregateEntityOptionsTest.cs new file mode 100644 index 00000000..891a165f --- /dev/null +++ b/test/Savvyio.Domain.EventSourcing.Tests/EfCoreTracedAggregateEntityOptionsTest.cs @@ -0,0 +1,61 @@ +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Savvyio.Extensions.EFCore.Domain.EventSourcing +{ + public class EfCoreTracedAggregateEntityOptionsTest : Test + { + public EfCoreTracedAggregateEntityOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void EfCoreTracedAggregateEntityOptions_Ensure_Initialization_Defaults() + { + var sut = new EfCoreTracedAggregateEntityOptions(); + + Assert.Equal("DomainEvents", sut.TableName); + Assert.Equal("id", sut.CompositePrimaryKeyIdColumnName); + Assert.Equal("uniqueidentifier", sut.CompositePrimaryKeyIdColumnType); + Assert.Equal("version", sut.CompositePrimaryKeyVersionColumnName); + Assert.Equal("int", sut.CompositePrimaryKeyVersionColumnType); + Assert.Equal("timestamp", sut.TimestampColumnName); + Assert.Equal("datetime", sut.TimestampColumnType); + Assert.Equal("clrtype", sut.TypeColumnName); + Assert.Equal("varchar(1024)", sut.TypeColumnType); + Assert.Equal("payload", sut.PayloadColumnName); + Assert.Equal("varchar(max)", sut.PayloadColumnType); + } + + [Fact] + public void EfCoreTracedAggregateEntityOptions_ShouldAcceptCustomValues() + { + var sut = new EfCoreTracedAggregateEntityOptions + { + TableName = "TraceEvents", + CompositePrimaryKeyIdColumnName = "aggregate_id", + CompositePrimaryKeyIdColumnType = "uuid", + CompositePrimaryKeyVersionColumnName = "aggregate_version", + CompositePrimaryKeyVersionColumnType = "bigint", + TimestampColumnName = "recorded_at", + TimestampColumnType = "datetime2", + TypeColumnName = "aggregate_type", + TypeColumnType = "nvarchar(512)", + PayloadColumnName = "body", + PayloadColumnType = "varbinary(max)" + }; + + Assert.Equal("TraceEvents", sut.TableName); + Assert.Equal("aggregate_id", sut.CompositePrimaryKeyIdColumnName); + Assert.Equal("uuid", sut.CompositePrimaryKeyIdColumnType); + Assert.Equal("aggregate_version", sut.CompositePrimaryKeyVersionColumnName); + Assert.Equal("bigint", sut.CompositePrimaryKeyVersionColumnType); + Assert.Equal("recorded_at", sut.TimestampColumnName); + Assert.Equal("datetime2", sut.TimestampColumnType); + Assert.Equal("aggregate_type", sut.TypeColumnName); + Assert.Equal("nvarchar(512)", sut.TypeColumnType); + Assert.Equal("body", sut.PayloadColumnName); + Assert.Equal("varbinary(max)", sut.PayloadColumnType); + } + } +} diff --git a/test/Savvyio.Domain.EventSourcing.Tests/EfCoreTracedDomainEventExtensionsTest.cs b/test/Savvyio.Domain.EventSourcing.Tests/EfCoreTracedDomainEventExtensionsTest.cs new file mode 100644 index 00000000..e063d317 --- /dev/null +++ b/test/Savvyio.Domain.EventSourcing.Tests/EfCoreTracedDomainEventExtensionsTest.cs @@ -0,0 +1,28 @@ +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Savvyio.Assets.Domain.Events; +using Savvyio.Domain.EventSourcing; +using Savvyio.Extensions.Newtonsoft.Json; +using Xunit; + +namespace Savvyio.Extensions.EFCore.Domain.EventSourcing +{ + public class TracedDomainEventExtensionsTest : Test + { + public TracedDomainEventExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void TracedDomainEventExtensions_ShouldConvertDomainEventToByteArray() + { + var domainEvent = new TracedAccountEmailAddressChanged("test@unit.test"); + var expected = NewtonsoftJsonMarshaller.Default.Serialize(domainEvent, typeof(ITracedDomainEvent)).ToByteArray(); + + var sut = domainEvent.ToByteArray(NewtonsoftJsonMarshaller.Default); + + Assert.NotEmpty(sut); + Assert.Equal(expected, sut); + } + } +} diff --git a/test/Savvyio.Domain.EventSourcing.Tests/TracedAggregateRootTest.cs b/test/Savvyio.Domain.EventSourcing.Tests/TracedAggregateRootTest.cs index 8d0de4e0..459c9be1 100644 --- a/test/Savvyio.Domain.EventSourcing.Tests/TracedAggregateRootTest.cs +++ b/test/Savvyio.Domain.EventSourcing.Tests/TracedAggregateRootTest.cs @@ -9,6 +9,7 @@ using Savvyio.Assets.Domain.Events; using Savvyio.Assets.Domain.EventSourcing; using Savvyio.Assets.Domain.Handlers; +using Savvyio.Domain; using Savvyio.Extensions.DependencyInjection; using Savvyio.Extensions.DependencyInjection.Domain.EventSourcing; using Savvyio.Extensions.DependencyInjection.EFCore; @@ -18,7 +19,9 @@ using Savvyio.Extensions.EFCore.Domain.EventSourcing; using Savvyio.Extensions.Newtonsoft.Json; using Savvyio.Extensions.Text.Json; +using Savvyio.Handlers; using System; +using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -86,7 +89,7 @@ public async Task EfCoreDataStore_ShouldRaiseDomainEventsAndDehydrateEventsToSto sc.AddMarshaller(); sc.AddEfCoreAggregateDataSource(o => { - o.ContextConfigurator = b => b.UseInMemoryDatabase(dbName).EnableSensitiveDataLogging().EnableDetailedErrors().LogTo(Console.WriteLine, LogLevel.Trace); + o.ContextConfigurator = b => b.UseInMemoryDatabase(dbName).EnableSensitiveDataLogging().EnableDetailedErrors().EnableServiceProviderCaching(false).LogTo(Console.WriteLine, LogLevel.Trace); o.ModelConstructor = mb => { mb.AddEventSourcing(eo => eo.TableName = $"{nameof(TracedAccount)}_DomainEvents"); @@ -100,7 +103,7 @@ public async Task EfCoreDataStore_ShouldRaiseDomainEventsAndDehydrateEventsToSto sc.AddMarshaller(); sc.AddEfCoreAggregateDataSource(o => { - o.ContextConfigurator = b => b.UseInMemoryDatabase(dbName).EnableSensitiveDataLogging().EnableDetailedErrors().LogTo(Console.WriteLine, LogLevel.Trace); + o.ContextConfigurator = b => b.UseInMemoryDatabase(dbName).EnableSensitiveDataLogging().EnableDetailedErrors().EnableServiceProviderCaching(false).LogTo(Console.WriteLine, LogLevel.Trace); o.ModelConstructor = mb => { mb.AddEventSourcing(eo => eo.TableName = $"{nameof(TracedAccount)}_DomainEvents"); @@ -214,5 +217,90 @@ public void EfCoreDataStore_ShouldRehydrateFromProvidedEvents() Assert.Equal(newName, sut.FullName); Assert.Equal(newEmail, sut.EmailAddress); } + + [Fact] + public void EfCoreTracedAggregateEntity_ShouldExposeExplicitInterfaceMembers() + { + var id = Guid.NewGuid(); + var providerId = new PlatformProviderId(Guid.NewGuid()); + var fullName = new FullName("Test", "User"); + var emailAddress = new EmailAddress("test@unit.test"); + var ta = new TracedAccount(id, providerId, fullName, emailAddress); + var domainEvent = ta.Events.First(); + var marshaller = new JsonMarshaller(); + + var entity = new EfCoreTracedAggregateEntity(ta, domainEvent, marshaller); + + var metadata = ((IMetadata)entity).Metadata; + var events = ((IAggregateRoot)entity).Events; + ((IAggregateRoot)entity).RemoveAllEvents(); // no-op + + Assert.NotNull(metadata); + Assert.Single(events); + Assert.Single(((IAggregateRoot)entity).Events); // RemoveAllEvents is no-op + } + + [Fact] + public async Task EfCoreTracedAggregateRepository_AddRange_ShouldPersistMultipleAggregates() + { + var sc = new ServiceCollection(); + var dbName = "Dummy_AddRange_" + Generate.RandomString(10); + sc.AddSavvyIO(o => o.AddDomainEventDispatcher().AddDomainEventHandler()); + sc.AddMarshaller(); + sc.AddEfCoreAggregateDataSource(o => + { + o.ContextConfigurator = b => b.UseInMemoryDatabase(dbName).EnableSensitiveDataLogging().EnableDetailedErrors(); + o.ModelConstructor = mb => mb.AddEventSourcing(eo => eo.TableName = $"{nameof(TracedAccount)}_DomainEvents"); + }); + sc.AddEfCoreTracedAggregateRepository(); + sc.AddScoped, InMemoryTestStore>(); + + var sp = sc.BuildServiceProvider(); + var ds = sp.GetRequiredService>() as IEfCoreDataSource; + var sut4 = sp.GetRequiredService>() as ITracedAggregateRepository; + + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + var providerId = Guid.NewGuid(); + + var ta1 = new TracedAccount(id1, providerId, "Name1", "email1@test.com"); + var ta2 = new TracedAccount(id2, providerId, "Name2", "email2@test.com"); + + sut4.AddRange([ta1, ta2]); + await ds.SaveChangesAsync(); + + var result1 = await sut4.GetByIdAsync(id1); + var result2 = await sut4.GetByIdAsync(id2); + + Assert.Equal(id1, result1.Id); + Assert.Equal("Name1", result1.FullName); + Assert.Equal(id2, result2.Id); + Assert.Equal("Name2", result2.FullName); + } + + [Fact] + public async Task EfCoreTracedAggregateRepository_GetByIdAsync_ShouldThrowMissingMethodException_WhenEntityLacksRehydrationConstructor() + { + var sc = new ServiceCollection(); + var dbName = "Dummy_NoCtor_" + Generate.RandomString(10); + sc.AddSavvyIO(o => o.AddDomainEventDispatcher()); + sc.AddMarshaller(); + sc.AddEfCoreAggregateDataSource(o => + { + o.ContextConfigurator = b => b.UseInMemoryDatabase(dbName); + o.ModelConstructor = mb => mb.AddEventSourcing(eo => eo.TableName = "NoCtorTracedAccount_DomainEvents"); + }); + sc.AddEfCoreTracedAggregateRepository(); + + var sp = sc.BuildServiceProvider(); + var sut = sp.GetRequiredService>() as ITracedAggregateRepository; + + await Assert.ThrowsAsync(() => sut.GetByIdAsync(Guid.NewGuid())); + } + + private class NoCtorTracedAccount : TracedAggregateRoot + { + protected override void RegisterDelegates(IFireForgetRegistry handler) { } + } } } diff --git a/test/Savvyio.Domain.EventSourcing.Tests/TracedDomainEventExtensionsTest.cs b/test/Savvyio.Domain.EventSourcing.Tests/TracedDomainEventExtensionsTest.cs new file mode 100644 index 00000000..5629af2f --- /dev/null +++ b/test/Savvyio.Domain.EventSourcing.Tests/TracedDomainEventExtensionsTest.cs @@ -0,0 +1,36 @@ +using System; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.Domain.Events; +using Xunit; + +namespace Savvyio.Domain.EventSourcing +{ + public class TracedDomainEventExtensionsTest : Test + { + public TracedDomainEventExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void TracedDomainEventExtensions_ShouldSetAndGetAggregateVersion() + { + var domainEvent = new TracedAccountEmailAddressChanged("test@unit.test"); + + var sut = domainEvent.SetAggregateVersion(42); + + Assert.Same(domainEvent, sut); + Assert.Equal(42, sut.GetAggregateVersion()); + } + + [Fact] + public void TracedDomainEventExtensions_ShouldReturnMemberTypeMetadata() + { + var sut = new TracedAccountInitiated(Guid.NewGuid(), Guid.NewGuid(), "Jane Doe", "jd@office.com"); + + var memberType = sut.GetMemberType(); + + Assert.StartsWith(typeof(TracedAccountInitiated).FullName, memberType); + Assert.Contains("Savvyio.Assets.Tests", memberType); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/IntegrationEventDispatcherTest.cs b/test/Savvyio.EventDriven.Tests/IntegrationEventDispatcherTest.cs new file mode 100644 index 00000000..af105b00 --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/IntegrationEventDispatcherTest.cs @@ -0,0 +1,48 @@ +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Savvyio.Dispatchers; +using Savvyio.Extensions.DependencyInjection; +using Savvyio.Handlers; +using Xunit; + +namespace Savvyio.EventDriven +{ + public class IntegrationEventDispatcherTest : Test + { + public IntegrationEventDispatcherTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Publish_ShouldDispatchIntegrationEvent() + { + var services = new ServiceCollection() + .AddServiceLocator() + .AddSingleton, InMemoryTestStore>() + .AddTransient(); + var provider = services.BuildServiceProvider(); + var sut = new IntegrationEventDispatcher(provider.GetRequiredService()); + + sut.Publish(new TestIntegrationEvent("published")); + + Assert.Collection(provider.GetRequiredService>().Query(), item => Assert.Equal("published", item)); + } + + private sealed record TestIntegrationEvent(string Value) : IntegrationEvent; + + private sealed class TestIntegrationEventHandler : IntegrationEventHandler + { + private readonly ITestStore _store; + + public TestIntegrationEventHandler(ITestStore store) + { + _store = store; + } + + protected override void RegisterDelegates(IFireForgetRegistry handlers) + { + handlers.Register(message => _store.Add(message.Value)); + } + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/IntegrationEventTest.cs b/test/Savvyio.EventDriven.Tests/IntegrationEventTest.cs index 88dd87b8..b2e40f8c 100644 --- a/test/Savvyio.EventDriven.Tests/IntegrationEventTest.cs +++ b/test/Savvyio.EventDriven.Tests/IntegrationEventTest.cs @@ -44,5 +44,19 @@ public void IntegrationEvent_AccountCreated_ShouldHaveMetadata_WithSpecifiedEven }); Assert.True(sut.Metadata.Count == 3, "sut.Metadata.Count == 3"); } + + [Fact] + public void IntegrationEvent_AccountCreated_ShouldExposeMetadataThroughExtensions() + { + var eventId = Guid.NewGuid().ToString("N"); + var timestamp = DateTime.UtcNow; + var sut = new AccountCreated(100, "Michael Mortensen", "root@gimlichael.dev") + .SetEventId(eventId) + .SetTimestamp(timestamp); + + Assert.Equal(eventId, sut.GetEventId()); + Assert.Equal(timestamp, sut.GetTimestamp()); + Assert.Equal(sut.GetType().ToFullNameIncludingAssemblyName(), sut.GetMemberType()); + } } } diff --git a/test/Savvyio.EventDriven.Tests/Messaging/AcknowledgeableTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/AcknowledgeableTest.cs new file mode 100644 index 00000000..10140abc --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/AcknowledgeableTest.cs @@ -0,0 +1,34 @@ +using System.Threading.Tasks; +using Cuemon.Extensions; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.Commands; +using Xunit; + +namespace Savvyio.Messaging +{ + public class AcknowledgeableTest : Test + { + public AcknowledgeableTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task AcknowledgeAsync_ShouldRaiseAcknowledgedEvent_WithMessageProperties() + { + var message = new Message("mid1", "urn:members:1".ToUri(), nameof(CreateMemberCommand), new CreateMemberCommand("Jane", 21, "j@j.com")); + AcknowledgedEventArgs acknowledged = null; + message.Properties["messageId"] = message.Id; + message.Acknowledged += (_, e) => + { + acknowledged = e; + return Task.CompletedTask; + }; + + await message.AcknowledgeAsync(); + + Assert.NotNull(acknowledged); + Assert.Same(message.Properties, acknowledged.Properties); + Assert.Equal(message.Id, acknowledged.Properties["messageId"]); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Messaging/CloudEvents/CloudEventDictionaryTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/CloudEvents/CloudEventDictionaryTest.cs new file mode 100644 index 00000000..799f646a --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/CloudEvents/CloudEventDictionaryTest.cs @@ -0,0 +1,79 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Cuemon.Extensions; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.EventDriven; +using Xunit; + +namespace Savvyio.EventDriven.Messaging.CloudEvents +{ + public class CloudEventDictionaryTest : Test + { + public CloudEventDictionaryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void IDictionaryMembers_ShouldManageExtensionAttributes() + { + var sut = CreateCloudEvent(); + IDictionary dictionary = sut; + ICollection> collection = dictionary; + + sut.Add("TenantId", 42); + sut["TraceId"] = "abc123"; + + Assert.True(sut.ContainsKey("tenantid")); + Assert.True(sut.ContainsKey("traceid")); + Assert.Equal(42, sut["tenantid"]); + Assert.True(sut.TryGetValue("traceid", out var traceId)); + Assert.Equal("abc123", traceId); + Assert.Contains("tenantid", dictionary.Keys); + Assert.Contains("traceid", dictionary.Keys); + Assert.Contains(42, dictionary.Values); + Assert.Contains("abc123", dictionary.Values); + Assert.True(collection.Contains(new KeyValuePair("tenantid", 42))); + var copy = new KeyValuePair[collection.Count]; + collection.CopyTo(copy, 0); + Assert.Contains(copy, kvp => kvp.Key == "tenantid" && Equals(kvp.Value, 42)); + Assert.False(collection.IsReadOnly); + Assert.True(sut.Remove("traceid")); + Assert.False(sut.ContainsKey("traceid")); + } + + [Fact] + public void EnumeratorsAndClear_ShouldExposeAndResetExtensionAttributes() + { + var sut = CreateCloudEvent(); + IDictionary dictionary = sut; + ICollection> collection = dictionary; + + collection.Add(new KeyValuePair("CorrelationId", "corr-1")); + collection.Add(new KeyValuePair("TenantId", 7)); + + var genericItems = dictionary.ToList(); + var nongenericItems = ((IEnumerable)sut).Cast>().ToList(); + + Assert.Equal(2, genericItems.Count); + Assert.Equal(genericItems, nongenericItems); + Assert.True(collection.Remove(new KeyValuePair("tenantid", 7))); + Assert.Single(dictionary); + + sut.Clear(); + + Assert.Empty(dictionary); + } + + private static CloudEvent CreateCloudEvent() + { + var message = new MemberCreated("Jane Doe", "jd@office.com").ToMessage("https://savvyio.net/members".ToUri(), nameof(MemberCreated), o => + { + o.MessageId = "message-id"; + o.Time = new System.DateTime(2024, 1, 1, 0, 0, 0, System.DateTimeKind.Utc); + }); + + return (CloudEvent)message.ToCloudEvent(); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Messaging/CloudEvents/Cryptography/CloudEventExtensionsTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/CloudEvents/Cryptography/CloudEventExtensionsTest.cs new file mode 100644 index 00000000..9a368080 --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/CloudEvents/Cryptography/CloudEventExtensionsTest.cs @@ -0,0 +1,61 @@ +using System; +using Cuemon.Extensions; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.EventDriven; +using Savvyio.Extensions.Text.Json; +using Xunit; + +namespace Savvyio.EventDriven.Messaging.CloudEvents.Cryptography +{ + public class CloudEventExtensionsTest : Test + { + public CloudEventExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void SignCloudEvent_ShouldCreateSignedCloudEvent_WithVerifiableSignature() + { + var marshaller = new JsonMarshaller(); + var cloudEvent = CreateCloudEvent(); + + var signed = cloudEvent.SignCloudEvent(marshaller, o => o.SignatureSecret = new byte[] { 1, 2, 3 }); + + Assert.Equal(cloudEvent.Id, signed.Id); + Assert.Equal(cloudEvent.Source, signed.Source); + Assert.Equal(cloudEvent.Type, signed.Type); + Assert.Equal(cloudEvent.Time, signed.Time); + Assert.Equal(cloudEvent.Data, signed.Data); + Assert.Equal(cloudEvent.Specversion, signed.Specversion); + Assert.False(string.IsNullOrWhiteSpace(signed.Signature)); + + signed.CheckCloudEventSignature(marshaller, o => o.SignatureSecret = new byte[] { 1, 2, 3 }); + } + + [Fact] + public void CheckCloudEventSignature_ShouldThrow_WhenSecretDoesNotMatch() + { + var marshaller = new JsonMarshaller(); + var signed = CreateCloudEvent().SignCloudEvent(marshaller, o => o.SignatureSecret = new byte[] { 1, 2, 3 }); + + var exception = Assert.Throws(() => signed.CheckCloudEventSignature(marshaller, o => o.SignatureSecret = new byte[] { 3, 2, 1 })); + + Assert.Equal(signed.Signature, exception.ActualValue); + } + + private static CloudEvent CreateCloudEvent() + { + var utc = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var @event = new MemberCreated("Jane Doe", "jd@office.com") + .SetEventId("event-123") + .SetTimestamp(utc); + var message = @event.ToMessage("https://api.example.com/members".ToUri(), nameof(MemberCreated), o => + { + o.MessageId = "message-123"; + o.Time = utc; + }); + + return (CloudEvent)message.ToCloudEvent(); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Messaging/Cryptography/SignedMessageOptionsTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/Cryptography/SignedMessageOptionsTest.cs new file mode 100644 index 00000000..bd0db700 --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/Cryptography/SignedMessageOptionsTest.cs @@ -0,0 +1,37 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Savvyio.Messaging.Cryptography +{ + public class SignedMessageOptionsTest : Test + { + public SignedMessageOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ValidateOptions_ShouldThrow_WhenSignatureSecretIsNull() + { + var options = new SignedMessageOptions(); + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldThrow_WhenSignatureSecretIsEmpty() + { + var options = new SignedMessageOptions { SignatureSecret = Array.Empty() }; + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldSucceed_WhenSignatureSecretIsSet() + { + var options = new SignedMessageOptions { SignatureSecret = new byte[] { 1, 2, 3 } }; + + options.ValidateOptions(); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Messaging/Cryptography/SignedMessageTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/Cryptography/SignedMessageTest.cs new file mode 100644 index 00000000..5741b74b --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/Cryptography/SignedMessageTest.cs @@ -0,0 +1,58 @@ +using System; +using Cuemon.Extensions; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.Commands; +using Savvyio.Commands.Messaging; +using Xunit; + +namespace Savvyio.Messaging.Cryptography +{ + public class SignedMessageTest : Test + { + public SignedMessageTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldThrowArgumentNullException_WhenMessageIsNull() + { + Assert.Throws(() => new SignedMessage(null, "sig")); + } + + [Fact] + public void Ctor_ShouldThrowArgumentNullException_WhenSignatureIsNull() + { + var message = new CreateMemberCommand("Jane", 21, "j@j.com").ToMessage("https://api.example.com".ToUri(), "test"); + + Assert.Throws(() => new SignedMessage(message, null)); + } + + [Fact] + public void Ctor_ShouldThrowArgumentException_WhenSignatureIsEmpty() + { + var message = new CreateMemberCommand("Jane", 21, "j@j.com").ToMessage("https://api.example.com".ToUri(), "test"); + + Assert.Throws(() => new SignedMessage(message, string.Empty)); + } + + [Fact] + public void Ctor_ShouldCopyMessageProperties() + { + var utc = DateTime.UtcNow; + var message = new CreateMemberCommand("Jane", 21, "j@j.com").ToMessage("https://api.example.com".ToUri(), "test", o => + { + o.MessageId = "abc123"; + o.Time = utc; + }); + + var signed = new SignedMessage(message, "mysig"); + + Assert.Equal(message.Id, signed.Id); + Assert.Equal(message.Source, signed.Source); + Assert.Equal(message.Type, signed.Type); + Assert.Equal(message.Time, signed.Time); + Assert.Equal(message.Data, signed.Data); + Assert.Equal("mysig", signed.Signature); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Messaging/MessageAsyncEnumerableOptionsTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/MessageAsyncEnumerableOptionsTest.cs new file mode 100644 index 00000000..fc7fb3a0 --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/MessageAsyncEnumerableOptionsTest.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.Commands; +using Xunit; + +namespace Savvyio.Messaging +{ + public class MessageAsyncEnumerableOptionsTest : Test + { + public MessageAsyncEnumerableOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldInitializeAcknowledgedProperties() + { + var options = new MessageAsyncEnumerableOptions(); + + Assert.IsType>>(options.AcknowledgedProperties); + } + + [Fact] + public void ValidateOptions_ShouldThrow_WhenAcknowledgedPropertiesIsNull() + { + var options = new MessageAsyncEnumerableOptions + { + AcknowledgedProperties = null, + MessageCallback = _ => Task.CompletedTask + }; + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldThrow_WhenMessageCallbackIsNull() + { + var options = new MessageAsyncEnumerableOptions + { + AcknowledgedProperties = new ConcurrentBag>() + }; + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldSucceed_WhenRequiredPropertiesAreSet() + { + var options = new MessageAsyncEnumerableOptions + { + AcknowledgedProperties = new ConcurrentBag>(), + MessageCallback = _ => Task.CompletedTask + }; + + options.ValidateOptions(); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Messaging/MessageAsyncEnumerableTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/MessageAsyncEnumerableTest.cs new file mode 100644 index 00000000..4ef826e1 --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/MessageAsyncEnumerableTest.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Cuemon.Extensions; +using Codebelt.Extensions.Xunit; +using Savvyio.Assets.Commands; +using Xunit; + +namespace Savvyio.Messaging +{ + public class MessageAsyncEnumerableTest : Test + { + public MessageAsyncEnumerableTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + Assert.Throws(() => new MessageAsyncEnumerable((IAsyncEnumerable>)null)); + } + + [Fact] + public async Task GetAsyncEnumerator_ShouldInvokeCallbacks_AndCollectAcknowledgedProperties() + { + var callbacks = new List(); + List> acknowledged = null; + var utc = DateTime.UtcNow; + var source = new IMessage[] + { + new Message("mid1", "urn:members:1".ToUri(), nameof(CreateMemberCommand), new CreateMemberCommand("Jane", 21, "jane@example.com"), utc), + new Message("mid2", "urn:members:2".ToUri(), nameof(CreateMemberCommand), new CreateMemberCommand("John", 22, "john@example.com"), utc) + }; + var sut = new MessageAsyncEnumerable(source, o => + { + o.MessageCallback = async message => + { + callbacks.Add(message.Id); + message.Properties["messageId"] = message.Id; + await message.AcknowledgeAsync(); + }; + o.AcknowledgedPropertiesCallback = properties => + { + acknowledged = properties.ToList(); + return Task.CompletedTask; + }; + }); + + await foreach (var message in sut) + { + Assert.NotNull(message); + } + + Assert.Equal(new[] { "mid1", "mid2" }, callbacks); + Assert.NotNull(acknowledged); + Assert.Equal(2, acknowledged.Count); + Assert.Equal(new[] { "mid1", "mid2" }, acknowledged.Select(p => p["messageId"]).Cast().OrderBy(id => id).ToArray()); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Messaging/MessageOptionsTest.cs b/test/Savvyio.EventDriven.Tests/Messaging/MessageOptionsTest.cs new file mode 100644 index 00000000..6f5c04e4 --- /dev/null +++ b/test/Savvyio.EventDriven.Tests/Messaging/MessageOptionsTest.cs @@ -0,0 +1,37 @@ +using System; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Savvyio.Messaging +{ + public class MessageOptionsTest : Test + { + public MessageOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ValidateOptions_ShouldThrow_WhenMessageIdIsNull() + { + var options = new MessageOptions { MessageId = null }; + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldThrow_WhenTimeIsNotUtc() + { + var options = new MessageOptions { Time = DateTime.Now }; + + Assert.Throws(() => options.ValidateOptions()); + } + + [Fact] + public void ValidateOptions_ShouldSucceed_WhenValid() + { + var options = new MessageOptions { MessageId = "abc", Time = DateTime.UtcNow }; + + options.ValidateOptions(); + } + } +} diff --git a/test/Savvyio.EventDriven.Tests/Savvyio.EventDriven.Tests.csproj b/test/Savvyio.EventDriven.Tests/Savvyio.EventDriven.Tests.csproj index 8cb1df7d..3056270b 100644 --- a/test/Savvyio.EventDriven.Tests/Savvyio.EventDriven.Tests.csproj +++ b/test/Savvyio.EventDriven.Tests/Savvyio.EventDriven.Tests.csproj @@ -11,6 +11,7 @@ + diff --git a/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceOptionsTest.cs b/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceOptionsTest.cs index 050dc718..c105b4bd 100644 --- a/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceOptionsTest.cs +++ b/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceOptionsTest.cs @@ -1,4 +1,5 @@ using Codebelt.Extensions.Xunit; +using Microsoft.Data.Sqlite; using Xunit; namespace Savvyio.Extensions.Dapper @@ -16,5 +17,18 @@ public void DapperDataSourceOptions_Ensure_Initialization_Defaults() Assert.Null(sut.ConnectionFactory); } + + [Fact] + public void DapperDataSourceOptions_ValidateOptions_ShouldPassWhenConnectionFactoryIsSet() + { + var sut = new DapperDataSourceOptions + { + ConnectionFactory = () => new SqliteConnection("Data Source=:memory:") + }; + + var exception = Record.Exception(sut.ValidateOptions); + + Assert.Null(exception); + } } } diff --git a/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceTest.cs b/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceTest.cs index e6f44328..2dc4c21a 100644 --- a/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceTest.cs +++ b/test/Savvyio.Extensions.Dapper.Tests/DapperDataSourceTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Data; using Codebelt.Extensions.Xunit; using Microsoft.Data.Sqlite; using Xunit; @@ -49,5 +50,74 @@ public void DapperDataSource_ShouldFailWithInvalidOperationException() Assert.Throws(() => sut.BeginTransaction()); Assert.True(sut.Disposed); } + + [Fact] + public void DapperDataSource_ShouldExposeConnectionMembers() + { + var sut = new DapperDataSource(new DapperDataSourceOptions + { + ConnectionFactory = () => new SqliteConnection("Data Source=:memory:") + }); + + Assert.Equal(ConnectionState.Open, sut.State); + Assert.NotNull(sut.CreateCommand()); + Assert.Equal("Data Source=:memory:", sut.ConnectionString); + + using (var transaction = sut.BeginTransaction()) + { + Assert.NotNull(transaction); + transaction.Rollback(); + } + + Assert.Throws(() => sut.ChangeDatabase("main")); + Assert.Equal("main", sut.Database); + + sut.Close(); + Assert.Equal(ConnectionState.Closed, sut.State); + + sut.Open(); + Assert.Equal(ConnectionState.Open, sut.State); + } + + [Fact] + public void DapperDataSource_ShouldSupportBeginTransactionWithIsolationLevel() + { + var sut = new DapperDataSource(new DapperDataSourceOptions + { + ConnectionFactory = () => new SqliteConnection("Data Source=:memory:") + }); + + using var transaction = sut.BeginTransaction(IsolationLevel.ReadCommitted); + + Assert.NotNull(transaction); + } + + [Fact] + public void DapperDataSource_ShouldExposeConnectionTimeout() + { + var sut = new DapperDataSource(new DapperDataSourceOptions + { + ConnectionFactory = () => new SqliteConnection("Data Source=:memory:") + }); + + var timeout = sut.ConnectionTimeout; + + Assert.True(timeout >= 0); + TestOutput.WriteLine($"ConnectionTimeout: {timeout}"); + } + + [Fact] + public void DapperDataSource_ShouldSupportConnectionStringSetter() + { + var sut = new DapperDataSource(new DapperDataSourceOptions + { + ConnectionFactory = () => new SqliteConnection("Data Source=:memory:") + }); + + sut.Close(); + sut.ConnectionString = "Data Source=:memory:"; + + Assert.Equal("Data Source=:memory:", sut.ConnectionString); + } } } diff --git a/test/Savvyio.Extensions.Dapper.Tests/DapperQueryOptionsTest.cs b/test/Savvyio.Extensions.Dapper.Tests/DapperQueryOptionsTest.cs index 74dd05ff..73cb373f 100644 --- a/test/Savvyio.Extensions.Dapper.Tests/DapperQueryOptionsTest.cs +++ b/test/Savvyio.Extensions.Dapper.Tests/DapperQueryOptionsTest.cs @@ -4,6 +4,7 @@ using Cuemon.Extensions; using Codebelt.Extensions.Xunit; using Dapper; +using Microsoft.Data.Sqlite; using Xunit; namespace Savvyio.Extensions.Dapper @@ -36,5 +37,35 @@ public void DapperOptions_Ensure_Initialization_Defaults() Assert.Equal(sut.Transaction, cd.Transaction); } + + [Fact] + public void DapperOptions_ShouldPreserveCustomValuesInImplicitConversion() + { + using var connection = new SqliteConnection("Data Source=:memory:"); + connection.Open(); + using var transaction = connection.BeginTransaction(); + using var cts = new CancellationTokenSource(); + var parameters = new { Id = 42 }; + var sut = new DapperQueryOptions + { + CommandText = "SELECT 1", + CommandFlags = CommandFlags.NoCache, + CommandTimeout = TimeSpan.FromSeconds(12), + CommandType = CommandType.StoredProcedure, + Parameters = parameters, + Transaction = transaction, + CancellationToken = cts.Token + }; + + var cd = (CommandDefinition)sut; + + Assert.Equal("SELECT 1", cd.CommandText); + Assert.Equal(CommandFlags.NoCache, cd.Flags); + Assert.Equal(12, cd.CommandTimeout); + Assert.Equal(CommandType.StoredProcedure, cd.CommandType); + Assert.Equal(parameters, cd.Parameters); + Assert.Equal(transaction, cd.Transaction); + Assert.Equal(cts.Token, cd.CancellationToken); + } } } diff --git a/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/ServiceCollectionRegistrationTest.cs b/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/ServiceCollectionRegistrationTest.cs new file mode 100644 index 00000000..06c22814 --- /dev/null +++ b/test/Savvyio.Extensions.DependencyInjection.Dapper.Tests/ServiceCollectionRegistrationTest.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.DependencyInjection; +using Savvyio.Data; +using Savvyio.Extensions.Dapper; +using Xunit; + +namespace Savvyio.Extensions.DependencyInjection.Dapper +{ + public class ServiceCollectionRegistrationTest : Test + { + public ServiceCollectionRegistrationTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddDapperDataStore_ShouldTypeForwardPersistentDataStore() + { + var services = new ServiceCollection(); + services.AddDapperDataSource(o => o.ConnectionFactory = () => new SqliteConnection("Data Source=:memory:")); + services.AddDapperDataStore(); + var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService>()); + } + + private sealed class FakeRecord + { + } + + private sealed class FakeDapperDataStore : DapperDataStore + { + public FakeDapperDataStore(Savvyio.Extensions.Dapper.IDapperDataSource source) : base(source) + { + } + + public override Task CreateAsync(FakeRecord dto, Action setup = null) + { + return Task.CompletedTask; + } + + public override Task UpdateAsync(FakeRecord dto, Action setup = null) + { + return Task.CompletedTask; + } + + public override Task GetByIdAsync(object id, Action setup = null) + { + return Task.FromResult(null); + } + + public override Task> FindAllAsync(Action setup = null) + { + return Task.FromResult>(Array.Empty()); + } + + public override Task DeleteAsync(FakeRecord dto, Action setup = null) + { + return Task.CompletedTask; + } + } + } +} diff --git a/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/AggregateDataSourceRegistrationTest.cs b/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/AggregateDataSourceRegistrationTest.cs new file mode 100644 index 00000000..0c7c451b --- /dev/null +++ b/test/Savvyio.Extensions.DependencyInjection.EFCore.Domain.Tests/AggregateDataSourceRegistrationTest.cs @@ -0,0 +1,60 @@ +using System; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Threading; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Savvyio.Assets; +using Savvyio.Domain; +using Savvyio.Extensions.DependencyInjection.Domain; +using Savvyio.Extensions.EFCore; +using Savvyio.Extensions.EFCore.Domain; +using Xunit; + +namespace Savvyio.Extensions.DependencyInjection.EFCore.Domain +{ + public class AggregateDataSourceRegistrationTest : Test + { + public AggregateDataSourceRegistrationTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddEfCoreAggregateDataSource_ShouldTypeForwardDefaultImplementation() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddEfCoreAggregateDataSource(o => o.ContextConfigurator = b => b.UseInMemoryDatabase("aggregate-" + Guid.NewGuid())); + var provider = services.BuildServiceProvider(); + + Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + Assert.IsType(provider.GetRequiredService()); + } + + [Fact] + public void AddEfCoreAggregateDataSource_ShouldTypeForwardMarkedImplementation() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddEfCoreAggregateDataSource(o => o.ContextConfigurator = b => b.UseInMemoryDatabase("aggregate-marker-" + Guid.NewGuid())); + var provider = services.BuildServiceProvider(); + + Assert.IsType>(provider.GetRequiredService>()); + Assert.IsType>(provider.GetRequiredService>()); + Assert.IsType>(provider.GetRequiredService>()); + } + + private sealed class SilentDomainEventDispatcher : IDomainEventDispatcher + { + public void Raise(IDomainEvent request) + { + } + + public Task RaiseAsync(IDomainEvent request, Action setup = null) + { + return Task.CompletedTask; + } + } + } +} diff --git a/test/Savvyio.Extensions.DependencyInjection.Tests/ServiceCollectionRegistrationTest.cs b/test/Savvyio.Extensions.DependencyInjection.Tests/ServiceCollectionRegistrationTest.cs new file mode 100644 index 00000000..57a980e8 --- /dev/null +++ b/test/Savvyio.Extensions.DependencyInjection.Tests/ServiceCollectionRegistrationTest.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Codebelt.Extensions.Xunit; +using Cuemon.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Savvyio.Dispatchers; +using Xunit; + +namespace Savvyio.Extensions.DependencyInjection +{ + public class ServiceCollectionRegistrationTest : Test + { + public ServiceCollectionRegistrationTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddConfiguredOptions_ShouldRegisterAllSupportedRepresentations() + { + var services = new ServiceCollection(); + services.AddConfiguredOptions(o => o.Name = "Configured"); + var provider = services.BuildServiceProvider(); + var action = provider.GetRequiredService>(); + var target = new TestOptions(); + + action(target); + + Assert.Equal("Configured", provider.GetRequiredService>().Value.Name); + Assert.Equal("Configured", provider.GetRequiredService().Name); + Assert.Equal("Configured", target.Name); + } + + [Fact] + public void AddMarshaller_ShouldRegisterFactoryImplementation() + { + var services = new ServiceCollection(); + services.AddMarshaller(_ => new FakeMarshaller(), o => o.Lifetime = ServiceLifetime.Singleton); + var provider = services.BuildServiceProvider(); + + var sut1 = provider.GetRequiredService(); + var sut2 = provider.GetRequiredService(); + + Assert.IsType(sut1); + Assert.Same(sut1, sut2); + } + + [Fact] + public void AddServiceLocator_ShouldRespectCustomFactoryAndLifetime() + { + var services = new ServiceCollection(); + services.AddServiceLocator(o => + { + o.Lifetime = ServiceLifetime.Singleton; + o.ImplementationFactory = _ => new ServiceLocator(_ => Array.Empty()); + }); + var provider = services.BuildServiceProvider(); + + var sut1 = provider.GetRequiredService(); + var sut2 = provider.GetRequiredService(); + + Assert.Same(sut1, sut2); + Assert.Empty(sut1.GetServices(typeof(string))); + } + + [Fact] + public void AddHandlerServicesDescriptor_ShouldDoNothingWhenDescriptorIsMissing() + { + var services = new ServiceCollection(); + services.AddHandlerServicesDescriptor(); + var provider = services.BuildServiceProvider(); + + Assert.Null(provider.GetService()); + } + + private sealed class TestOptions : IParameterObject + { + public string Name { get; set; } + } + + private sealed class FakeMarshaller : IMarshaller + { + public Stream Serialize(TValue value) + { + return new MemoryStream(new byte[] { 1, 2, 3 }); + } + + public Stream Serialize(object value, Type inputType) + { + return new MemoryStream(new byte[] { 1, 2, 3 }); + } + + public TValue Deserialize(Stream data) + { + return default; + } + + public object Deserialize(Stream data, Type returnType) + { + return null; + } + } + } +} diff --git a/test/Savvyio.Extensions.Dispatchers.Tests/MediatorSyncTest.cs b/test/Savvyio.Extensions.Dispatchers.Tests/MediatorSyncTest.cs new file mode 100644 index 00000000..a9634f1b --- /dev/null +++ b/test/Savvyio.Extensions.Dispatchers.Tests/MediatorSyncTest.cs @@ -0,0 +1,135 @@ +using Codebelt.Extensions.Xunit; +using Microsoft.Extensions.DependencyInjection; +using Savvyio.Commands; +using Savvyio.Dispatchers; +using Savvyio.Domain; +using Savvyio.EventDriven; +using Savvyio.Extensions.DependencyInjection; +using Savvyio.Handlers; +using Savvyio.Queries; +using Xunit; + +namespace Savvyio.Extensions +{ + public class MediatorSyncTest : Test + { + public MediatorSyncTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Commit_ShouldDispatchCommandSynchronously() + { + var provider = CreateServiceProvider(services => services.AddTransient()); + var sut = new Mediator(provider.GetRequiredService()); + + sut.Commit(new TestMediatorCommand()); + + Assert.Collection(provider.GetRequiredService>().Query(), item => Assert.Equal("command", item)); + } + + [Fact] + public void Raise_ShouldDispatchDomainEventSynchronously() + { + var provider = CreateServiceProvider(services => services.AddTransient()); + var sut = new Mediator(provider.GetRequiredService()); + + sut.Raise(new TestMediatorDomainEvent()); + + Assert.Collection(provider.GetRequiredService>().Query(), item => Assert.Equal("domain-event", item)); + } + + [Fact] + public void Publish_ShouldDispatchIntegrationEventSynchronously() + { + var provider = CreateServiceProvider(services => services.AddTransient()); + var sut = new Mediator(provider.GetRequiredService()); + + sut.Publish(new TestMediatorIntegrationEvent()); + + Assert.Collection(provider.GetRequiredService>().Query(), item => Assert.Equal("integration-event", item)); + } + + [Fact] + public void Query_ShouldDispatchQuerySynchronously() + { + var provider = CreateServiceProvider(services => services.AddTransient()); + var sut = new Mediator(provider.GetRequiredService()); + + var result = sut.Query(new TestMediatorQuery()); + + Assert.Equal("query", result); + } + + private static ServiceProvider CreateServiceProvider(System.Action setup) + { + var services = new ServiceCollection() + .AddServiceLocator() + .AddSingleton, InMemoryTestStore>(); + + setup(services); + return services.BuildServiceProvider(); + } + + private sealed record TestMediatorCommand : Command; + + private sealed record TestMediatorDomainEvent : DomainEvent; + + private sealed record TestMediatorIntegrationEvent : IntegrationEvent; + + private sealed record TestMediatorQuery : Query; + + private sealed class TestMediatorCommandHandler : CommandHandler + { + private readonly ITestStore _store; + + public TestMediatorCommandHandler(ITestStore store) + { + _store = store; + } + + protected override void RegisterDelegates(IFireForgetRegistry handlers) + { + handlers.Register(_ => _store.Add("command")); + } + } + + private sealed class TestMediatorDomainEventHandler : DomainEventHandler + { + private readonly ITestStore _store; + + public TestMediatorDomainEventHandler(ITestStore store) + { + _store = store; + } + + protected override void RegisterDelegates(IFireForgetRegistry handlers) + { + handlers.Register(_ => _store.Add("domain-event")); + } + } + + private sealed class TestMediatorIntegrationEventHandler : IntegrationEventHandler + { + private readonly ITestStore _store; + + public TestMediatorIntegrationEventHandler(ITestStore store) + { + _store = store; + } + + protected override void RegisterDelegates(IFireForgetRegistry handlers) + { + handlers.Register(_ => _store.Add("integration-event")); + } + } + + private sealed class TestMediatorQueryHandler : QueryHandler + { + protected override void RegisterDelegates(IRequestReplyRegistry handlers) + { + handlers.Register(_ => "query"); + } + } + } +} diff --git a/test/Savvyio.Extensions.Dispatchers.Tests/MediatorTest.cs b/test/Savvyio.Extensions.Dispatchers.Tests/MediatorTest.cs index f1cc9928..8d0fdd76 100644 --- a/test/Savvyio.Extensions.Dispatchers.Tests/MediatorTest.cs +++ b/test/Savvyio.Extensions.Dispatchers.Tests/MediatorTest.cs @@ -77,6 +77,7 @@ public async Task Mediator_ShouldInvoke_CreateAccountAsync_OnInProcAccountCreate services.AddHandlerServicesDescriptor(); services.AddSingleton, InMemoryTestStore>(); services.AddSingleton, InMemoryTestStore>(); + services.AddSingleton, InMemoryTestStore>(); }); using var scope = test.Host.Services.CreateScope(); var mediator = scope.ServiceProvider.GetRequiredService(); @@ -119,6 +120,7 @@ public async Task Mediator_ShouldInvoke_CreatePlatformProviderAsyncLambda_OnInPr services.AddHandlerServicesDescriptor(); services.AddSingleton, InMemoryTestStore>(); services.AddSingleton, InMemoryTestStore>(); + services.AddSingleton, InMemoryTestStore>(); }); var scope = test.Host.Services.CreateScope(); var mediator = scope.ServiceProvider.GetRequiredService(); @@ -153,6 +155,7 @@ public async Task QueryTest() services.AddHandlerServicesDescriptor(); services.AddSingleton, InMemoryTestStore>(); services.AddSingleton, InMemoryTestStore>(); + services.AddSingleton, InMemoryTestStore>(); }); using var scope = test.Host.Services.CreateScope(); var mediator = scope.ServiceProvider.GetRequiredService(); diff --git a/test/Savvyio.Extensions.Dispatchers.Tests/SavvyioOptionsExtensionsTest.cs b/test/Savvyio.Extensions.Dispatchers.Tests/SavvyioOptionsExtensionsTest.cs new file mode 100644 index 00000000..0c5e603d --- /dev/null +++ b/test/Savvyio.Extensions.Dispatchers.Tests/SavvyioOptionsExtensionsTest.cs @@ -0,0 +1,42 @@ +using System.Linq; +using Codebelt.Extensions.Xunit; +using Savvyio.Commands; +using Savvyio.Domain; +using Savvyio.EventDriven; +using Savvyio.Queries; +using Xunit; + +namespace Savvyio.Extensions +{ + public class SavvyioOptionsExtensionsTest : Test + { + public SavvyioOptionsExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void UseAutomaticDispatcherDiscovery_ShouldUseCallingAssembly_WhenBruteAssemblyScanningIsFalse() + { + var options = new SavvyioOptions().UseAutomaticDispatcherDiscovery(); + + Assert.Empty(options.DispatcherServiceTypes); + Assert.Empty(options.DispatcherImplementationTypes); + } + + [Fact] + public void UseAutomaticHandlerDiscovery_ShouldUseCallingAssembly_WhenBruteAssemblyScanningIsFalse() + { + var options = new SavvyioOptions().UseAutomaticHandlerDiscovery(); + + Assert.Collection(options.HandlerServiceTypes.OrderBy(type => type.Name), + type => Assert.Equal(typeof(ICommandHandler), type), + type => Assert.Equal(typeof(IDomainEventHandler), type), + type => Assert.Equal(typeof(IIntegrationEventHandler), type), + type => Assert.Equal(typeof(IQueryHandler), type)); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorCommandHandler"); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorDomainEventHandler"); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorIntegrationEventHandler"); + Assert.Contains(options.HandlerImplementationTypes, type => type.Name == "TestMediatorQueryHandler"); + } + } +} diff --git a/test/Savvyio.Extensions.EFCore.Tests/DomainEventDispatcherExtensionsTest.cs b/test/Savvyio.Extensions.EFCore.Tests/DomainEventDispatcherExtensionsTest.cs new file mode 100644 index 00000000..332105b7 --- /dev/null +++ b/test/Savvyio.Extensions.EFCore.Tests/DomainEventDispatcherExtensionsTest.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Microsoft.EntityFrameworkCore; +using Savvyio.Assets; +using Savvyio.Assets.Domain; +using Savvyio.Assets.Domain.Events; +using Savvyio.Domain; +using Xunit; + +namespace Savvyio.Extensions.EFCore.Domain +{ + public class DomainEventDispatcherExtensionsTest : Test + { + public DomainEventDispatcherExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void DomainEventDispatcherExtensions_ShouldRaiseManySynchronously() + { + var dispatcher = new TrackingDomainEventDispatcher(); + var source = new EfCoreAggregateDataSource(null, new EfCoreDataSourceOptions + { + ContextConfigurator = b => b.UseInMemoryDatabase("dispatcher-" + Guid.NewGuid()), + ModelConstructor = mb => mb.AddAccount() + }); + var repository = new EfCoreRepository(source); + var id = Guid.NewGuid(); + + repository.Add(new Account(id, "Test", "test@unit.test")); + dispatcher.RaiseMany(source.DbContext); + + var tracked = source.DbContext.ChangeTracker.Entries>().Single().Entity; + + Assert.Single(dispatcher.Events); + Assert.IsType(dispatcher.Events.Single()); + Assert.Equal(id, ((AccountInitiated)dispatcher.Events.Single()).PlatformProviderId); + Assert.Empty(tracked.Events); + } + + [Fact] + public async Task EfCoreAggregateDataSource_ShouldSaveChangesWithoutDispatcher() + { + var id = Guid.NewGuid(); + var source = new EfCoreAggregateDataSource(null, new EfCoreDataSourceOptions + { + ContextConfigurator = b => b.UseInMemoryDatabase("aggregate-null-dispatcher-" + Guid.NewGuid()), + ModelConstructor = mb => mb.AddAccount() + }); + var repository = new EfCoreRepository(source); + + repository.Add(new Account(id, "Test", "test@unit.test")); + await source.SaveChangesAsync(); + + var sut = await repository.FindAllAsync(a => a.PlatformProviderId == id).SingleOrDefaultAsync(); + + Assert.NotNull(sut); + Assert.Equal(id, sut.PlatformProviderId); + } + + private sealed class TrackingDomainEventDispatcher : IDomainEventDispatcher + { + public List Events { get; } = new(); + + public void Raise(IDomainEvent request) + { + Events.Add(request); + } + + public Task RaiseAsync(IDomainEvent request, Action setup = null) + { + Events.Add(request); + return Task.CompletedTask; + } + } + } +} diff --git a/test/Savvyio.Extensions.EFCore.Tests/EfCoreDataStoreTest.cs b/test/Savvyio.Extensions.EFCore.Tests/EfCoreDataStoreTest.cs index 8f3e2567..12e10486 100644 --- a/test/Savvyio.Extensions.EFCore.Tests/EfCoreDataStoreTest.cs +++ b/test/Savvyio.Extensions.EFCore.Tests/EfCoreDataStoreTest.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Codebelt.Extensions.Xunit; using Microsoft.EntityFrameworkCore; @@ -93,5 +94,44 @@ public async Task EfCoreDataStore_ShouldUpdateObject() Assert.NotEqual(name, sut3.FullName); Assert.Equal(newName, sut4.FullName); } + + [Fact] + public async Task EfCoreDataStore_ShouldGetObjectById() + { + var id = Guid.NewGuid(); + var sut1 = new EfCoreDataSource(new EfCoreDataSourceOptions() + { + ContextConfigurator = b => b.UseInMemoryDatabase("Dummy_" + Guid.NewGuid()), + ModelConstructor = mb => mb.AddAccount() + }); + var sut2 = new EfCoreDataStore(sut1); + var dto = new Account(id, "Test", "test@unit.test"); + + await sut2.CreateAsync(dto); + + var sut3 = await sut2.GetByIdAsync(dto.Id); + var sut4 = await sut2.GetByIdAsync(long.MaxValue); + + Assert.Equal(dto.Id, sut3.Id); + Assert.Null(sut4); + } + + [Fact] + public async Task EfCoreDataStore_ShouldReturnAllObjectsWhenPredicateIsMissing() + { + var sut1 = new EfCoreDataSource(new EfCoreDataSourceOptions() + { + ContextConfigurator = b => b.UseInMemoryDatabase("Dummy_" + Guid.NewGuid()), + ModelConstructor = mb => mb.AddAccount() + }); + var sut2 = new EfCoreDataStore(sut1); + + await sut2.CreateAsync(new Account(Guid.NewGuid(), "Test1", "test1@unit.test")); + await sut2.CreateAsync(new Account(Guid.NewGuid(), "Test2", "test2@unit.test")); + + var sut3 = await sut2.FindAllAsync(); + + Assert.Equal(2, sut3.Count()); + } } } diff --git a/test/Savvyio.Extensions.EFCore.Tests/EfCoreRepositoryTest.cs b/test/Savvyio.Extensions.EFCore.Tests/EfCoreRepositoryTest.cs index 06686731..902a595e 100644 --- a/test/Savvyio.Extensions.EFCore.Tests/EfCoreRepositoryTest.cs +++ b/test/Savvyio.Extensions.EFCore.Tests/EfCoreRepositoryTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Codebelt.Extensions.Xunit; using Microsoft.EntityFrameworkCore; @@ -128,5 +129,51 @@ public async Task EfCoreRepository_ShouldUpdateEntity() s => Assert.Equal("Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker: Unchanged --> Modified", s), s => Assert.Equal("Microsoft.EntityFrameworkCore.ChangeTracking.ChangeTracker: Modified --> Unchanged", s)); } + + [Fact] + public async Task EfCoreRepository_ShouldGetEntityById() + { + var entity = new Account(Guid.NewGuid(), "Test", "test@unit.test"); + var sut1 = new EfCoreDataSource(new EfCoreDataSourceOptions() + { + ContextConfigurator = b => b.UseInMemoryDatabase("Dummy_" + Guid.NewGuid()), + ModelConstructor = mb => mb.AddAccount() + }); + var sut2 = new EfCoreRepository(sut1); + + sut2.Add(entity); + await sut1.SaveChangesAsync(); + + var sut3 = await sut2.GetByIdAsync(entity.Id); + var sut4 = await sut2.GetByIdAsync(long.MaxValue); + + Assert.Equal(entity.Id, sut3.Id); + Assert.Null(sut4); + } + + [Fact] + public async Task EfCoreRepository_ShouldAddAndRemoveRangeOfEntities() + { + var sut1 = new EfCoreDataSource(new EfCoreDataSourceOptions() + { + ContextConfigurator = b => b.UseInMemoryDatabase("Dummy_" + Guid.NewGuid()), + ModelConstructor = mb => mb.AddAccount() + }); + var sut2 = new EfCoreRepository(sut1); + var entities = new[] + { + new Account(Guid.NewGuid(), "Test1", "test1@unit.test"), + new Account(Guid.NewGuid(), "Test2", "test2@unit.test") + }; + + sut2.AddRange(entities); + await sut1.SaveChangesAsync(); + Assert.Equal(2, (await sut2.FindAllAsync()).Count()); + + sut2.RemoveRange(entities); + await sut1.SaveChangesAsync(); + + Assert.Empty(await sut2.FindAllAsync()); + } } } diff --git a/test/Savvyio.Extensions.NATS.Tests/Commands/NatsCommandQueueTest.cs b/test/Savvyio.Extensions.NATS.Tests/Commands/NatsCommandQueueTest.cs index 74c97c9d..2e78021b 100644 --- a/test/Savvyio.Extensions.NATS.Tests/Commands/NatsCommandQueueTest.cs +++ b/test/Savvyio.Extensions.NATS.Tests/Commands/NatsCommandQueueTest.cs @@ -1,5 +1,15 @@ using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Codebelt.Extensions.Xunit; +using Cuemon.Extensions; +using Cuemon.Extensions.IO; +using Cuemon.Extensions.Reflection; +using Moq; +using NATS.Client.Core; +using NATS.Client.JetStream; +using NATS.Client.JetStream.Models; using Savvyio.Commands; using Savvyio.Extensions.Text.Json; using Savvyio.Messaging; @@ -50,5 +60,289 @@ public void Constructor_ShouldThrow_WhenOptionsAreInvalid() { Assert.Throws(() => new NatsCommandQueue(_marshaller, _options)); } + + [Fact] + public void GetHealthCheckTarget_Should_Return_Connection() + { + var queue = new NatsCommandQueue(_marshaller, new NatsCommandQueueOptions + { + StreamName = "stream", + ConsumerName = "consumer", + Subject = "subject" + }); + + Assert.Same(queue.GetHealthCheckTarget(), queue.GetHealthCheckTarget()); + Assert.NotNull(queue.GetHealthCheckTarget()); + } + + [Fact] + public async Task SendAsync_WithEmptyMessages_ShouldCoverJetStreamContextCreation() + { + var queue = new NatsCommandQueue(_marshaller, new NatsCommandQueueOptions + { + StreamName = "stream", + ConsumerName = "consumer", + Subject = "subject" + }); + + await queue.SendAsync(Array.Empty>()); + + Assert.True(true); + } + + [Fact] + public async Task SendAsync_WithOneMessage_ShouldCoverLoopBodyBeforeConnectionFailure() + { + var queue = new NatsCommandQueue(_marshaller, new NatsCommandQueueOptions + { + StreamName = "stream", + ConsumerName = "consumer", + Subject = "subject", + NatsUrl = new Uri("nats://localhost:59999") + }); + + var message = new Message( + Guid.NewGuid().ToString("N"), + new Uri("urn:test"), + "test", + new TestCommand()); + + var ex = await Record.ExceptionAsync(() => queue.SendAsync(new IMessage[] { message })); + + Assert.NotNull(ex); + } + + [Fact] + public async Task SendAsync_ShouldPublishSerializedMessages() + { + var queue = new TestableNatsCommandQueue(_marshaller, CreateOptions()); + var message = CreateMessage(); + + await queue.SendAsync([message]); + + Assert.Equal("subject", queue.PublishedSubject); + Assert.Equal(message.GetType().ToFullNameIncludingAssemblyName(), queue.PublishedHeaders["type"]); + Assert.NotNull(queue.PublishedMessage); + } + + [Fact] + public async Task SendAsync_ShouldUseJetStreamContext_WhenPublishingMessage() + { + var context = new Mock(); + context.Setup(c => c.PublishAsync(It.IsAny(), It.IsAny(), null, null, It.IsAny(), It.IsAny())) + .Returns(new ValueTask(default(PubAckResponse))); + var queue = new ContextNatsCommandQueue(_marshaller, CreateOptions(), context.Object); + var message = CreateMessage(); + + await queue.SendAsync([message]); + + context.Verify(c => c.PublishAsync("subject", It.IsAny(), null, null, It.Is(headers => headers["type"] == message.GetType().ToFullNameIncludingAssemblyName()), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ReceiveAsync_ShouldDeserializeFetchedMessagesAndAcknowledge() + { + var options = CreateOptions(); + options.AutoAcknowledge = true; + options.Expires = TimeSpan.FromSeconds(30); + var message = CreateMessage(); + var queue = new TestableNatsCommandQueue(_marshaller, options, message); + + var received = new List>(); + await foreach (var item in queue.ReceiveAsync()) + { + received.Add(item); + } + + Assert.Single(received); + Assert.Equal(message.Id, received[0].Id); + Assert.Equal("stream", queue.StreamConfig.Name); + Assert.Equal("consumer", queue.ConsumerConfig.Name); + Assert.Equal(TimeSpan.FromSeconds(30), queue.FetchOptions.Expires); + Assert.Equal(TimeSpan.FromSeconds(5), queue.FetchOptions.IdleHeartbeat); + Assert.Equal(1, queue.AcknowledgedCount); + } + + [Fact] + public async Task ReceiveAsync_ShouldUseJetStreamContext_WhenCreatingConsumer() + { + var context = new Mock(); + context.Setup(c => c.CreateOrUpdateStreamAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Mock.Of()); + context.Setup(c => c.CreateOrUpdateConsumerAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Mock.Of()); + var queue = new ContextNatsCommandQueue(_marshaller, CreateOptions(), context.Object); + + var consumer = await queue.CallCreateConsumerAsync( + new StreamConfig("stream", ["subject"]), + new ConsumerConfig("consumer"), + CancellationToken.None); + + Assert.NotNull(consumer); + context.Verify(c => c.CreateOrUpdateStreamAsync(It.Is(config => config.Name == "stream"), It.IsAny()), Times.Once); + context.Verify(c => c.CreateOrUpdateConsumerAsync("stream", It.Is(config => config.Name == "consumer"), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ReceiveAsync_ShouldFetchMessagesFromConsumer() + { + var message = CreateMessage(); + var payload = JsonMarshaller.Default.Serialize(message).ToByteArray().ToBase64String(); + var headers = new NatsHeaders + { + { "type", message.GetType().ToFullNameIncludingAssemblyName() } + }; + var natsMessage = new NatsMsg("subject", "reply", payload.Length, headers, payload, Mock.Of(), default); + var jetStreamMessage = new NatsJSMsg(natsMessage, Mock.Of()); + var consumer = new Mock(); + consumer.Setup(c => c.FetchAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Returns(Yield>(jetStreamMessage)); + var queue = new ContextNatsCommandQueue(_marshaller, CreateOptions(), Mock.Of()); + + var fetched = await queue.CallFetchMessagesAsync(consumer.Object, new NatsJSFetchOpts(), CancellationToken.None); + + Assert.Equal(1, fetched.Count); + Assert.Equal(payload, fetched.Data); + } + + [Fact] + public async Task ReceiveAsync_WithPreCancelledToken_ShouldThrowException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var queue = new NatsCommandQueue(_marshaller, new NatsCommandQueueOptions + { + StreamName = "stream", + ConsumerName = "consumer", + Subject = "subject" + }); + + var exception = await Record.ExceptionAsync(async () => + { + await foreach (var _ in queue.ReceiveAsync(o => o.CancellationToken = cts.Token)) { } + }); + + Assert.NotNull(exception); + } + + private static NatsCommandQueueOptions CreateOptions() + { + return new NatsCommandQueueOptions + { + StreamName = "stream", + ConsumerName = "consumer", + Subject = "subject" + }; + } + + private static IMessage CreateMessage() + { + return new Message( + Guid.NewGuid().ToString("N"), + new Uri("urn:test"), + "test", + new TestCommand()); + } + + private sealed class TestableNatsCommandQueue : NatsCommandQueue + { + private readonly IMessage _message; + + public TestableNatsCommandQueue(IMarshaller marshaller, NatsCommandQueueOptions options, IMessage message = null) : base(marshaller, options) + { + _message = message; + } + + public string PublishedSubject { get; private set; } + + public string PublishedMessage { get; private set; } + + public NatsHeaders PublishedHeaders { get; private set; } + + public StreamConfig StreamConfig { get; private set; } + + public ConsumerConfig ConsumerConfig { get; private set; } + + public NatsJSFetchOpts FetchOptions { get; private set; } + + public int AcknowledgedCount { get; private set; } + + protected override Task PublishMessageAsync(INatsJSContext context, string subject, string message, NatsHeaders headers) + { + PublishedSubject = subject; + PublishedMessage = message; + PublishedHeaders = headers; + return Task.CompletedTask; + } + + protected override Task CreateConsumerAsync(StreamConfig streamConfig, ConsumerConfig consumerConfig, CancellationToken cancellationToken) + { + StreamConfig = streamConfig; + ConsumerConfig = consumerConfig; + return Task.FromResult(null); + } + + protected override async IAsyncEnumerable FetchMessagesAsync(INatsJSConsumer consumer, NatsJSFetchOpts options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + FetchOptions = options; + if (_message != null) + { + var payload = JsonMarshaller.Default.Serialize(_message).ToByteArray().ToBase64String(); + yield return new ReceivedNatsMessage(new NatsHeaders + { + { "type", _message.GetType().ToFullNameIncludingAssemblyName() } + }, payload, _ => + { + AcknowledgedCount++; + return Task.CompletedTask; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + } + } + + private sealed class ContextNatsCommandQueue : NatsCommandQueue + { + private readonly INatsJSContext _context; + + public ContextNatsCommandQueue(IMarshaller marshaller, NatsCommandQueueOptions options, INatsJSContext context) : base(marshaller, options) + { + _context = context; + } + + public Task CallCreateConsumerAsync(StreamConfig streamConfig, ConsumerConfig consumerConfig, CancellationToken cancellationToken) + { + return base.CreateConsumerAsync(streamConfig, consumerConfig, cancellationToken); + } + + public async Task<(int Count, string Data)> CallFetchMessagesAsync(INatsJSConsumer consumer, NatsJSFetchOpts options, CancellationToken cancellationToken) + { + var count = 0; + string data = null; + await foreach (var message in base.FetchMessagesAsync(consumer, options, cancellationToken)) + { + count++; + data = message.Data; + await Record.ExceptionAsync(() => message.AcknowledgeAsync(cancellationToken)).ConfigureAwait(false); + } + + return (count, data); + } + + protected override INatsJSContext CreateJetStreamContext() + { + return _context; + } + } + + private static async IAsyncEnumerable Yield(T item) + { + yield return item; + await Task.CompletedTask.ConfigureAwait(false); + } + + private record TestCommand : Command { } } } diff --git a/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusOptionsTest.cs b/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusOptionsTest.cs index e63ce76f..ad01e1db 100644 --- a/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusOptionsTest.cs +++ b/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusOptionsTest.cs @@ -1,17 +1,30 @@ -using Xunit; using Codebelt.Extensions.Xunit; +using Xunit; namespace Savvyio.Extensions.NATS.EventDriven { public class NatsEventBusOptionsTest : Test { - public NatsEventBusOptionsTest(ITestOutputHelper output) : base(output) { } + public NatsEventBusOptionsTest(ITestOutputHelper output) : base(output) + { + } [Fact] - public void ShouldInheritFromNatsMessageOptions() + public void Constructor_Should_Inherit_Defaults_From_Base_Type() { var options = new NatsEventBusOptions(); + Assert.IsAssignableFrom(options); + Assert.Equal(new System.Uri("nats://127.0.0.1:4222"), options.NatsUrl); + Assert.Null(options.Subject); + } + + [Fact] + public void ValidateOptions_Should_Require_Subject() + { + var options = new NatsEventBusOptions(); + + Assert.Throws(() => options.ValidateOptions()); } } } diff --git a/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusTest.cs b/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusTest.cs index c39e2e43..b78e0823 100644 --- a/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusTest.cs +++ b/test/Savvyio.Extensions.NATS.Tests/EventDriven/NatsEventBusTest.cs @@ -1,7 +1,13 @@ using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; using Codebelt.Extensions.Xunit; +using Cuemon.Extensions; +using Cuemon.Extensions.IO; +using Cuemon.Extensions.Reflection; +using NATS.Client.Core; using Savvyio.EventDriven; -using Savvyio.Extensions.NATS.Commands; using Savvyio.Extensions.NATS.EventDriven; using Savvyio.Extensions.Text.Json; using Savvyio.Messaging; @@ -51,5 +57,136 @@ public void Constructor_ShouldThrow_WhenOptionsAreInvalid() { Assert.Throws(() => new NatsEventBus(_marshaller, _options)); } + + [Fact] + public void GetHealthCheckTarget_Should_Return_Connection() + { + var bus = new NatsEventBus(_marshaller, new NatsEventBusOptions + { + Subject = "subject" + }); + + Assert.Same(bus.GetHealthCheckTarget(), bus.GetHealthCheckTarget()); + Assert.NotNull(bus.GetHealthCheckTarget()); + } + + [Fact] + public async Task PublishAsync_WithPreCancelledToken_ShouldThrowOperationCanceledException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var bus = new NatsEventBus(_marshaller, new NatsEventBusOptions { Subject = "subject" }); + var message = new Message( + Guid.NewGuid().ToString("N"), + new Uri("urn:test"), + "test.event", + new TestIntegrationEvent()); + + var ex = await Record.ExceptionAsync( + () => bus.PublishAsync(message, o => o.CancellationToken = cts.Token)); + + Assert.NotNull(ex); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public async Task PublishAsync_ShouldPublishSerializedMessage() + { + var bus = new TestableNatsEventBus(_marshaller, new NatsEventBusOptions { Subject = "subject" }); + var message = CreateMessage(); + + await bus.PublishAsync(message); + + Assert.Equal("subject", bus.PublishedSubject); + Assert.Equal(message.GetType().ToFullNameIncludingAssemblyName(), bus.PublishedHeaders["type"]); + Assert.NotNull(bus.PublishedMessage); + } + + [Fact] + public async Task SubscribeAsync_WithPreCancelledToken_ShouldThrowOperationCanceledException() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var bus = new NatsEventBus(_marshaller, new NatsEventBusOptions { Subject = "subject" }); + + var ex = await Record.ExceptionAsync( + () => bus.SubscribeAsync( + (msg, ct) => Task.CompletedTask, + o => o.CancellationToken = cts.Token)); + + Assert.NotNull(ex); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public async Task SubscribeAsync_ShouldDeserializeMessagesAndInvokeHandler() + { + var message = CreateMessage(); + var bus = new TestableNatsEventBus(_marshaller, new NatsEventBusOptions { Subject = "subject" }, message); + IMessage received = null; + + await bus.SubscribeAsync((msg, _) => + { + received = msg; + return Task.CompletedTask; + }); + + Assert.NotNull(received); + Assert.Equal(message.Id, received.Id); + Assert.Equal("subject", bus.SubscribedSubject); + } + + private static IMessage CreateMessage() + { + return new Message( + Guid.NewGuid().ToString("N"), + new Uri("urn:test"), + "test.event", + new TestIntegrationEvent()); + } + + private sealed class TestableNatsEventBus : NatsEventBus + { + private readonly IMessage _message; + + public TestableNatsEventBus(IMarshaller marshaller, NatsEventBusOptions options, IMessage message = null) : base(marshaller, options) + { + _message = message; + } + + public string PublishedSubject { get; private set; } + + public string PublishedMessage { get; private set; } + + public NatsHeaders PublishedHeaders { get; private set; } + + public string SubscribedSubject { get; private set; } + + protected override Task PublishMessageAsync(string subject, string message, NatsHeaders headers, CancellationToken cancellationToken) + { + PublishedSubject = subject; + PublishedMessage = message; + PublishedHeaders = headers; + return Task.CompletedTask; + } + + protected override async IAsyncEnumerable SubscribeMessagesAsync(string subject, NatsSubOpts options, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + SubscribedSubject = subject; + if (_message != null) + { + yield return new ReceivedNatsMessage(new NatsHeaders + { + { "type", _message.GetType().ToFullNameIncludingAssemblyName() } + }, JsonMarshaller.Default.Serialize(_message).ToByteArray().ToBase64String()); + } + + await Task.CompletedTask.ConfigureAwait(false); + } + } + + private record TestIntegrationEvent : IntegrationEvent { } } } diff --git a/test/Savvyio.Extensions.NATS.Tests/NatsMessageOptionsTest.cs b/test/Savvyio.Extensions.NATS.Tests/NatsMessageOptionsTest.cs index d05fb1d1..ce9d699b 100644 --- a/test/Savvyio.Extensions.NATS.Tests/NatsMessageOptionsTest.cs +++ b/test/Savvyio.Extensions.NATS.Tests/NatsMessageOptionsTest.cs @@ -1,5 +1,6 @@  using System; using Codebelt.Extensions.Xunit; +using Cuemon; using Xunit; namespace Savvyio.Extensions.NATS @@ -44,5 +45,16 @@ public void ValidateOptions_ShouldNotThrow_WhenValid() var ex = Record.Exception(() => options.ValidateOptions()); Assert.Null(ex); } + + [Fact] + public void Validator_Should_Wrap_Invalid_Options() + { + var options = new NatsMessageOptions(); + + var exception = Assert.Throws(() => Validator.ThrowIfInvalidOptions(options)); + + Assert.Equal($"{nameof(NatsMessageOptions)} are not in a valid state. (Parameter '{nameof(options)}')", exception.Message); + Assert.IsType(exception.InnerException); + } } } diff --git a/test/Savvyio.Extensions.NATS.Tests/NatsMessageTest.cs b/test/Savvyio.Extensions.NATS.Tests/NatsMessageTest.cs index cc06b895..1c1b182c 100644 --- a/test/Savvyio.Extensions.NATS.Tests/NatsMessageTest.cs +++ b/test/Savvyio.Extensions.NATS.Tests/NatsMessageTest.cs @@ -1,37 +1,22 @@ using System; -using System.IO; using System.Threading.Tasks; using Codebelt.Extensions.Xunit; -using Xunit; -using Savvyio.Extensions.NATS; -using NATS.Client.Core; -using System.Threading; using NATS.Net; +using Savvyio.Extensions.Text.Json; +using Xunit; namespace Savvyio.Extensions.NATS { - // Minimal fake marshaller for testing - public class FakeMarshaller : IMarshaller - { - public Stream Serialize(TValue value) => new MemoryStream(); - public Stream Serialize(object value, Type inputType) => new MemoryStream(); - public TValue Deserialize(Stream data) => default; - public object Deserialize(Stream data, Type returnType) => null; - } - - // Minimal concrete NatsMessage for testing public class TestNatsMessage : NatsMessage { public TestNatsMessage(IMarshaller marshaller, NatsMessageOptions options) : base(marshaller, options) { } - // Expose protected OnDisposeManagedResourcesAsync for testing - public async Task CallOnDisposeManagedResourcesAsync() + public Task CallOnDisposeManagedResourcesAsync() { - await DisposeAsync(); + return DisposeAsync().AsTask(); } - // Expose protected NatsClient for assertions public NatsClient ExposedNatsClient => NatsClient; public IMarshaller ExposedMarshaller => Marshaller; } @@ -43,13 +28,13 @@ public NatsMessageTest(ITestOutputHelper output) : base(output) { } [Fact] public void Constructor_ShouldInitializeProperties() { - var marshaller = new FakeMarshaller(); + var marshaller = JsonMarshaller.Default; var options = new NatsMessageOptions { Subject = "foo" }; var sut = new TestNatsMessage(marshaller, options); Assert.NotNull(sut.ExposedNatsClient); - Assert.Equal(marshaller, sut.ExposedMarshaller); + Assert.Same(marshaller, sut.ExposedMarshaller); } [Fact] @@ -62,24 +47,19 @@ public void Constructor_ShouldThrow_WhenMarshallerIsNull() [Fact] public void Constructor_ShouldThrow_WhenOptionsIsNull() { - var marshaller = new FakeMarshaller(); - Assert.Throws(() => new TestNatsMessage(marshaller, null)); + Assert.Throws(() => new TestNatsMessage(JsonMarshaller.Default, null)); } [Fact] public void Constructor_ShouldThrow_WhenOptionsInvalid() { - var marshaller = new FakeMarshaller(); - var options = new NatsMessageOptions { Subject = null }; // Invalid: Subject is null - Assert.Throws(() => new TestNatsMessage(marshaller, options)); + Assert.Throws(() => new TestNatsMessage(JsonMarshaller.Default, new NatsMessageOptions())); } [Fact] public void GetHealthCheckTarget_ShouldReturnNatsConnection() { - var marshaller = new FakeMarshaller(); - var options = new NatsMessageOptions { Subject = "foo" }; - var sut = new TestNatsMessage(marshaller, options); + var sut = new TestNatsMessage(JsonMarshaller.Default, new NatsMessageOptions { Subject = "foo" }); var connection = sut.GetHealthCheckTarget(); @@ -90,9 +70,7 @@ public void GetHealthCheckTarget_ShouldReturnNatsConnection() [Fact] public async Task OnDisposeManagedResourcesAsync_ShouldDisposeNatsClient() { - var marshaller = new FakeMarshaller(); - var options = new NatsMessageOptions { Subject = "foo" }; - var sut = new TestNatsMessage(marshaller, options); + var sut = new TestNatsMessage(JsonMarshaller.Default, new NatsMessageOptions { Subject = "foo" }); // NatsClient should not be disposed before Assert.False(sut.Disposed); diff --git a/test/Savvyio.Extensions.NATS.Tests/Savvyio.Extensions.NATS.Tests.csproj b/test/Savvyio.Extensions.NATS.Tests/Savvyio.Extensions.NATS.Tests.csproj index 3227d756..0107eb82 100644 --- a/test/Savvyio.Extensions.NATS.Tests/Savvyio.Extensions.NATS.Tests.csproj +++ b/test/Savvyio.Extensions.NATS.Tests/Savvyio.Extensions.NATS.Tests.csproj @@ -9,4 +9,8 @@ + + + + diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/AggregateRootConverterTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/AggregateRootConverterTest.cs new file mode 100644 index 00000000..056db83d --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/AggregateRootConverterTest.cs @@ -0,0 +1,104 @@ +using System; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Savvyio.Assets.Domain; +using Savvyio.Domain; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json.Converters +{ + public class AggregateRootConverterTest : Test + { + public AggregateRootConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AggregateRootConverter_ShouldDeserializeUsingMatchingConstructor() + { + var id = Guid.NewGuid(); + var settings = CreateSettings(); + var json = JsonConvert.SerializeObject(new MatchingAggregate(id, "Jane Doe"), settings); + + var sut = JsonConvert.DeserializeObject(json, settings); + + Assert.NotNull(sut); + Assert.Equal(id, sut.Id); + Assert.Equal("Jane Doe", sut.Name); + } + + [Fact] + public void AggregateRootConverter_ShouldDeserializeUsingDefaultConstructorFallback() + { + var id = Guid.NewGuid(); + var providerId = new PlatformProviderId(Guid.NewGuid()); + var settings = CreateSettings(); + var json = JsonConvert.SerializeObject(new FallbackAggregate(id, "Jane Doe") + { + PlatformProviderId = providerId + }, settings); + + var sut = JsonConvert.DeserializeObject(json, settings); + + Assert.NotNull(sut); + Assert.Equal(id, sut.Id); + Assert.Equal("Jane Doe", sut.Name); + Assert.Equal(providerId, sut.PlatformProviderId); + } + + [Fact] + public void AggregateRootConverter_ShouldThrowWhenNoSuitableConstructorExists() + { + var settings = CreateSettings(); + var json = JsonConvert.SerializeObject(new UnsupportedAggregate("Jane Doe"), settings); + + var ex = Assert.Throws(() => JsonConvert.DeserializeObject(json, settings)); + + Assert.StartsWith("Unable to deserialize", ex.Message); + } + + private static JsonSerializerSettings CreateSettings() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(new AggregateRootConverter()); + settings.Converters.Add(new SingleValueObjectConverter()); + return settings; + } + + private sealed class MatchingAggregate : AggregateRoot + { + public MatchingAggregate(Guid id, string name) : base(id) + { + Name = name; + } + + public string Name { get; } + } + + private sealed class FallbackAggregate : AggregateRoot + { + public FallbackAggregate() + { + } + + public FallbackAggregate(Guid id, string name) : base(id) + { + Name = name; + } + + public string Name { get; set; } = string.Empty; + + public PlatformProviderId PlatformProviderId { get; set; } + } + + private sealed class UnsupportedAggregate : AggregateRoot + { + public UnsupportedAggregate(string name) + { + Name = name; + } + + public string Name { get; } + } + } +} diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Commands/Messaging/Cryptography/MessageExtensionsTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Commands/Messaging/Cryptography/MessageExtensionsTest.cs index ffcdcd79..b1032c49 100644 --- a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Commands/Messaging/Cryptography/MessageExtensionsTest.cs +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Commands/Messaging/Cryptography/MessageExtensionsTest.cs @@ -53,5 +53,27 @@ public void EncloseToSignedMessage_ShouldSerializeAndDeserialize_WithSignature() Assert.Equal("""{"id":"2d4030d32a254ee8a27046e5bafe696a","source":"https://fancy.api/members","type":"CreateMemberCommand","time":"2023-11-16T23:24:17.8414532Z","data":{"name":"Jane Doe","age":21,"emailAddress":"jd@office.com","metadata":{"memberType":"Savvyio.Assets.Commands.CreateMemberCommand, Savvyio.Assets.Tests","correlationId":"3eefdef050c340bfba100bd49c58c181"}},"signature":"0cb0f1b239d9a31fad1dcbf9819e105d841f860c818f389d487d0693ed014c5b"}""", jsonString); } + + [Fact] + public void EncloseToSignedMessage_ShouldDeserializeSignedMessageInterface_WithSignature() + { + var utc = DateTime.Parse("2023-11-16T23:24:17.8414532Z", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal); + var sut1 = new CreateMemberCommand("Jane Doe", 21, "jd@office.com").SetCorrelationId("3eefdef050c340bfba100bd49c58c181"); + var sut2 = sut1.ToMessage("https://fancy.api/members".ToUri(), nameof(CreateMemberCommand), o => + { + o.MessageId = "2d4030d32a254ee8a27046e5bafe696a"; + o.Time = utc; + }).Sign(NewtonsoftJsonMarshaller.Default, o => o.SignatureSecret = new byte[] { 1, 2, 3 }); + + var json = NewtonsoftJsonMarshaller.Default.Serialize(sut2); + var sut3 = NewtonsoftJsonMarshaller.Default.Deserialize>(json); + + Assert.IsType>(sut3); + Assert.Equal(sut2.Id, sut3.Id); + Assert.Equal(sut2.Source, sut3.Source); + Assert.Equal(sut2.Type, sut3.Type); + Assert.Equal(sut2.Signature, sut3.Signature); + Assert.Equivalent(sut2.Data, sut3.Data, true); + } } } diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/ValueObjectConverterTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/ValueObjectConverterTest.cs new file mode 100644 index 00000000..0f29af40 --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/Converters/ValueObjectConverterTest.cs @@ -0,0 +1,165 @@ +using System; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Savvyio.Domain; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json.Converters +{ + public class ValueObjectConverterTest : Test + { + public ValueObjectConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void WriteJson_ShouldSerializeValueObjectWithSimpleProperties() + { + var sut = new SimplePoint(3, 7); + var settings = CreateSettings(); + + var json = JsonConvert.SerializeObject(sut, settings); + + TestOutput.WriteLine(json); + + Assert.Contains("X", json); + Assert.Contains("Y", json); + Assert.Contains("3", json); + Assert.Contains("7", json); + } + + [Fact] + public void WriteJson_ShouldSerializeSingleValueObjectProperty() + { + var sut = new LabeledPoint("origin", new PointLabel("O")); + var settings = CreateSettings(); + + var json = JsonConvert.SerializeObject(sut, settings); + + TestOutput.WriteLine(json); + + Assert.Contains("Name", json); + Assert.Contains("Label", json); + Assert.Contains("origin", json); + Assert.Contains("O", json); + } + + [Fact] + public void ReadJson_ShouldDeserializeSingleValueObjectScalarWhenNested() + { + var original = new LabeledPoint("center", new PointLabel("C")); + var settings = CreateSettings(); + + var json = JsonConvert.SerializeObject(original, settings); + TestOutput.WriteLine(json); + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.NotNull(result); + Assert.Equal("center", result.Name); + Assert.NotNull(result.Label); + Assert.Equal("C", result.Label.Value); + } + + [Fact] + public void ReadJson_ShouldDeserializeComplexValueObjectWithMatchingCtor() + { + var original = new SimplePoint(1, 2); + var settings = CreateSettings(); + + var json = JsonConvert.SerializeObject(original, settings); + TestOutput.WriteLine(json); + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.NotNull(result); + Assert.Equal(1, result.X); + Assert.Equal(2, result.Y); + } + + [Fact] + public void ReadJson_ShouldDeserializeValueObjectWithDefaultCtor() + { + var original = new MutablePoint { X = 5, Y = 6 }; + var settings = CreateSettings(); + + var json = JsonConvert.SerializeObject(original, settings); + TestOutput.WriteLine(json); + + var result = JsonConvert.DeserializeObject(json, settings); + + Assert.NotNull(result); + Assert.Equal(5, result.X); + Assert.Equal(6, result.Y); + } + + [Fact] + public void ReadJson_ShouldThrowInvalidOperationException_WhenNoSuitableCtorExists() + { + var settings = CreateSettings(); + var json = JsonConvert.SerializeObject(new NoSuitableCtorPoint("a", "b"), settings); + TestOutput.WriteLine(json); + + var ex = Assert.Throws(() => + JsonConvert.DeserializeObject(json, settings)); + + Assert.Contains("Unable to deserialize", ex.Message); + } + + private static JsonSerializerSettings CreateSettings() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(new ValueObjectConverter()); + return settings; + } + + private record SimplePoint : ValueObject + { + public SimplePoint(int x, int y) + { + X = x; + Y = y; + } + + public int X { get; } + public int Y { get; } + } + + private sealed record PointLabel : SingleValueObject + { + public PointLabel(string value) : base(value) { } + } + + private record LabeledPoint : ValueObject + { + public LabeledPoint(string name, PointLabel label) + { + Name = name; + Label = label; + } + + public string Name { get; } + public PointLabel Label { get; } + } + + private record MutablePoint : ValueObject + { + public MutablePoint() { } + + public int X { get; set; } + public int Y { get; set; } + } + + private record NoSuitableCtorPoint : ValueObject + { + public NoSuitableCtorPoint(string a, string b, string c = null) + { + A = a; + B = b; + } + + public string A { get; } + public string B { get; } + } + } +} diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/EventDriven/Messaging/CloudEvents/IntegrationEventExtensionsTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/EventDriven/Messaging/CloudEvents/IntegrationEventExtensionsTest.cs index 6668682a..3872f12b 100644 --- a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/EventDriven/Messaging/CloudEvents/IntegrationEventExtensionsTest.cs +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/EventDriven/Messaging/CloudEvents/IntegrationEventExtensionsTest.cs @@ -95,5 +95,26 @@ public void ToMessage_ToCloudEvent_ShouldSerializeAndDeserialize_MemberCreated_U } """.ReplaceLineEndings(), jsonString); } + + [Fact] + public void ToMessage_ToCloudEvent_ShouldSerializeAndDeserialize_ExtensionAttributes_UsingInterface() + { + var utc = DateTime.Parse("2023-11-16T23:24:17.8414532Z", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal); + var sut1 = new MemberCreated("Jane Doe", "jd@office.com").SetEventId("69bccf3b1117425397c5ed9ed757bb0f").SetTimestamp(utc); + var sut2 = sut1.ToMessage("https://fancy.api/members".ToUri(), nameof(MemberCreated), o => + { + o.MessageId = "2d4030d32a254ee8a27046e5bafe696a"; + o.Time = utc; + }).ToCloudEvent(); + sut2["tenant"] = "acme"; + sut2["sequence"] = 42; + + var json = NewtonsoftJsonMarshaller.Default.Serialize(sut2); + var sut3 = NewtonsoftJsonMarshaller.Default.Deserialize>(json); + + Assert.Equal("acme", sut3["tenant"]); + Assert.Equal(42L, sut3["sequence"]); + Assert.Equivalent(sut2.Data, sut3.Data, true); + } } } diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/JsonConverterExtensionsTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/JsonConverterExtensionsTest.cs new file mode 100644 index 00000000..548aa60a --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/JsonConverterExtensionsTest.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Savvyio.Extensions.Newtonsoft.Json.Converters; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json +{ + public class JsonConverterExtensionsTest : Test + { + public JsonConverterExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void JsonConverterExtensions_ShouldAddExpectedConverters() + { + ICollection sut = new List(); + + var chained = sut.AddValueObjectConverter() + .AddAggregateRootConverter() + .AddMetadataDictionaryConverter() + .AddRequestConverter() + .AddMessageConverter() + .AddSingleValueObjectConverter(); + + Assert.Same(sut, chained); + Assert.Contains(sut, c => c is ValueObjectConverter); + Assert.Contains(sut, c => c is AggregateRootConverter); + Assert.Contains(sut, c => c is RequestConverter); + Assert.Contains(sut, c => c is MessageConverter); + Assert.Contains(sut, c => c is SingleValueObjectConverter); + Assert.Equal(6, sut.Count); + } + + [Fact] + public void AddMetadataDictionaryConverter_ShouldDeserializeMetadataDictionary() + { + var settings = new JsonSerializerSettings(); + settings.Converters.AddMetadataDictionaryConverter(); + + var sut = JsonConvert.DeserializeObject("{\"memberType\":\"custom\",\"attempts\":42}", settings); + + Assert.IsType(sut); + Assert.Equal("custom", sut["memberType"]); + Assert.Equal(42L, sut["attempts"]); + } + } +} diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/JsonSerializerExtensionsTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/JsonSerializerExtensionsTest.cs new file mode 100644 index 00000000..e6d849e1 --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/JsonSerializerExtensionsTest.cs @@ -0,0 +1,38 @@ +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json +{ + public class JsonSerializerExtensionsTest : Test + { + public JsonSerializerExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void JsonSerializerExtensions_ShouldResolveKeysWithDefaultNamingStrategy() + { + var sut = JsonSerializer.CreateDefault(); + + Assert.Equal("MemberType", sut.ResolvePropertyKeyByConvention("MemberType")); + Assert.Equal("MemberType", sut.ResolveDictionaryKeyByConvention("MemberType")); + } + + [Fact] + public void JsonSerializerExtensions_ShouldResolveKeysWithCamelCaseNamingStrategy() + { + var sut = JsonSerializer.CreateDefault(new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver + { + NamingStrategy = new CamelCaseNamingStrategy() + } + }); + + Assert.Equal("memberType", sut.ResolvePropertyKeyByConvention("MemberType")); + Assert.Equal("MemberType", sut.ResolveDictionaryKeyByConvention("MemberType")); + } + } +} diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/NewtonsoftJsonMarshallerTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/NewtonsoftJsonMarshallerTest.cs new file mode 100644 index 00000000..cd96cf9e --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/NewtonsoftJsonMarshallerTest.cs @@ -0,0 +1,50 @@ +using System; +using System.IO; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Savvyio.Assets.Commands; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json +{ + public class NewtonsoftJsonMarshallerTest : Test + { + public NewtonsoftJsonMarshallerTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void NewtonsoftJsonMarshaller_Default_ShouldUseCompactFormatting() + { + using var stream = NewtonsoftJsonMarshaller.Default.Serialize(new CreateMemberCommand("Jane Doe", 21, "jd@office.com")); + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + Assert.DoesNotContain(Environment.NewLine, json); + } + + [Fact] + public void NewtonsoftJsonMarshaller_Create_ShouldHonorConfiguredFormatting() + { + var sut = NewtonsoftJsonMarshaller.Create(o => o.Settings.Formatting = Formatting.Indented); + + using var stream = sut.Serialize(new CreateMemberCommand("Jane Doe", 21, "jd@office.com")); + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + Assert.Contains(Environment.NewLine, json); + } + + [Fact] + public void NewtonsoftJsonMarshaller_ShouldRoundtripUsingTypeBasedMethods() + { + var marshaller = new NewtonsoftJsonMarshaller(); + using var stream = marshaller.Serialize(new CreateMemberCommand("Jane Doe", 21, "jd@office.com"), typeof(CreateMemberCommand)); + + var sut = marshaller.Deserialize(stream, typeof(CreateMemberCommand)); + + Assert.IsType(sut); + Assert.Equal("Jane Doe", ((CreateMemberCommand)sut).Name); + } + } +} diff --git a/test/Savvyio.Extensions.Newtonsoft.Json.Tests/RequestConverterTest.cs b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/RequestConverterTest.cs new file mode 100644 index 00000000..efef9e31 --- /dev/null +++ b/test/Savvyio.Extensions.Newtonsoft.Json.Tests/RequestConverterTest.cs @@ -0,0 +1,68 @@ +using System; +using System.IO; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Savvyio; +using Savvyio.Assets.Commands; +using Xunit; + +namespace Savvyio.Extensions.Newtonsoft.Json.Converters +{ + public class RequestConverterTest : Test + { + public RequestConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void RequestConverter_ShouldExposeReadOnlyCapabilities() + { + var sut = new RequestConverter(); + + Assert.False(sut.CanWrite); + Assert.True(sut.CanConvert(typeof(CreateMemberCommand))); + Assert.False(sut.CanConvert(typeof(string))); + } + + [Fact] + public void RequestConverter_ShouldThrowWhenWritingJson() + { + var sut = new RequestConverter(); + using var writer = new JsonTextWriter(new StringWriter()); + + Assert.Throws(() => sut.WriteJson(writer, new CreateMemberCommand("Jane Doe", 21, "jd@office.com"), JsonSerializer.CreateDefault())); + } + + [Fact] + public void RequestConverter_ShouldRehydrateAutoPropertyRequests() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(new RequestConverter()); + + var sut = JsonConvert.DeserializeObject("{\"name\":\"Jane Doe\",\"age\":21,\"emailAddress\":\"jd@office.com\"}", settings); + + Assert.NotNull(sut); + Assert.Equal("Jane Doe", sut.Name); + Assert.Equal((byte)21, sut.Age); + Assert.Equal("jd@office.com", sut.EmailAddress); + } + + [Fact] + public void RequestConverter_ShouldFailWhenNoSupportedBackingFieldExists() + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(new RequestConverter()); + + var ex = Assert.Throws(() => JsonConvert.DeserializeObject("{\"name\":\"Jane Doe\"}", settings)); + + Assert.StartsWith("This deserializer only supports rehydration", ex.Message); + } + + private sealed class UnsupportedRequest : IRequest + { + private readonly string _name = string.Empty; + + public string Name => _name; + } + } +} diff --git a/test/Savvyio.Extensions.QueueStorage.Tests/AzureQueueOptionsTest.cs b/test/Savvyio.Extensions.QueueStorage.Tests/AzureQueueOptionsTest.cs index 2472ef3c..7844d133 100644 --- a/test/Savvyio.Extensions.QueueStorage.Tests/AzureQueueOptionsTest.cs +++ b/test/Savvyio.Extensions.QueueStorage.Tests/AzureQueueOptionsTest.cs @@ -1,5 +1,7 @@ using System; +using Azure; using Azure.Identity; +using Azure.Storage; using Codebelt.Extensions.Xunit; using Cuemon; using Savvyio.Extensions.QueueStorage.Commands; @@ -101,20 +103,48 @@ public void ValidateOptions_ThrowsInvalidOperationException_WhenStorageNameAndQu Assert.IsType(sut3.InnerException); } + [Fact] + public void Credential_Setters_Should_Be_Mutually_Exclusive() + { + var sut = new AzureQueueOptions + { + SasCredential = new AzureSasCredential("sig") + }; + + Assert.NotNull(sut.SasCredential); + Assert.Null(sut.Credential); + Assert.Null(sut.StorageKeyCredential); + + sut.StorageKeyCredential = new StorageSharedKeyCredential("account", Convert.ToBase64String(new byte[32])); + Assert.NotNull(sut.StorageKeyCredential); + Assert.Null(sut.Credential); + Assert.Null(sut.SasCredential); + + sut.Credential = new DefaultAzureCredential(); + Assert.NotNull(sut.Credential); + Assert.Null(sut.SasCredential); + Assert.Null(sut.StorageKeyCredential); + } + [Fact] public void PostConfigureClient_ShouldInvokeCallback() { var callbackInvoked = false; - var client = new AzureCommandQueue(JsonMarshaller.Default, new AzureQueueOptions() + var options = new AzureQueueOptions() { ConnectionString = "UseDevelopmentStorage=true", QueueName = "testqueue" - }.PostConfigureClient(c => + }; + + var returned = options.PostConfigureClient(c => { callbackInvoked = true; - Assert.Equal(c.Name, "testqueue"); - })); + Assert.Equal("testqueue", c.Name); + }); + + _ = new AzureCommandQueue(JsonMarshaller.Default, options); + Assert.Same(options, returned); Assert.True(callbackInvoked); } } diff --git a/test/Savvyio.Extensions.QueueStorage.Tests/Commands/AzureCommandQueueTest.cs b/test/Savvyio.Extensions.QueueStorage.Tests/Commands/AzureCommandQueueTest.cs index 48a911b7..787e4879 100644 --- a/test/Savvyio.Extensions.QueueStorage.Tests/Commands/AzureCommandQueueTest.cs +++ b/test/Savvyio.Extensions.QueueStorage.Tests/Commands/AzureCommandQueueTest.cs @@ -2,28 +2,31 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Azure; +using Azure.Identity; +using Azure.Storage; using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; using Codebelt.Extensions.Xunit; +using Cuemon.Extensions; +using Cuemon.Extensions.IO; +using Cuemon.Extensions.Reflection; using Moq; -using Moq.Protected; using Savvyio.Commands; -using Savvyio.Extensions.QueueStorage.Commands; +using Savvyio.Extensions.Text.Json; using Savvyio.Messaging; using Xunit; namespace Savvyio.Extensions.QueueStorage.Commands { - /// - /// Unit tests for . - /// public class AzureCommandQueueTest : Test { - private readonly Mock _marshallerMock; + private readonly IMarshaller _marshaller; private readonly AzureQueueOptions _options; public AzureCommandQueueTest(ITestOutputHelper output) : base(output) { - _marshallerMock = new Mock(MockBehavior.Strict); + _marshaller = JsonMarshaller.Default; _options = new AzureQueueOptions { QueueName = "test-queue", StorageAccountName = "test-account" }; } @@ -36,57 +39,142 @@ public void Constructor_ShouldThrowArgumentNullException_WhenMarshallerIsNull() [Fact] public void Constructor_ShouldThrowArgumentNullException_WhenOptionsIsNull() { - Assert.Throws(() => new AzureCommandQueue(_marshallerMock.Object, null)); + Assert.Throws(() => new AzureCommandQueue(_marshaller, null)); + } + + [Fact] + public void Constructor_ShouldThrowArgumentException_WhenOptionsAreInvalid() + { + Assert.Throws(() => new AzureCommandQueue(_marshaller, new AzureQueueOptions { Credential = null })); } [Fact] public void Constructor_ShouldCreateInstance_WhenArgumentsAreValid() { - var queue = new AzureCommandQueue(_marshallerMock.Object, _options); + var queue = new AzureCommandQueue(_marshaller, _options); Assert.NotNull(queue); } [Fact] - public async Task SendAsync_ShouldReturnTask() + public async Task SendAsync_ShouldThrowArgumentNullException_WhenMessagesAreNull() { - // Arrange - var queue = new AzureCommandQueue(_marshallerMock.Object, _options); - var messages = new List>(); - - // Act - var task = queue.SendAsync(messages); + var queue = new AzureCommandQueue(_marshaller, _options); - // Assert - Assert.NotNull(task); - await task; // Ensure it completes without exception + await Assert.ThrowsAsync(() => queue.SendAsync(null)); } [Fact] public void ReceiveAsync_ShouldReturnAsyncEnumerable() { - // Arrange - var queue = new AzureCommandQueue(_marshallerMock.Object, _options); + var queue = new AzureCommandQueue(_marshaller, _options); - // Act var result = queue.ReceiveAsync(); - // Assert Assert.NotNull(result); Assert.IsAssignableFrom>>(result); } [Fact] - public void GetHealthCheckTarget_ShouldReturnQueueServiceClient() + public void GetHealthCheckTarget_ShouldReturnQueueServiceClient_ForConnectionString() { - // Arrange - var queue = new AzureCommandQueue(_marshallerMock.Object, _options); + var queue = new AzureCommandQueue(_marshaller, new AzureQueueOptions + { + ConnectionString = "UseDevelopmentStorage=true", + QueueName = "testqueue" + }); - // Act var result = queue.GetHealthCheckTarget(); - // Assert Assert.NotNull(result); Assert.IsType(result); + Assert.StartsWith("http://127.0.0.1:10001/devstoreaccount1", result.Uri.AbsoluteUri, StringComparison.OrdinalIgnoreCase); } + + [Fact] + public void GetHealthCheckTarget_ShouldReturnQueueServiceClient_ForStorageSharedKeyCredential() + { + var queue = new AzureCommandQueue(_marshaller, new AzureQueueOptions + { + StorageAccountName = "testaccount", + QueueName = "testqueue", + StorageKeyCredential = new StorageSharedKeyCredential("testaccount", Convert.ToBase64String(new byte[32])) + }); + + var result = queue.GetHealthCheckTarget(); + + Assert.Equal(new Uri("https://testaccount.queue.core.windows.net/testqueue"), result.Uri); + } + + [Fact] + public async Task SendMessageAsync_ShouldSendFormattedMessages() + { + var queueClient = new Mock(); + var sent = new List(); + queueClient.Setup(c => c.SendMessageAsync(It.IsAny(), It.IsAny(), It.IsAny(), default)) + .Callback((message, _, _, _) => sent.Add(message)) + .ReturnsAsync(Response.FromValue(QueuesModelFactory.SendReceipt("id", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1), "receipt", DateTimeOffset.UtcNow), Mock.Of())); + var queue = new TestAzureQueue(_marshaller, _options, queueClient.Object); + + await queue.SendAsync([CreateMessage()]); + + Assert.Single(sent); + Assert.Contains(".", sent[0], StringComparison.Ordinal); + } + + [Fact] + public async Task ReceiveMessagesAsync_ShouldYieldMessagesAndDeleteOnAcknowledge() + { + var message = CreateMessage(); + var encodedType = message.GetType().ToFullNameIncludingAssemblyName().ToByteArray().ToBase64String(); + var encodedMessage = _marshaller.Serialize(message).ToByteArray().ToBase64String(); + var raw = QueuesModelFactory.QueueMessage("message-id", "pop-receipt", $"{encodedType}.{encodedMessage}", 1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1), DateTimeOffset.UtcNow); + var queueClient = new Mock(); + queueClient.Setup(c => c.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), default)) + .ReturnsAsync(Response.FromValue(new[] { raw }, Mock.Of())); + string deletedMessageId = null; + queueClient.Setup(c => c.DeleteMessageAsync(It.IsAny(), It.IsAny(), default)) + .Callback((messageId, _, _) => deletedMessageId = messageId) + .ReturnsAsync(Mock.Of()); + var queue = new TestAzureQueue(_marshaller, _options, queueClient.Object); + + var received = new List>(); + await foreach (var item in queue.ReceiveAsync()) + { + received.Add(item); + await item.AcknowledgeAsync(); + } + + Assert.Single(received); + Assert.Equal(message.Id, received[0].Id); + Assert.Equal("message-id", deletedMessageId); + } + + private static IMessage CreateMessage() + { + return new Message( + Guid.NewGuid().ToString("N"), + new Uri("urn:test"), + "test", + new TestCommand()); + } + + private sealed class TestAzureQueue : AzureQueue + { + public TestAzureQueue(IMarshaller marshaller, AzureQueueOptions options, QueueClient client) : base(marshaller, options, Mock.Of(), client) + { + } + + public Task SendAsync(IEnumerable> messages) + { + return SendMessageAsync(messages); + } + + public IAsyncEnumerable> ReceiveAsync() + { + return ReceiveMessagesAsync(); + } + } + + private record TestCommand : Command { } } } diff --git a/test/Savvyio.Extensions.QueueStorage.Tests/EventDriven/AzureEventBusOptionsTest.cs b/test/Savvyio.Extensions.QueueStorage.Tests/EventDriven/AzureEventBusOptionsTest.cs new file mode 100644 index 00000000..05442c07 --- /dev/null +++ b/test/Savvyio.Extensions.QueueStorage.Tests/EventDriven/AzureEventBusOptionsTest.cs @@ -0,0 +1,93 @@ +using System; +using Azure; +using Azure.Identity; +using Codebelt.Extensions.Xunit; +using Cuemon; +using Xunit; + +namespace Savvyio.Extensions.QueueStorage.EventDriven +{ + public class AzureEventBusOptionsTest : Test + { + public AzureEventBusOptionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_Should_Set_Defaults() + { + var sut = new AzureEventBusOptions(); + + Assert.NotNull(sut.Credential); + Assert.IsType(sut.Credential); + Assert.Null(sut.TopicEndpoint); + Assert.NotNull(sut.Settings); + Assert.Null(sut.KeyCredential); + Assert.Null(sut.SasCredential); + } + + [Fact] + public void Credential_Setters_Should_Be_Mutually_Exclusive() + { + var sut = new AzureEventBusOptions + { + KeyCredential = new AzureKeyCredential("key") + }; + + Assert.NotNull(sut.KeyCredential); + Assert.Null(sut.Credential); + Assert.Null(sut.SasCredential); + + sut.SasCredential = new AzureSasCredential("sig"); + Assert.NotNull(sut.SasCredential); + Assert.Null(sut.Credential); + Assert.Null(sut.KeyCredential); + + sut.Credential = new DefaultAzureCredential(); + Assert.NotNull(sut.Credential); + Assert.Null(sut.KeyCredential); + Assert.Null(sut.SasCredential); + } + + [Fact] + public void ValidateOptions_Should_Throw_When_Topic_Endpoint_Is_Missing() + { + var sut1 = new AzureEventBusOptions(); + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal($"A {nameof(AzureEventBusOptions.TopicEndpoint)} is required. (Expression '{nameof(AzureEventBusOptions.TopicEndpoint)} == null')", sut2.Message); + Assert.Equal($"{nameof(AzureEventBusOptions)} are not in a valid state. (Parameter '{nameof(sut1)}')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void ValidateOptions_Should_Throw_When_All_Credentials_Are_Missing() + { + var sut1 = new AzureEventBusOptions + { + TopicEndpoint = new Uri("https://test.topic") + }; + sut1.Credential = null; + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal("A credential type has to be specified for Azure authentication. (Expression 'Credential == null && SasCredential == null && KeyCredential == null')", sut2.Message); + Assert.Equal($"{nameof(AzureEventBusOptions)} are not in a valid state. (Parameter '{nameof(sut1)}')", sut3.Message); + Assert.IsType(sut3.InnerException); + } + + [Fact] + public void ValidateOptions_Should_Not_Throw_When_Valid() + { + var sut = new AzureEventBusOptions + { + TopicEndpoint = new Uri("https://test.topic") + }; + + var exception = Record.Exception(() => sut.ValidateOptions()); + + Assert.Null(exception); + } + } +} diff --git a/test/Savvyio.Extensions.QueueStorage.Tests/EventDriven/AzureEventBusTest.cs b/test/Savvyio.Extensions.QueueStorage.Tests/EventDriven/AzureEventBusTest.cs index 4d64ddc2..3d757ae7 100644 --- a/test/Savvyio.Extensions.QueueStorage.Tests/EventDriven/AzureEventBusTest.cs +++ b/test/Savvyio.Extensions.QueueStorage.Tests/EventDriven/AzureEventBusTest.cs @@ -8,8 +8,12 @@ using Azure; using Azure.Core; using Azure.Messaging.EventGrid; +using Azure.Storage.Queues; +using Azure.Storage.Queues.Models; using Codebelt.Extensions.Xunit; using Cuemon.Extensions; +using Cuemon.Extensions.IO; +using Cuemon.Extensions.Reflection; using Cuemon.Threading; using Moq; using Savvyio.EventDriven; @@ -42,7 +46,7 @@ public void Ctor_Should_Throw_When_Options_Invalid() } [Fact] - public void Ctor_Should_Use_Correct_Credential() + public void Ctor_Should_Use_Token_Credential() { var marshaller = new JsonMarshaller(); var queueOptions = new AzureQueueOptions @@ -61,6 +65,26 @@ public void Ctor_Should_Use_Correct_Credential() Assert.NotNull(bus); } + [Fact] + public void Ctor_Should_Use_Sas_And_Key_Credentials() + { + var marshaller = new JsonMarshaller(); + var queueOptions = new AzureQueueOptions + { + StorageAccountName = "testaccount", + QueueName = "testqueue", + SasCredential = new AzureSasCredential("sig") + }; + var eventBusOptions = new AzureEventBusOptions + { + TopicEndpoint = new Uri("https://test.topic"), + KeyCredential = new AzureKeyCredential("key") + }; + + var bus = new AzureEventBus(marshaller, queueOptions, eventBusOptions); + Assert.NotNull(bus); + } + [Fact] public async Task PublishAsync_Should_Throw_When_Message_Null() { @@ -82,7 +106,7 @@ public async Task PublishAsync_Should_Invoke_EventGridClient() var bus = CreateSut(marshaller: marshaller); SetPrivateClient(bus, eventGridClient.Object); - var message = new Message("id", "https://source".ToUri(), "type", new DummyIntegrationEvent(), DateTime.UtcNow); + var message = new Message("id", "https://source".ToUri(), "type", new DummyIntegrationEvent(), DateTime.UtcNow); await bus.PublishAsync(message); @@ -119,9 +143,8 @@ public async Task PublishAsync_Should_Add_Signature_When_SignedMessage() } // Dummy event for test - private class DummyIntegrationEvent : IIntegrationEvent + private record DummyIntegrationEvent : IntegrationEvent { - public IMetadataDictionary Metadata { get; } } // Helper test double for ISignedMessage @@ -157,6 +180,33 @@ public void GetHealthCheckTarget_Should_Return_Health_Uri() Assert.Equal(new Uri("https://test.topic/api/health"), uri); } + [Fact] + public async Task SubscribeAsync_ShouldReceiveQueuedCloudEventsAndInvokeHandler() + { + var marshaller = new JsonMarshaller(); + var message = new Message("id", "https://source".ToUri(), "type", new DummyIntegrationEvent(), DateTime.UtcNow); + var cloudEvent = message.ToCloudEvent(); + cloudEvent.Add(AzureEventBus.CloudEventTypeExtensionAttribute, message.GetType().ToFullNameIncludingAssemblyName()); + var raw = QueuesModelFactory.QueueMessage("message-id", "pop-receipt", marshaller.Serialize(cloudEvent).ToEncodedString().ToByteArray().ToBase64String(), 1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1), DateTimeOffset.UtcNow); + var queueClient = new Mock(); + queueClient.SetupSequence(c => c.ReceiveMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Response.FromValue(new[] { raw }, Mock.Of())) + .ReturnsAsync(Response.FromValue(Array.Empty(), Mock.Of())); + queueClient.Setup(c => c.DeleteMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Mock.Of()); + var bus = new TestableAzureEventBus(marshaller, queueClient.Object); + IMessage received = null; + + await bus.SubscribeAsync((msg, _) => + { + received = msg; + return Task.CompletedTask; + }); + + Assert.NotNull(received); + Assert.Equal(message.Id, received.Id); + } + // --- Helpers --- private AzureEventBus CreateSut( @@ -185,5 +235,21 @@ private void SetPrivateClient(AzureEventBus bus, EventGridPublisherClient client field.SetValue(bus, client); } + private sealed class TestableAzureEventBus : AzureEventBus + { + public TestableAzureEventBus(IMarshaller marshaller, QueueClient queueClient) : base(marshaller, new AzureQueueOptions + { + StorageAccountName = "testaccount", + QueueName = "testqueue", + Credential = new Mock().Object + }, new AzureEventBusOptions + { + TopicEndpoint = new Uri("https://test.topic"), + Credential = new Mock().Object + }, Mock.Of(), queueClient, Mock.Of()) + { + } + } + } } \ No newline at end of file diff --git a/test/Savvyio.Extensions.RabbitMQ.Tests/Commands/RabbitMqCommandQueueOptionsTest.cs b/test/Savvyio.Extensions.RabbitMQ.Tests/Commands/RabbitMqCommandQueueOptionsTest.cs index 55bf0baf..99110cda 100644 --- a/test/Savvyio.Extensions.RabbitMQ.Tests/Commands/RabbitMqCommandQueueOptionsTest.cs +++ b/test/Savvyio.Extensions.RabbitMQ.Tests/Commands/RabbitMqCommandQueueOptionsTest.cs @@ -1,10 +1,15 @@ using System; +using Codebelt.Extensions.Xunit; using Xunit; namespace Savvyio.Extensions.RabbitMQ.Commands { - public class RabbitMqCommandQueueOptionsTest + public class RabbitMqCommandQueueOptionsTest : Test { + public RabbitMqCommandQueueOptionsTest(ITestOutputHelper output) : base(output) + { + } + [Fact] public void Constructor_Should_Set_Defaults() { @@ -12,10 +17,10 @@ public void Constructor_Should_Set_Defaults() Assert.Null(options.QueueName); Assert.False(options.AutoAcknowledge); - Assert.False(options.Durable); + Assert.True(options.Durable); Assert.False(options.Exclusive); Assert.False(options.AutoDelete); - Assert.NotNull(options.AmqpUrl); // Inherited from RabbitMqMessageOptions + Assert.NotNull(options.AmqpUrl); } [Fact] @@ -29,6 +34,18 @@ public void ValidateOptions_Should_Throw_When_QueueName_Is_Null_Or_Empty() Assert.Throws(() => options.ValidateOptions()); } + [Fact] + public void ValidateOptions_Should_Also_Throw_When_AmqpUrl_Is_Null() + { + var options = new RabbitMqCommandQueueOptions + { + QueueName = "test-queue", + AmqpUrl = null + }; + + Assert.Throws(() => options.ValidateOptions()); + } + [Fact] public void ValidateOptions_Should_Not_Throw_When_QueueName_Is_Set() { diff --git a/test/Savvyio.Extensions.RabbitMQ.Tests/Commands/RabbitMqCommandQueueTest.cs b/test/Savvyio.Extensions.RabbitMQ.Tests/Commands/RabbitMqCommandQueueTest.cs new file mode 100644 index 00000000..747c099d --- /dev/null +++ b/test/Savvyio.Extensions.RabbitMQ.Tests/Commands/RabbitMqCommandQueueTest.cs @@ -0,0 +1,167 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Moq; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Savvyio; +using Savvyio.Commands; +using Savvyio.Extensions.Text.Json; +using Savvyio.Messaging; +using Xunit; + +namespace Savvyio.Extensions.RabbitMQ.Commands +{ + public class RabbitMqCommandQueueTest : Test + { + public RabbitMqCommandQueueTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_Should_Throw_When_Marshaller_Is_Null() + { + Assert.Throws(() => new RabbitMqCommandQueue(null, CreateOptions())); + } + + [Fact] + public void Constructor_Should_Throw_When_Options_Are_Invalid() + { + Assert.Throws(() => new RabbitMqCommandQueue(JsonMarshaller.Default, new RabbitMqCommandQueueOptions())); + } + + [Fact] + public async Task SendAsync_Should_Declare_Queue_And_Publish_Each_Message() + { + var options = CreateOptions(o => o.Persistent = true); + var channel = new Mock(); + BasicProperties publishedProperties = null; + + channel.Setup(c => c.QueueDeclareAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueDeclareOk(options.QueueName, 0, 0)); + channel.Setup(c => c.BasicPublishAsync("", options.QueueName, true, It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((_, _, _, properties, _, _) => publishedProperties = properties) + .Returns(ValueTask.CompletedTask); + + var sut = CreateSut(channel.Object, options); + var messages = new IMessage[] + { + new Message("id-1", new Uri("urn:command:test"), "command.test", new TestCommand(), DateTime.UtcNow), + new Message("id-2", new Uri("urn:command:test"), "command.test", new TestCommand(), DateTime.UtcNow) + }; + + await sut.SendAsync(messages); + + channel.Verify(c => c.QueueDeclareAsync(options.QueueName, options.Durable, options.Exclusive, options.AutoDelete, null, false, false, It.IsAny()), Times.Once); + channel.Verify(c => c.BasicPublishAsync("", options.QueueName, true, It.IsAny(), It.IsAny>(), It.IsAny()), Times.Exactly(2)); + Assert.True(publishedProperties.Persistent); + Assert.Equal($"{typeof(Message).FullName}, {typeof(Message).Assembly.GetName().Name}", publishedProperties.Headers["type"]); + } + + [Fact] + public async Task ReceiveAsync_Should_Acknowledge_Message_When_Requested() + { + var options = CreateOptions(); + var channel = new Mock(); + var consumerSource = new TaskCompletionSource(); + + channel.Setup(c => c.QueueDeclareAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueDeclareOk(options.QueueName, 0, 0)); + channel.Setup(c => c.BasicConsumeAsync(options.QueueName, false, string.Empty, false, false, null, It.IsAny(), It.IsAny())) + .Callback, IAsyncBasicConsumer, CancellationToken>((_, _, _, _, _, _, consumer, _) => consumerSource.TrySetResult(consumer)) + .ReturnsAsync("consumer-tag"); + channel.Setup(c => c.BasicAckAsync(77UL, false, It.IsAny())).Returns(ValueTask.CompletedTask); + + var sut = CreateSut(channel.Object, options); + await using var enumerator = sut.ReceiveAsync().GetAsyncEnumerator(); + var moveNext = enumerator.MoveNextAsync().AsTask(); + var consumer = await consumerSource.Task.ConfigureAwait(false); + + await PublishDeliveredMessageAsync((AsyncEventingBasicConsumer)consumer, new Message("id-1", new Uri("urn:command:test"), "command.test", new TestCommand(), DateTime.UtcNow), 77UL, options.QueueName).ConfigureAwait(false); + + Assert.True(await moveNext.ConfigureAwait(false)); + var message = enumerator.Current; + + Assert.Equal(77UL, message.Properties[nameof(BasicDeliverEventArgs.DeliveryTag)]); + Assert.Equal(options.QueueName, message.Properties[nameof(QueueDeclareOk.QueueName)]); + + await message.AcknowledgeAsync().ConfigureAwait(false); + + channel.Verify(c => c.BasicAckAsync(77UL, false, It.IsAny()), Times.Once); + channel.Verify(c => c.QueueDeclareAsync(options.QueueName, options.Durable, options.Exclusive, options.AutoDelete, null, false, false, It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task ReceiveAsync_Should_Auto_Acknowledge_Message_When_Configured() + { + var options = CreateOptions(o => o.AutoAcknowledge = true); + var channel = new Mock(); + var consumerSource = new TaskCompletionSource(); + + channel.Setup(c => c.QueueDeclareAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueDeclareOk(options.QueueName, 0, 0)); + channel.Setup(c => c.BasicConsumeAsync(options.QueueName, false, string.Empty, false, false, null, It.IsAny(), It.IsAny())) + .Callback, IAsyncBasicConsumer, CancellationToken>((_, _, _, _, _, _, consumer, _) => consumerSource.TrySetResult(consumer)) + .ReturnsAsync("consumer-tag"); + channel.Setup(c => c.BasicAckAsync(88UL, false, It.IsAny())).Returns(ValueTask.CompletedTask); + + var sut = CreateSut(channel.Object, options); + await using var enumerator = sut.ReceiveAsync().GetAsyncEnumerator(); + var moveNext = enumerator.MoveNextAsync().AsTask(); + var consumer = await consumerSource.Task.ConfigureAwait(false); + + await PublishDeliveredMessageAsync((AsyncEventingBasicConsumer)consumer, new Message("id-2", new Uri("urn:command:test"), "command.test", new TestCommand(), DateTime.UtcNow), 88UL, options.QueueName).ConfigureAwait(false); + + Assert.True(await moveNext.ConfigureAwait(false)); + channel.Verify(c => c.BasicAckAsync(88UL, false, It.IsAny()), Times.Once); + } + + private static RabbitMqCommandQueue CreateSut(IChannel channel, RabbitMqCommandQueueOptions options) + { + var connection = new Mock(); + connection.Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())).ReturnsAsync(channel); + + var factory = new Mock(); + factory.Setup(f => f.CreateConnectionAsync(It.IsAny())).ReturnsAsync(connection.Object); + + var sut = new RabbitMqCommandQueue(JsonMarshaller.Default, options); + typeof(RabbitMqMessage) + .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(sut, factory.Object); + return sut; + } + + private static RabbitMqCommandQueueOptions CreateOptions(Action setup = null) + { + var options = new RabbitMqCommandQueueOptions + { + QueueName = "orders" + }; + setup?.Invoke(options); + return options; + } + + private static async Task PublishDeliveredMessageAsync(AsyncEventingBasicConsumer consumer, Message message, ulong deliveryTag, string queueName) + { + var body = await JsonMarshaller.Default.Serialize(message).ToByteArrayAsync().ConfigureAwait(false); + var properties = new BasicProperties + { + Headers = new Dictionary + { + ["type"] = Encoding.UTF8.GetBytes($"{typeof(Message).FullName}, {typeof(Message).Assembly.GetName().Name}") + } + }; + + await consumer.HandleBasicDeliverAsync("consumer-tag", deliveryTag, false, string.Empty, queueName, properties, body, CancellationToken.None).ConfigureAwait(false); + } + + private sealed class TestCommand : ICommand + { + public IMetadataDictionary Metadata { get; } = new MetadataDictionary(); + } + } +} diff --git a/test/Savvyio.Extensions.RabbitMQ.Tests/EventDriven/RabbitMqEventBusOptionsTest.cs b/test/Savvyio.Extensions.RabbitMQ.Tests/EventDriven/RabbitMqEventBusOptionsTest.cs index a2015d88..e3775f39 100644 --- a/test/Savvyio.Extensions.RabbitMQ.Tests/EventDriven/RabbitMqEventBusOptionsTest.cs +++ b/test/Savvyio.Extensions.RabbitMQ.Tests/EventDriven/RabbitMqEventBusOptionsTest.cs @@ -1,17 +1,22 @@ using System; +using Codebelt.Extensions.Xunit; using Xunit; namespace Savvyio.Extensions.RabbitMQ.EventDriven { - public class RabbitMqEventBusOptionsTest + public class RabbitMqEventBusOptionsTest : Test { + public RabbitMqEventBusOptionsTest(ITestOutputHelper output) : base(output) + { + } + [Fact] public void Constructor_Should_Set_Defaults() { var options = new RabbitMqEventBusOptions(); Assert.Null(options.ExchangeName); - Assert.NotNull(options.AmqpUrl); // Inherited from RabbitMqMessageOptions + Assert.NotNull(options.AmqpUrl); Assert.Equal(new Uri("amqp://localhost:5672"), options.AmqpUrl); } @@ -26,6 +31,18 @@ public void ValidateOptions_Should_Throw_When_ExchangeName_Is_Null_Or_Empty() Assert.Throws(() => options.ValidateOptions()); } + [Fact] + public void ValidateOptions_Should_Also_Throw_When_AmqpUrl_Is_Null() + { + var options = new RabbitMqEventBusOptions + { + ExchangeName = "test-exchange", + AmqpUrl = null + }; + + Assert.Throws(() => options.ValidateOptions()); + } + [Fact] public void ValidateOptions_Should_Not_Throw_When_ExchangeName_Is_Set() { diff --git a/test/Savvyio.Extensions.RabbitMQ.Tests/EventDriven/RabbitMqEventBusTest.cs b/test/Savvyio.Extensions.RabbitMQ.Tests/EventDriven/RabbitMqEventBusTest.cs new file mode 100644 index 00000000..b6b4f504 --- /dev/null +++ b/test/Savvyio.Extensions.RabbitMQ.Tests/EventDriven/RabbitMqEventBusTest.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Moq; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; +using Savvyio; +using Savvyio.EventDriven; +using Savvyio.Extensions.Text.Json; +using Savvyio.Messaging; +using Xunit; + +namespace Savvyio.Extensions.RabbitMQ.EventDriven +{ + public class RabbitMqEventBusTest : Test + { + public RabbitMqEventBusTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_Should_Throw_When_Marshaller_Is_Null() + { + Assert.Throws(() => new RabbitMqEventBus(null, CreateOptions())); + } + + [Fact] + public void Constructor_Should_Throw_When_Options_Are_Invalid() + { + Assert.Throws(() => new RabbitMqEventBus(JsonMarshaller.Default, new RabbitMqEventBusOptions())); + } + + [Fact] + public async Task PublishAsync_Should_Declare_Exchange_And_Publish_Message() + { + var options = CreateOptions(o => o.Persistent = true); + var channel = new Mock(); + BasicProperties publishedProperties = null; + + channel.Setup(c => c.ExchangeDeclareAsync(options.ExchangeName, ExchangeType.Fanout, false, false, null, false, false, It.IsAny())) + .Returns(Task.CompletedTask); + channel.Setup(c => c.BasicPublishAsync(options.ExchangeName, string.Empty, false, It.IsAny(), It.IsAny>(), It.IsAny())) + .Callback, CancellationToken>((_, _, _, properties, _, _) => publishedProperties = properties) + .Returns(ValueTask.CompletedTask); + + var sut = CreateSut(channel.Object, options); + var message = new Message("id-1", new Uri("urn:event:test"), "event.test", new TestIntegrationEvent(), DateTime.UtcNow); + + await sut.PublishAsync(message); + + channel.Verify(c => c.ExchangeDeclareAsync(options.ExchangeName, ExchangeType.Fanout, false, false, null, false, false, It.IsAny()), Times.Once); + channel.Verify(c => c.BasicPublishAsync(options.ExchangeName, string.Empty, false, It.IsAny(), It.IsAny>(), It.IsAny()), Times.Once); + Assert.True(publishedProperties.Persistent); + Assert.Equal($"{typeof(Message).FullName}, {typeof(Message).Assembly.GetName().Name}", publishedProperties.Headers["type"]); + } + + [Fact] + public async Task SubscribeAsync_Should_Invoke_Handler_For_Received_Message() + { + var options = CreateOptions(); + var channel = new Mock(); + var consumerSource = new TaskCompletionSource(); + var handled = 0; + IMessage received = null; + using var cts = new CancellationTokenSource(); + + channel.Setup(c => c.ExchangeDeclareAsync(options.ExchangeName, ExchangeType.Fanout, false, false, null, false, false, It.IsAny())) + .Returns(Task.CompletedTask); + channel.Setup(c => c.QueueDeclareAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueDeclareOk("events-queue", 0, 0)); + channel.Setup(c => c.QueueBindAsync("events-queue", options.ExchangeName, string.Empty, null, false, It.IsAny())) + .Returns(Task.CompletedTask); + channel.Setup(c => c.BasicConsumeAsync("events-queue", true, string.Empty, false, false, null, It.IsAny(), It.IsAny())) + .Callback, IAsyncBasicConsumer, CancellationToken>((_, _, _, _, _, _, consumer, _) => consumerSource.TrySetResult(consumer)) + .ReturnsAsync("consumer-tag"); + + var sut = CreateSut(channel.Object, options); + var subscribeTask = sut.SubscribeAsync((message, token) => + { + handled++; + received = message; + cts.Cancel(); + return Task.CompletedTask; + }, o => o.CancellationToken = cts.Token); + + var consumer = await consumerSource.Task.ConfigureAwait(false); + await PublishDeliveredMessageAsync((AsyncEventingBasicConsumer)consumer, new Message("id-2", new Uri("urn:event:test"), "event.test", new TestIntegrationEvent(), DateTime.UtcNow), 91UL).ConfigureAwait(false); + + try + { + await subscribeTask.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // expected: the subscription loop exits when the token is cancelled inside the handler + } + + Assert.Equal(1, handled); + Assert.NotNull(received); + Assert.Equal(91UL, received.Properties[nameof(BasicDeliverEventArgs.DeliveryTag)]); + Assert.Equal("events-queue", received.Properties[nameof(QueueDeclareOk.QueueName)]); + } + + private static RabbitMqEventBus CreateSut(IChannel channel, RabbitMqEventBusOptions options) + { + var connection = new Mock(); + connection.Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())).ReturnsAsync(channel); + + var factory = new Mock(); + factory.Setup(f => f.CreateConnectionAsync(It.IsAny())).ReturnsAsync(connection.Object); + + var sut = new RabbitMqEventBus(JsonMarshaller.Default, options); + typeof(RabbitMqMessage) + .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .SetValue(sut, factory.Object); + return sut; + } + + private static RabbitMqEventBusOptions CreateOptions(Action setup = null) + { + var options = new RabbitMqEventBusOptions + { + ExchangeName = "events" + }; + setup?.Invoke(options); + return options; + } + + private static async Task PublishDeliveredMessageAsync(AsyncEventingBasicConsumer consumer, Message message, ulong deliveryTag) + { + var body = await JsonMarshaller.Default.Serialize(message).ToByteArrayAsync().ConfigureAwait(false); + var properties = new BasicProperties + { + Headers = new Dictionary + { + ["type"] = Encoding.UTF8.GetBytes($"{typeof(Message).FullName}, {typeof(Message).Assembly.GetName().Name}") + } + }; + + await consumer.HandleBasicDeliverAsync("consumer-tag", deliveryTag, false, string.Empty, string.Empty, properties, body, CancellationToken.None).ConfigureAwait(false); + } + + private sealed class TestIntegrationEvent : IIntegrationEvent + { + public IMetadataDictionary Metadata { get; } = new MetadataDictionary(); + } + } +} diff --git a/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageOptionsTest.cs b/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageOptionsTest.cs index cea5cac4..4e7a5201 100644 --- a/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageOptionsTest.cs +++ b/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageOptionsTest.cs @@ -1,10 +1,15 @@ using System; +using Codebelt.Extensions.Xunit; using Xunit; namespace Savvyio.Extensions.RabbitMQ { - public class RabbitMqMessageOptionsTest + public class RabbitMqMessageOptionsTest : Test { + public RabbitMqMessageOptionsTest(ITestOutputHelper output) : base(output) + { + } + [Fact] public void Constructor_Should_Set_Default_AmqpUrl() { @@ -15,6 +20,17 @@ public void Constructor_Should_Set_Default_AmqpUrl() Assert.Equal(new Uri("amqp://localhost:5672"), options.AmqpUrl); } + [Fact] + public void Persistent_Should_Be_Settable() + { + var options = new RabbitMqMessageOptions + { + Persistent = true + }; + + Assert.True(options.Persistent); + } + [Fact] public void ValidateOptions_Should_Throw_When_AmqpUrl_Is_Null() { diff --git a/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageTest.cs b/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageTest.cs index 2de0df01..4723f6c1 100644 --- a/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageTest.cs +++ b/test/Savvyio.Extensions.RabbitMQ.Tests/RabbitMqMessageTest.cs @@ -1,8 +1,7 @@ using Codebelt.Extensions.Xunit; -using Cuemon.Reflection; using Moq; using RabbitMQ.Client; -using Savvyio.Extensions.RabbitMQ; +using Savvyio.Extensions.Text.Json; using System; using System.Threading; using System.Threading.Tasks; @@ -12,28 +11,33 @@ namespace Savvyio.Extensions.RabbitMQ { public class RabbitMqMessageTest : Test { - public RabbitMqMessageTest(ITestOutputHelper output) : base(output) { } + public RabbitMqMessageTest(ITestOutputHelper output) : base(output) + { + } - private class TestRabbitMqMessage : RabbitMqMessage + private sealed class TestRabbitMqMessage : RabbitMqMessage { - public TestRabbitMqMessage(IMarshaller marshaller, RabbitMqMessageOptions options) - : base(marshaller, options) { } + public TestRabbitMqMessage(IMarshaller marshaller, RabbitMqMessageOptions options) : base(marshaller, options) + { + } - public async Task CallEnsureConnectivityAsync(CancellationToken ct = default) => - await EnsureConnectivityAsync(ct); + public Task CallEnsureConnectivityAsync(CancellationToken ct = default) => EnsureConnectivityAsync(ct); - public async ValueTask CallOnDisposeManagedResourcesAsync() => - await OnDisposeManagedResourcesAsync(); + public ValueTask CallOnDisposeManagedResourcesAsync() => OnDisposeManagedResourcesAsync(); - public void SetConnection(IConnection connection) => + public void SetConnection(IConnection connection) + { typeof(RabbitMqMessage) - .GetProperty("RabbitMqConnection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetProperty("RabbitMqConnection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .SetValue(this, connection); + } - public void SetChannel(IChannel channel) => + public void SetChannel(IChannel channel) + { typeof(RabbitMqMessage) - .GetProperty("RabbitMqChannel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetProperty("RabbitMqChannel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .SetValue(this, channel); + } } [Fact] @@ -46,83 +50,61 @@ public void Constructor_Throws_WhenMarshallerIsNull() [Fact] public void Constructor_Throws_WhenOptionsIsNull() { - var marshaller = new Mock().Object; - Assert.Throws(() => new TestRabbitMqMessage(marshaller, null)); + Assert.Throws(() => new TestRabbitMqMessage(JsonMarshaller.Default, null)); } [Fact] public void Constructor_SetsProperties() { - var marshaller = new Mock().Object; var options = new RabbitMqMessageOptions { AmqpUrl = new Uri("amqp://localhost:5672") }; - var sut = new TestRabbitMqMessage(marshaller, options); + var sut = new TestRabbitMqMessage(JsonMarshaller.Default, options); Assert.NotNull(sut); - Assert.NotNull(typeof(RabbitMqMessage).GetProperty("Marshaller", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(sut)); - Assert.NotNull(typeof(RabbitMqMessage).GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance).GetValue(sut)); + Assert.NotNull(typeof(RabbitMqMessage).GetProperty("Marshaller", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(sut)); + Assert.NotNull(typeof(RabbitMqMessage).GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(sut)); } [Fact] public async Task EnsureConnectivityAsync_InitializesConnectionAndChannel_Once() { - var marshaller = new Mock().Object; var options = new RabbitMqMessageOptions { AmqpUrl = new Uri("amqp://localhost:5672") }; - var connectionMock = new Mock(); var channelMock = new Mock(); - connectionMock - .Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(channelMock.Object); + connectionMock.Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())).ReturnsAsync(channelMock.Object); var factoryMock = new Mock(); - factoryMock.Setup(f => f.CreateConnectionAsync(It.IsAny())) - .ReturnsAsync(connectionMock.Object); + factoryMock.Setup(f => f.CreateConnectionAsync(It.IsAny())).ReturnsAsync(connectionMock.Object); - var sut = new TestRabbitMqMessage(marshaller, options); + var sut = new TestRabbitMqMessage(JsonMarshaller.Default, options); typeof(RabbitMqMessage) - .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .SetValue(sut, factoryMock.Object); await sut.CallEnsureConnectivityAsync(); - - var conn = typeof(RabbitMqMessage) - .GetProperty("RabbitMqConnection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - .GetValue(sut); - var chan = typeof(RabbitMqMessage) - .GetProperty("RabbitMqChannel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - .GetValue(sut); - - Assert.Same(connectionMock.Object, conn); - Assert.Same(channelMock.Object, chan); - - // Call again to ensure it does not re-initialize await sut.CallEnsureConnectivityAsync(); + + Assert.Same(connectionMock.Object, typeof(RabbitMqMessage).GetProperty("RabbitMqConnection", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(sut)); + Assert.Same(channelMock.Object, typeof(RabbitMqMessage).GetProperty("RabbitMqChannel", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!.GetValue(sut)); factoryMock.Verify(f => f.CreateConnectionAsync(It.IsAny()), Times.Once); } [Fact] public async Task EnsureConnectivityAsync_IsThreadSafe_WhenCalledConcurrently() { - var marshaller = new Mock().Object; var options = new RabbitMqMessageOptions { AmqpUrl = new Uri("amqp://localhost:5672") }; - var connectionMock = new Mock(); var channelMock = new Mock(); - connectionMock.Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(channelMock.Object); + connectionMock.Setup(c => c.CreateChannelAsync(It.IsAny(), It.IsAny())).ReturnsAsync(channelMock.Object); var factoryMock = new Mock(); - factoryMock.Setup(f => f.CreateConnectionAsync(It.IsAny())) - .ReturnsAsync(connectionMock.Object); + factoryMock.Setup(f => f.CreateConnectionAsync(It.IsAny())).ReturnsAsync(connectionMock.Object); - var sut = new TestRabbitMqMessage(marshaller, options); + var sut = new TestRabbitMqMessage(JsonMarshaller.Default, options); typeof(RabbitMqMessage) - .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .SetValue(sut, factoryMock.Object); - await Task.WhenAll( - sut.CallEnsureConnectivityAsync(), - sut.CallEnsureConnectivityAsync()); + await Task.WhenAll(sut.CallEnsureConnectivityAsync(), sut.CallEnsureConnectivityAsync()); factoryMock.Verify(f => f.CreateConnectionAsync(It.IsAny()), Times.Once); } @@ -130,16 +112,13 @@ await Task.WhenAll( [Fact] public async Task OnDisposeManagedResourcesAsync_DisposesChannelAndConnection() { - var marshaller = new Mock().Object; - var options = new RabbitMqMessageOptions(); - var channelMock = new Mock(); channelMock.Setup(c => c.DisposeAsync()).Returns(ValueTask.CompletedTask).Verifiable(); var connectionMock = new Mock(); connectionMock.Setup(c => c.DisposeAsync()).Returns(ValueTask.CompletedTask).Verifiable(); - var sut = new TestRabbitMqMessage(marshaller, options); + var sut = new TestRabbitMqMessage(JsonMarshaller.Default, new RabbitMqMessageOptions()); sut.SetChannel(channelMock.Object); sut.SetConnection(connectionMock.Object); @@ -152,11 +131,8 @@ public async Task OnDisposeManagedResourcesAsync_DisposesChannelAndConnection() [Fact] public async Task GetHealthCheckTargetAsync_ReturnsExistingConnection_IfInitialized() { - var marshaller = new Mock().Object; - var options = new RabbitMqMessageOptions(); - var connectionMock = new Mock().Object; - var sut = new TestRabbitMqMessage(marshaller, options); + var sut = new TestRabbitMqMessage(JsonMarshaller.Default, new RabbitMqMessageOptions()); sut.SetConnection(connectionMock); var result = await sut.GetHealthCheckTargetAsync(); @@ -167,20 +143,15 @@ public async Task GetHealthCheckTargetAsync_ReturnsExistingConnection_IfInitiali [Fact] public async Task GetHealthCheckTargetAsync_CreatesConnection_IfNotInitialized() { - var marshaller = new Mock().Object; - var options = new RabbitMqMessageOptions(); - var connectionMock = new Mock().Object; var factoryMock = new Mock(); - factoryMock.Setup(f => f.CreateConnectionAsync(It.IsAny())) - .ReturnsAsync(connectionMock); + factoryMock.Setup(f => f.CreateConnectionAsync(It.IsAny())).ReturnsAsync(connectionMock); - var sut = new TestRabbitMqMessage(marshaller, options); + var sut = new TestRabbitMqMessage(JsonMarshaller.Default, new RabbitMqMessageOptions()); typeof(RabbitMqMessage) - .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) + .GetProperty("RabbitMqFactory", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .SetValue(sut, factoryMock.Object); - var result = await sut.GetHealthCheckTargetAsync(); Assert.Same(connectionMock, result); diff --git a/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageOptionsTest.cs b/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageOptionsTest.cs index 3d140dfb..a530012c 100644 --- a/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageOptionsTest.cs +++ b/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageOptionsTest.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Reflection; using Amazon; using Amazon.Runtime; using Amazon.SimpleNotificationService; @@ -119,5 +120,35 @@ public void ValidateOptions_ShouldPassWithConfiguredClient() Assert.Equal(sut1.ClientConfigurations.SimpleQueueService().ServiceURL, sut1.ClientConfigurations.SimpleNotificationService().ServiceURL); Assert.Equal(sut1.ClientConfigurations.SimpleQueueService().AuthenticationRegion, sut1.ClientConfigurations.SimpleNotificationService().AuthenticationRegion); } + + [Fact] + public void ConfigureClient_Should_Throw_When_Setup_Is_Null() + { + var sut = new AmazonMessageOptions(); + + Assert.Throws(() => sut.ConfigureClient(null)); + } + + [Fact] + public void ValidateOptions_ThrowsInvalidOperationException_WhenClientConfigurationsAreInvalid() + { + var sut1 = new AmazonMessageOptions + { + Credentials = new AnonymousAWSCredentials(), + Endpoint = RegionEndpoint.EUWest1, + SourceQueue = new Uri("urn:null") + }; + + typeof(AmazonMessageOptions) + .GetProperty(nameof(AmazonMessageOptions.ClientConfigurations), BindingFlags.Instance | BindingFlags.Public)! + .SetValue(sut1, new ClientConfig[] { new AmazonSQSConfig() }); + + var sut2 = Assert.Throws(() => sut1.ValidateOptions()); + var sut3 = Assert.Throws(() => Validator.ThrowIfInvalidOptions(sut1)); + + Assert.Equal($"Operation is not valid due to the current state of the object. (Expression '{nameof(AmazonMessageOptions.ClientConfigurations)}.Length > 0 && ({nameof(AmazonMessageOptions.ClientConfigurations)}.Length != 2 || !({nameof(AmazonMessageOptions.ClientConfigurations)}[0] is AmazonSQSConfig && {nameof(AmazonMessageOptions.ClientConfigurations)}[1] is AmazonSimpleNotificationServiceConfig))')", sut2.Message); + Assert.Equal($"{nameof(AmazonMessageOptions)} are not in a valid state. (Parameter '{nameof(sut1)}')", sut3.Message); + Assert.IsType(sut3.InnerException); + } } } diff --git a/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageReceiveOptionsTest.cs b/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageReceiveOptionsTest.cs index a9b4a0d6..238b6391 100644 --- a/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageReceiveOptionsTest.cs +++ b/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageReceiveOptionsTest.cs @@ -17,6 +17,8 @@ public void Ctor_ShouldHaveDefaultValues() Assert.Equal(10, sut.NumberOfMessagesToTakePerRequest); Assert.Equal(TimeSpan.FromSeconds(AmazonMessageOptions.MaxPollingWaitTimeInSeconds), sut.PollingTimeout); + Assert.Equal(TimeSpan.FromSeconds(AmazonMessageOptions.DefaultVisibilityTimeoutInSeconds), sut.VisibilityTimeout); + Assert.True(sut.AssumeMessageProcessed); Assert.True(sut.RemoveProcessedMessages); Assert.False(sut.UseApproximateNumberOfMessages); } diff --git a/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageTest.cs b/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageTest.cs new file mode 100644 index 00000000..6636451b --- /dev/null +++ b/test/Savvyio.Extensions.SimpleQueueService.Tests/AmazonMessageTest.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime; +using Codebelt.Extensions.Xunit; +using Savvyio.Commands; +using Savvyio.Extensions.Text.Json; +using Savvyio.Messaging; +using Xunit; + +namespace Savvyio.Extensions.SimpleQueueService +{ + public class AmazonMessageTest : Test + { + public AmazonMessageTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Constructor_Should_Set_Protected_Properties_For_Standard_Queue() + { + var marshaller = JsonMarshaller.Default; + var options = CreateOptions("https://sqs.eu-west-1.amazonaws.com/123456789012/orders"); + + var sut = new TestAmazonQueue(marshaller, options); + + Assert.Same(marshaller, sut.ExposedMarshaller); + Assert.Same(options, sut.Options); + Assert.False(sut.UseFirstInFirstOut); + } + + [Fact] + public void Constructor_Should_Detect_First_In_First_Out_Queue() + { + var sut = new TestAmazonQueue(JsonMarshaller.Default, CreateOptions("https://sqs.eu-west-1.amazonaws.com/123456789012/orders.fifo")); + + Assert.True(sut.UseFirstInFirstOut); + } + + private static AmazonMessageOptions CreateOptions(string queueUrl) + { + return new AmazonMessageOptions + { + Credentials = new AnonymousAWSCredentials(), + Endpoint = RegionEndpoint.EUWest1, + SourceQueue = new Uri(queueUrl) + }; + } + + private sealed class TestAmazonQueue : AmazonQueue + { + public TestAmazonQueue(IMarshaller marshaller, AmazonMessageOptions options) : base(marshaller, options) + { + } + + public bool UseFirstInFirstOut => base.UseFirstInFirstOut; + + public IMarshaller ExposedMarshaller => base.Marshaller; + + public override Task SendAsync(IEnumerable> messages, Action setup = null) + { + return Task.CompletedTask; + } + + public override async IAsyncEnumerable> ReceiveAsync(Action setup = null) + { + yield break; + } + } + } +} diff --git a/test/Savvyio.Extensions.SimpleQueueService.Tests/ClientConfigExtensionsTest.cs b/test/Savvyio.Extensions.SimpleQueueService.Tests/ClientConfigExtensionsTest.cs index 717f2a3a..2f3faaa9 100644 --- a/test/Savvyio.Extensions.SimpleQueueService.Tests/ClientConfigExtensionsTest.cs +++ b/test/Savvyio.Extensions.SimpleQueueService.Tests/ClientConfigExtensionsTest.cs @@ -8,30 +8,28 @@ namespace Savvyio.Extensions.SimpleQueueService { public class ClientConfigExtensionsTest : Test { - public ClientConfigExtensionsTest(ITestOutputHelper output) : base(output) { } + public ClientConfigExtensionsTest(ITestOutputHelper output) : base(output) + { + } [Fact] - public void IsValid_ShouldEvaluateConfigurations() + public void IsValid_Should_Return_True_When_Both_Aws_Client_Configurations_Are_Present() { - ClientConfig[] invalid = null; - Assert.False(invalid.IsValid()); + ClientConfig[] configurations = [ new AmazonSQSConfig(), new AmazonSimpleNotificationServiceConfig() ]; - var valid = new ClientConfig[] { new AmazonSQSConfig(), new AmazonSimpleNotificationServiceConfig() }; - Assert.True(valid.IsValid()); - - var wrongLength = new ClientConfig[] { new AmazonSQSConfig() }; - Assert.False(wrongLength.IsValid()); + Assert.True(configurations.IsValid()); + Assert.IsType(configurations.SimpleQueueService()); + Assert.IsType(configurations.SimpleNotificationService()); } [Fact] - public void ShouldResolveSpecificConfigurations() + public void IsValid_Should_Return_False_When_Configurations_Are_Missing_Expected_Types() { - var sqs = new AmazonSQSConfig(); - var sns = new AmazonSimpleNotificationServiceConfig(); - ClientConfig[] configs = { sqs, sns }; + ClientConfig[] invalid = [ new AmazonSQSConfig() ]; - Assert.Same(sqs, configs.SimpleQueueService()); - Assert.Same(sns, configs.SimpleNotificationService()); + Assert.False(invalid.IsValid()); + Assert.NotNull(invalid.SimpleQueueService()); + Assert.Null(invalid.SimpleNotificationService()); } } } diff --git a/test/Savvyio.Extensions.SimpleQueueService.Tests/Commands/AmazonCommandQueueTest.cs b/test/Savvyio.Extensions.SimpleQueueService.Tests/Commands/AmazonCommandQueueTest.cs index 07d81efe..99f48689 100644 --- a/test/Savvyio.Extensions.SimpleQueueService.Tests/Commands/AmazonCommandQueueTest.cs +++ b/test/Savvyio.Extensions.SimpleQueueService.Tests/Commands/AmazonCommandQueueTest.cs @@ -3,10 +3,11 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Amazon.Runtime; using Amazon.SQS; using Amazon.SQS.Model; using Codebelt.Extensions.Xunit; -using Cuemon.Extensions; +using Cuemon.Extensions.IO; using Cuemon.Extensions.Reflection; using Moq; using Savvyio.Commands; @@ -30,7 +31,7 @@ public AmazonCommandQueueTest(ITestOutputHelper output) : base(output) _marshaller = new JsonMarshaller(); _options = new AmazonCommandQueueOptions { - Credentials = new Mock().Object, + Credentials = new AnonymousAWSCredentials(), Endpoint = Amazon.RegionEndpoint.USEast1, SourceQueue = new Uri("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue") }; @@ -86,5 +87,173 @@ public void GetHealthCheckTarget_Should_Return_IAmazonSQS_Instance() Assert.NotNull(sqs); Assert.IsAssignableFrom(sqs); } + + [Fact] + public void GetHealthCheckTarget_Should_Use_Configured_Client_When_Available() + { + var options = new AmazonCommandQueueOptions + { + Credentials = new AnonymousAWSCredentials(), + Endpoint = Amazon.RegionEndpoint.USEast1, + SourceQueue = new Uri("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue") + }; + options.ConfigureClient(config => + { + config.ServiceURL = "http://localhost:4566"; + config.AuthenticationRegion = Amazon.RegionEndpoint.USEast1.SystemName; + }); + + var sut = new AmazonCommandQueue(_marshaller, options); + + var sqs = sut.GetHealthCheckTarget(); + + Assert.Equal("http://localhost:4566/", sqs.Config.ServiceURL); + Assert.Equal(Amazon.RegionEndpoint.USEast1.SystemName, sqs.Config.AuthenticationRegion); + } + + [Fact] + public async Task SendAsync_ShouldSendMessagesInBatches() + { + var sqs = new Mock(); + var requests = new List(); + sqs.Setup(s => s.SendMessageBatchAsync(It.IsAny(), It.IsAny())) + .Callback((request, _) => requests.Add(request)) + .ReturnsAsync(new SendMessageBatchResponse()); + var queue = new TestableAmazonCommandQueue(_marshaller, _options, sqs.Object); + var messages = Enumerable.Range(0, 11).Select(_ => CreateMessage()).ToArray(); + + await queue.SendAsync(messages); + + Assert.Equal(2, requests.Count); + Assert.Equal(10, requests[0].Entries.Count); + Assert.Single(requests[1].Entries); + Assert.All(requests.SelectMany(request => request.Entries), entry => + { + Assert.NotNull(entry.MessageBody); + Assert.True(entry.MessageAttributes.ContainsKey("type")); + }); + } + + [Fact] + public async Task ReceiveAsync_ShouldDeserializeMessagesAndDeleteAcknowledgedMessages() + { + var message = CreateMessage(); + var awsMessage = new Message + { + Body = _marshaller.Serialize(message).ToEncodedString(), + MessageId = "message-id", + ReceiptHandle = "receipt-handle", + MessageAttributes = new Dictionary + { + ["type"] = new MessageAttributeValue + { + DataType = "String", + StringValue = message.GetType().ToFullNameIncludingAssemblyName() + } + } + }; + var sqs = new Mock(); + sqs.Setup(s => s.ReceiveMessageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ReceiveMessageResponse + { + Messages = [awsMessage] + }); + DeleteMessageBatchRequest deleteRequest = null; + sqs.Setup(s => s.DeleteMessageBatchAsync(It.IsAny(), It.IsAny())) + .Callback((request, _) => deleteRequest = request) + .ReturnsAsync(new DeleteMessageBatchResponse()); + var queue = new TestableAmazonCommandQueue(_marshaller, _options, sqs.Object); + + var received = new List>(); + await foreach (var item in queue.ReceiveAsync()) + { + received.Add(item); + } + + Assert.Single(received); + Assert.Equal(message.Id, received[0].Id); + Assert.NotNull(deleteRequest); + Assert.Equal("message-id", deleteRequest.Entries[0].Id); + Assert.Equal("receipt-handle", deleteRequest.Entries[0].ReceiptHandle); + } + + [Fact] + public async Task ReceiveAsync_ShouldUseApproximateNumberOfMessages_WhenConfigured() + { + var message = CreateMessage(); + var awsMessage = new Message + { + Body = _marshaller.Serialize(message).ToEncodedString(), + MessageId = "message-id", + ReceiptHandle = "receipt-handle", + MessageAttributes = new Dictionary + { + ["type"] = new MessageAttributeValue + { + DataType = "String", + StringValue = message.GetType().ToFullNameIncludingAssemblyName() + } + } + }; + var options = new AmazonCommandQueueOptions + { + Credentials = new AnonymousAWSCredentials(), + Endpoint = Amazon.RegionEndpoint.USEast1, + SourceQueue = new Uri("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue") + }; + options.ReceiveContext.UseApproximateNumberOfMessages = true; + var sqs = new Mock(); + sqs.Setup(s => s.GetQueueAttributesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new GetQueueAttributesResponse + { + Attributes = new Dictionary + { + ["ApproximateNumberOfMessages"] = "1" + } + }); + sqs.Setup(s => s.ReceiveMessageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ReceiveMessageResponse + { + Messages = [awsMessage] + }); + sqs.Setup(s => s.DeleteMessageBatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DeleteMessageBatchResponse()); + var queue = new TestableAmazonCommandQueue(_marshaller, options, sqs.Object); + + var received = new List>(); + await foreach (var item in queue.ReceiveAsync()) + { + received.Add(item); + } + + Assert.Single(received); + sqs.Verify(s => s.GetQueueAttributesAsync(It.Is(request => request.QueueUrl == options.SourceQueue.OriginalString), It.IsAny()), Times.Once); + } + + private static IMessage CreateMessage() + { + return new Message( + Guid.NewGuid().ToString("N"), + new Uri("urn:test"), + "test", + new TestCommand()); + } + + private sealed class TestableAmazonCommandQueue : AmazonCommandQueue + { + private readonly IAmazonSQS _sqs; + + public TestableAmazonCommandQueue(IMarshaller marshaller, AmazonCommandQueueOptions options, IAmazonSQS sqs) : base(marshaller, options) + { + _sqs = sqs; + } + + protected override IAmazonSQS CreateSimpleQueueServiceClient() + { + return _sqs; + } + } + + private record TestCommand : Command { } } } diff --git a/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/AmazonEventBusTest.cs b/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/AmazonEventBusTest.cs index 68e4def6..10cdede6 100644 --- a/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/AmazonEventBusTest.cs +++ b/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/AmazonEventBusTest.cs @@ -8,6 +8,8 @@ using Amazon.Runtime; using Amazon.SimpleNotificationService; using Amazon.SimpleNotificationService.Model; +using Amazon.SQS; +using Amazon.SQS.Model; using Codebelt.Extensions.Xunit; using Cuemon; using Cuemon.Extensions; @@ -79,5 +81,142 @@ public void GetHealthCheckTarget_Should_Return_IAmazonSimpleNotificationService_ Assert.NotNull(sns); Assert.IsAssignableFrom(sns); } + + [Fact] + public void GetHealthCheckTarget_Should_Use_Configured_Client_When_Available() + { + var marshaller = new JsonMarshaller(); + var options = new AmazonEventBusOptions + { + Credentials = new BasicAWSCredentials("key", "secret"), + Endpoint = RegionEndpoint.EUWest1, + SourceQueue = new Uri("https://sqs.eu-west-1.amazonaws.com/123456789012/MyQueue") + }; + options.ConfigureClient(config => + { + config.ServiceURL = "http://localhost:4566"; + config.AuthenticationRegion = RegionEndpoint.EUWest1.SystemName; + }); + + var sut = new AmazonEventBus(marshaller, options); + + var sns = sut.GetHealthCheckTarget(); + + Assert.Equal("http://localhost:4566/", sns.Config.ServiceURL); + Assert.Equal(RegionEndpoint.EUWest1.SystemName, sns.Config.AuthenticationRegion); + } + + [Fact] + public async Task PublishAsync_ShouldSendSerializedEvent() + { + var sns = new Mock(); + PublishRequest request = null; + sns.Setup(s => s.PublishAsync(It.IsAny(), It.IsAny())) + .Callback((r, _) => request = r) + .ReturnsAsync(new PublishResponse()); + var bus = new TestableAmazonEventBus(new JsonMarshaller(), CreateOptions("https://sqs.eu-west-1.amazonaws.com/123456789012/MyQueue.fifo"), sns.Object); + var message = CreateMessage(); + + await bus.PublishAsync(message); + + Assert.NotNull(request); + Assert.Equal(message.Source, request.TopicArn); + Assert.Equal(message.Source, request.MessageGroupId); + Assert.Equal(message.Id, request.MessageDeduplicationId); + Assert.Equal(message.GetType().ToFullNameIncludingAssemblyName(), request.MessageAttributes["type"].StringValue); + } + + [Fact] + public async Task SubscribeAsync_ShouldReceiveMessagesAndInvokeHandler() + { + var message = CreateMessage(); + var sqsMessage = new Message + { + Body = new JsonMarshaller().Serialize(message).ToEncodedString(), + MessageId = "message-id", + ReceiptHandle = "receipt-handle", + MessageAttributes = new Dictionary + { + ["type"] = new Amazon.SQS.Model.MessageAttributeValue + { + DataType = "String", + StringValue = message.GetType().ToFullNameIncludingAssemblyName() + } + } + }; + var sqs = new Mock(); + sqs.SetupSequence(s => s.ReceiveMessageAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new ReceiveMessageResponse { Messages = [sqsMessage] }) + .ReturnsAsync(new ReceiveMessageResponse { Messages = [] }); + sqs.Setup(s => s.DeleteMessageBatchAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new DeleteMessageBatchResponse()); + var bus = new TestableAmazonEventBus(new JsonMarshaller(), CreateOptions("https://sqs.eu-west-1.amazonaws.com/123456789012/MyQueue"), Mock.Of(), sqs.Object); + IMessage received = null; + + await bus.SubscribeAsync((msg, _) => + { + received = msg; + return Task.CompletedTask; + }); + + Assert.NotNull(received); + Assert.Equal(message.Id, received.Id); + } + + [Fact] + public async Task SubscribeAsync_ShouldSwallowCancellation_WhenThrowIfCancellationWasRequestedIsFalse() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var sqs = new Mock(); + sqs.Setup(s => s.ReceiveMessageAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException(cts.Token)); + var bus = new TestableAmazonEventBus(new JsonMarshaller(), CreateOptions("https://sqs.eu-west-1.amazonaws.com/123456789012/MyQueue"), Mock.Of(), sqs.Object); + + await bus.SubscribeAsync((_, _) => Task.CompletedTask, o => o.CancellationToken = cts.Token); + + sqs.Verify(s => s.ReceiveMessageAsync(It.IsAny(), cts.Token), Times.Once); + } + + private static AmazonEventBusOptions CreateOptions(string queueUrl) + { + return new AmazonEventBusOptions + { + Credentials = new AnonymousAWSCredentials(), + Endpoint = RegionEndpoint.EUWest1, + SourceQueue = new Uri(queueUrl) + }; + } + + private static IMessage CreateMessage() + { + return new Message("id", new Uri("arn:aws:sns:eu-west-1:123456789012:topic.fifo"), "type", new DummyIntegrationEvent(), DateTime.UtcNow); + } + + private sealed record DummyIntegrationEvent : IntegrationEvent + { + } + + private sealed class TestableAmazonEventBus : AmazonEventBus + { + private readonly IAmazonSimpleNotificationService _sns; + private readonly IAmazonSQS _sqs; + + public TestableAmazonEventBus(IMarshaller marshaller, AmazonEventBusOptions options, IAmazonSimpleNotificationService sns, IAmazonSQS sqs = null) : base(marshaller, options) + { + _sns = sns; + _sqs = sqs; + } + + protected override IAmazonSimpleNotificationService CreateSimpleNotificationServiceClient() + { + return _sns; + } + + protected override IAmazonSQS CreateSimpleQueueServiceClient() + { + return _sqs ?? base.CreateSimpleQueueServiceClient(); + } + } } } diff --git a/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/StringExtensionsTest.cs b/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/StringExtensionsTest.cs index cbf6c892..f3d1802c 100644 --- a/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/StringExtensionsTest.cs +++ b/test/Savvyio.Extensions.SimpleQueueService.Tests/EventDriven/StringExtensionsTest.cs @@ -6,27 +6,35 @@ namespace Savvyio.Extensions.SimpleQueueService.EventDriven { public class StringExtensionsTest : Test { - public StringExtensionsTest(ITestOutputHelper output) : base(output) { } + public StringExtensionsTest(ITestOutputHelper output) : base(output) + { + } [Fact] - public void ToSnsUri_ShouldBuildArnWithDefaults() + public void ToSnsUri_Should_Use_Default_Amazon_Resource_Name_Options() { - var uri = "sample-topic".ToSnsUri(); + var result = "order-created".ToSnsUri(); - Assert.Equal(new Uri("arn:aws:sns:eu-west-1:000000000000:sample-topic"), uri); + Assert.Equal($"arn:{AmazonResourceNameOptions.DefaultPartition}:sns:{AmazonResourceNameOptions.DefaultRegion}:{AmazonResourceNameOptions.DefaultAccountId}:order-created", result.OriginalString); } [Fact] - public void ToSnsUri_ShouldRespectCustomOptions() + public void ToSnsUri_Should_Apply_Custom_Amazon_Resource_Name_Options() { - var uri = "topic".ToSnsUri(o => + var result = "order-created".ToSnsUri(o => { - o.Partition = "aws"; - o.Region = "us-east-1"; + o.Partition = "aws-cn"; + o.Region = "cn-north-1"; o.AccountId = "123456789012"; }); - Assert.Equal(new Uri("arn:aws:sns:us-east-1:123456789012:topic"), uri); + Assert.Equal("arn:aws-cn:sns:cn-north-1:123456789012:order-created", result.OriginalString); + } + + [Fact] + public void ToSnsUri_Should_Throw_When_Configurator_Produces_Invalid_Options() + { + Assert.Throws(() => "order-created".ToSnsUri(o => o.AccountId = "bad")); } } } diff --git a/test/Savvyio.Extensions.Text.Json.Tests/Converters/DateTimeConverterTest.cs b/test/Savvyio.Extensions.Text.Json.Tests/Converters/DateTimeConverterTest.cs new file mode 100644 index 00000000..70a0ee89 --- /dev/null +++ b/test/Savvyio.Extensions.Text.Json.Tests/Converters/DateTimeConverterTest.cs @@ -0,0 +1,63 @@ +using System; +using System.Buffers; +using System.Text; +using System.Text.Json; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Savvyio.Extensions.Text.Json.Converters +{ + public class DateTimeConverterTest : Test + { + public DateTimeConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CanConvert_ShouldReturnTrue_ForDateTime() + { + var converter = new DateTimeConverter(); + Assert.True(converter.CanConvert(typeof(DateTime))); + } + + [Fact] + public void CanConvert_ShouldReturnFalse_ForOtherTypes() + { + var converter = new DateTimeConverter(); + Assert.False(converter.CanConvert(typeof(string))); + Assert.False(converter.CanConvert(typeof(DateTimeOffset))); + } + + [Fact] + public void Write_ShouldSerializeDateTime_AsIso8601() + { + var utc = new DateTime(2023, 11, 16, 23, 24, 17, DateTimeKind.Utc); + var converter = new DateTimeConverter(); + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer); + + converter.Write(writer, utc, new JsonSerializerOptions()); + writer.Flush(); + + var json = Encoding.UTF8.GetString(buffer.WrittenSpan); + TestOutput.WriteLine(json); + + Assert.Contains("2023-11-16", json); + } + + [Fact] + public void Read_ShouldDeserializeDateTime_FromIso8601() + { + var utc = new DateTime(2023, 11, 16, 23, 24, 17, DateTimeKind.Utc); + var converter = new DateTimeConverter(); + var jsonString = $"\"{utc:O}\""; + var bytes = Encoding.UTF8.GetBytes(jsonString); + var reader = new Utf8JsonReader(bytes); + reader.Read(); + + var result = converter.Read(ref reader, typeof(DateTime), new JsonSerializerOptions()); + + Assert.Equal(utc, result); + } + } +} diff --git a/test/Savvyio.Extensions.Text.Json.Tests/Converters/DateTimeOffsetConverterTest.cs b/test/Savvyio.Extensions.Text.Json.Tests/Converters/DateTimeOffsetConverterTest.cs new file mode 100644 index 00000000..3a11fb6f --- /dev/null +++ b/test/Savvyio.Extensions.Text.Json.Tests/Converters/DateTimeOffsetConverterTest.cs @@ -0,0 +1,56 @@ +using System; +using System.Buffers; +using System.Text; +using System.Text.Json; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Savvyio.Extensions.Text.Json.Converters +{ + public class DateTimeOffsetConverterTest : Test + { + public DateTimeOffsetConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CanConvert_ReturnsTrue_ForDateTime() + { + var converter = new DateTimeOffsetConverter(); + // Note: CanConvert checks for DateTime (not DateTimeOffset) by design in this implementation + Assert.True(converter.CanConvert(typeof(DateTime))); + } + + [Fact] + public void Write_ShouldSerializeDateTimeOffset_AsIso8601() + { + var dto = new DateTimeOffset(2023, 11, 16, 23, 24, 17, TimeSpan.Zero); + var converter = new DateTimeOffsetConverter(); + var buffer = new ArrayBufferWriter(); + using var writer = new Utf8JsonWriter(buffer); + + converter.Write(writer, dto, new JsonSerializerOptions()); + writer.Flush(); + + var json = Encoding.UTF8.GetString(buffer.WrittenSpan); + TestOutput.WriteLine(json); + + Assert.Contains("2023-11-16", json); + } + + [Fact] + public void Read_ShouldDeserializeDateTimeOffset_FromIso8601() + { + var dto = new DateTimeOffset(2023, 11, 16, 23, 24, 17, TimeSpan.Zero); + var converter = new DateTimeOffsetConverter(); + var jsonString = $"\"{dto:O}\""; + var bytes = Encoding.UTF8.GetBytes(jsonString); + var reader = new Utf8JsonReader(bytes); + reader.Read(); + + var result = converter.Read(ref reader, typeof(DateTimeOffset), new JsonSerializerOptions()); + + Assert.Equal(dto, result); + } + } +} diff --git a/test/Savvyio.Extensions.Text.Json.Tests/Converters/MetadataDictionaryConverterTest.cs b/test/Savvyio.Extensions.Text.Json.Tests/Converters/MetadataDictionaryConverterTest.cs new file mode 100644 index 00000000..75800b56 --- /dev/null +++ b/test/Savvyio.Extensions.Text.Json.Tests/Converters/MetadataDictionaryConverterTest.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Savvyio.Extensions.Text.Json.Converters +{ + public class MetadataDictionaryConverterTest : Test + { + public MetadataDictionaryConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CanConvert_ShouldReturnTrue_ForIMetadataDictionary() + { + var converter = new MetadataDictionaryConverter(); + + Assert.True(converter.CanConvert(typeof(IMetadataDictionary))); + Assert.True(converter.CanConvert(typeof(MetadataDictionary))); + } + + [Fact] + public void CanConvert_ShouldReturnFalse_ForUnrelatedTypes() + { + var converter = new MetadataDictionaryConverter(); + + Assert.False(converter.CanConvert(typeof(string))); + Assert.False(converter.CanConvert(typeof(int))); + } + + [Fact] + public void Read_ShouldHandleBooleanFalse() + { + var json = """{"flag": false}"""; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Equal(false, result["flag"]); + } + + [Fact] + public void Read_ShouldHandleBooleanTrue() + { + var json = """{"flag": true}"""; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Equal(true, result["flag"]); + } + + [Fact] + public void Read_ShouldHandleNullValue() + { + var json = """{"key": null}"""; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Null(result["key"]); + } + + [Fact] + public void Read_ShouldHandleInt64Number() + { + var json = """{"count": 9876543210}"""; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Equal(9876543210L, result["count"]); + } + + [Fact] + public void Read_ShouldHandleDecimalNumber() + { + var json = """{"price": 3.14}"""; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.Equal(3.14m, result["price"]); + } + + [Fact] + public void Read_ShouldHandleNestedObject() + { + var json = """{"outer": {"inner": "value"}}"""; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + var nested = Assert.IsAssignableFrom(result["outer"]); + Assert.Equal("value", nested["inner"]); + } + + [Fact] + public void Read_ShouldHandleArrayValue() + { + var json = """{"items": ["a", "b", "c"]}"""; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + var list = Assert.IsAssignableFrom>(result["items"]); + Assert.Equal(3, list.Count); + Assert.Equal("a", list[0]); + Assert.Equal("b", list[1]); + Assert.Equal("c", list[2]); + + TestOutput.WriteLine(string.Join(", ", list)); + } + + [Fact] + public void Read_ShouldDeserializeDateTimeString() + { + var dt = new DateTime(2023, 11, 16, 23, 24, 17, DateTimeKind.Utc); + var json = $"{{\"ts\": \"{dt:O}\"}}"; + var options = CreateOptions(); + + var result = JsonSerializer.Deserialize(json, options); + + Assert.NotNull(result); + Assert.IsType(result["ts"]); + Assert.Equal(dt, (DateTime)result["ts"]); + + TestOutput.WriteLine(result["ts"].ToString()); + } + + [Fact] + public void Write_ShouldSerializeMetadataDictionary() + { + var md = new MetadataDictionary + { + { "key1", "value1" }, + { "key2", 99L }, + { "key3", false } + }; + + var options = CreateOptions(); + var json = JsonSerializer.Serialize(md, options); + + TestOutput.WriteLine(json); + + Assert.Contains("key1", json); + Assert.Contains("value1", json); + Assert.Contains("key2", json); + Assert.Contains("99", json); + Assert.Contains("key3", json); + Assert.Contains("false", json); + } + + private static JsonSerializerOptions CreateOptions() + { + var options = new JsonSerializerOptions(); + options.Converters.Add(new MetadataDictionaryConverter()); + return options; + } + } +} diff --git a/test/Savvyio.Extensions.Text.Json.Tests/JsonConverterExtensionsTest.cs b/test/Savvyio.Extensions.Text.Json.Tests/JsonConverterExtensionsTest.cs new file mode 100644 index 00000000..110c857b --- /dev/null +++ b/test/Savvyio.Extensions.Text.Json.Tests/JsonConverterExtensionsTest.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using Codebelt.Extensions.Xunit; +using Savvyio.Extensions.Text.Json.Converters; +using Xunit; + +namespace Savvyio.Extensions.Text.Json +{ + public class JsonConverterExtensionsTest : Test + { + public JsonConverterExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddDateTimeConverter_ShouldAddConverter() + { + var converters = new List(); + converters.AddDateTimeConverter(); + + Assert.Single(converters, c => c is DateTimeConverter); + } + + [Fact] + public void AddDateTimeOffsetConverter_ShouldAddConverter() + { + var converters = new List(); + converters.AddDateTimeOffsetConverter(); + + Assert.Single(converters, c => c is DateTimeOffsetConverter); + } + + [Fact] + public void AddSingleValueObjectConverter_ShouldAddConverter() + { + var converters = new List(); + converters.AddSingleValueObjectConverter(); + + Assert.Single(converters, c => c is SingleValueObjectConverter); + } + + [Fact] + public void AddMetadataDictionaryConverter_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).AddMetadataDictionaryConverter()); + } + + [Fact] + public void AddMessageConverter_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).AddMessageConverter()); + } + + [Fact] + public void AddRequestConverter_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).AddRequestConverter()); + } + + [Fact] + public void AddDateTimeConverter_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).AddDateTimeConverter()); + } + + [Fact] + public void AddDateTimeOffsetConverter_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).AddDateTimeOffsetConverter()); + } + + [Fact] + public void AddSingleValueObjectConverter_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).AddSingleValueObjectConverter()); + } + + [Fact] + public void RemoveAllOf_Generic_ShouldRemoveMatchingConverter() + { + var converters = new List(); + converters.AddDateTimeConverter(); + converters.AddDateTimeOffsetConverter(); + + Assert.Equal(2, converters.Count); + + converters.RemoveAllOf(); + + Assert.DoesNotContain(converters, c => c is DateTimeConverter); + } + + [Fact] + public void RemoveAllOf_Generic_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).RemoveAllOf()); + } + + [Fact] + public void RemoveAllOf_Params_ShouldRemoveMatchingConverters() + { + var converters = new List(); + converters.AddDateTimeConverter(); + converters.AddDateTimeOffsetConverter(); + + converters.RemoveAllOf(typeof(DateTime), typeof(DateTimeOffset)); + + Assert.Empty(converters); + } + + [Fact] + public void RemoveAllOf_Params_ShouldThrowArgumentNullException_WhenConvertersIsNull() + { + Assert.Throws(() => ((ICollection)null).RemoveAllOf(typeof(DateTime))); + } + } +} diff --git a/test/Savvyio.Extensions.Text.Json.Tests/JsonSerializerOptionsExtensionsTest.cs b/test/Savvyio.Extensions.Text.Json.Tests/JsonSerializerOptionsExtensionsTest.cs new file mode 100644 index 00000000..ba2678fd --- /dev/null +++ b/test/Savvyio.Extensions.Text.Json.Tests/JsonSerializerOptionsExtensionsTest.cs @@ -0,0 +1,42 @@ +using System; +using System.Text.Json; +using Codebelt.Extensions.Xunit; +using Savvyio.Extensions.Text.Json.Converters; +using Xunit; + +namespace Savvyio.Extensions.Text.Json +{ + public class JsonSerializerOptionsExtensionsTest : Test + { + public JsonSerializerOptionsExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Clone_ShouldThrowArgumentNullException_WhenOptionsIsNull() + { + Assert.Throws(() => ((JsonSerializerOptions)null).Clone()); + } + + [Fact] + public void Clone_ShouldReturnNewInstance_WithoutSetup() + { + var original = new JsonSerializerOptions { WriteIndented = true }; + var cloned = original.Clone(); + + Assert.NotSame(original, cloned); + Assert.True(cloned.WriteIndented); + } + + [Fact] + public void Clone_ShouldApplySetup_WhenProvided() + { + var original = new JsonSerializerOptions { WriteIndented = true }; + var cloned = original.Clone(o => o.WriteIndented = false); + + Assert.NotSame(original, cloned); + Assert.True(original.WriteIndented); + Assert.False(cloned.WriteIndented); + } + } +} diff --git a/test/Savvyio.FunctionalTests/DistributedMediatorTest.cs b/test/Savvyio.FunctionalTests/DistributedMediatorTest.cs index a833a8b4..99e17671 100644 --- a/test/Savvyio.FunctionalTests/DistributedMediatorTest.cs +++ b/test/Savvyio.FunctionalTests/DistributedMediatorTest.cs @@ -33,6 +33,7 @@ using System; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using Xunit; using Xunit.v3.Priority; @@ -43,6 +44,7 @@ namespace Savvyio public class DistributedMediatorTest : Test { private static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + private static readonly string UniqueId = $"{Guid.NewGuid():N}"; public DistributedMediatorTest() { @@ -67,7 +69,7 @@ public async Task EmulateWebApi_Controller_SendCommand() var correlationId = Guid.NewGuid().ToString("N"); var platformProviderId = Guid.NewGuid(); var fullName = "Michael Amazortensen"; - var emailAddress = IsLinux ? "linux@aws.dev" : "windows@aws.dev"; + var emailAddress = IsLinux ? $"linux-{UniqueId}@aws.dev" : $"windows-{UniqueId}@aws.dev"; var createAccount = new CreateAccount(platformProviderId, fullName, emailAddress).SetCorrelationId(correlationId); @@ -103,6 +105,8 @@ public async Task EmulateWorker_ReceiveCommand() o.Credentials = new BasicAWSCredentials(context.Configuration["AWS:IAM:AccessKey"], context.Configuration["AWS:IAM:SecretKey"]); o.Endpoint = RegionEndpoint.EUWest1; o.SourceQueue = new Uri($"https://sqs.eu-west-1.amazonaws.com/{context.Configuration["AWS:CallerIdentity"]}/distribute-mediator-test"); + o.ReceiveContext.AssumeMessageProcessed = false; + o.ReceiveContext.VisibilityTimeout = TimeSpan.FromSeconds(5); }); services.AddAmazonEventBus(o => @@ -125,16 +129,29 @@ public async Task EmulateWorker_ReceiveCommand() var commandQueue = scope.ServiceProvider.GetRequiredService>(); var mediator = scope.ServiceProvider.GetRequiredService(); - var createAccountMessage = await commandQueue.ReceiveAsync().SingleOrDefaultAsync().ConfigureAwait(false); - Assert.IsType(createAccountMessage.Data); + IMessage createAccountMessage = null; + var cmdDeadline = DateTime.UtcNow.AddSeconds(90); + while (createAccountMessage == null && DateTime.UtcNow < cmdDeadline) + { + await foreach (var msg in commandQueue.ReceiveAsync()) + { + if (msg.Data is CreateAccount ca && ca.EmailAddress.Contains(UniqueId)) + { + await msg.AcknowledgeAsync().ConfigureAwait(false); + createAccountMessage = msg; + } + } + } + + Assert.NotNull(createAccountMessage); + var receivedCommand = Assert.IsType(createAccountMessage.Data); - await mediator.CommitAsync(createAccountMessage.Data); + await mediator.CommitAsync(receivedCommand); var accounts = scope.ServiceProvider.GetRequiredService>(); - var expectedEmailAddress = IsLinux ? "linux@aws.dev" : "windows@aws.dev"; - var entity = await accounts.FindAllAsync(account => account.EmailAddress == expectedEmailAddress).SingleOrDefaultAsync(); + var entity = await accounts.FindAllAsync(account => account.EmailAddress == receivedCommand.EmailAddress).SingleOrDefaultAsync(); var dao = await mediator.QueryAsync(new GetAccount(entity.Id)).ConfigureAwait(false); @@ -157,6 +174,8 @@ public async Task EmulateAnotherWorker_SubscribingToAccountCreated() o.Credentials = new BasicAWSCredentials(context.Configuration["AWS:IAM:AccessKey"], context.Configuration["AWS:IAM:SecretKey"]); o.Endpoint = RegionEndpoint.EUWest1; o.SourceQueue = new Uri($"https://sqs.eu-west-1.amazonaws.com/{context.Configuration["AWS:CallerIdentity"]}/distribute-mediator-test.fifo"); + o.ReceiveContext.AssumeMessageProcessed = false; + o.ReceiveContext.VisibilityTimeout = TimeSpan.FromSeconds(5); }); AmazonResourceNameOptions.DefaultAccountId = context.Configuration["AWS:CallerIdentity"]; @@ -164,12 +183,24 @@ public async Task EmulateAnotherWorker_SubscribingToAccountCreated() var eventBus = test.Host.Services.GetRequiredService>(); var invocationCount = 0; - await eventBus.SubscribeAsync((message, token) => + var deadline = DateTime.UtcNow.AddSeconds(90); + while (invocationCount == 0 && DateTime.UtcNow < deadline) { - invocationCount++; - Assert.IsType(message.Data); - return Task.CompletedTask; - }).ConfigureAwait(false); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + await eventBus.SubscribeAsync(async (message, token) => + { + if (message.Data is AccountCreated ac && ac.EmailAddress.Contains(UniqueId)) + { + invocationCount++; + await message.AcknowledgeAsync().ConfigureAwait(false); + cts.Cancel(); + } + }, o => + { + o.CancellationToken = cts.Token; + o.ThrowIfCancellationWasRequested = false; + }).ConfigureAwait(false); + } Assert.Equal(1, invocationCount); } } diff --git a/testenvironments.json b/testenvironments.json index e373b98f..af571b18 100644 --- a/testenvironments.json +++ b/testenvironments.json @@ -1,22 +1,22 @@ { - "version": "1", - "environments": [ - { - "name": "WSL-Ubuntu", - "type": "wsl", - "wslDistribution": "Ubuntu-24.04" - }, - { - "name": "Docker-Ubuntu (net9)", - "type": "docker", - "dockerImage": "codebeltnet/ubuntu-testrunner:9", - "dockerArgs": "-e AWS__CallerIdentity -e AWS__IAM__AccessKey -e AWS__IAM__SecretKey" - }, - { - "name": "Docker-Ubuntu (net10)", - "type": "docker", - "dockerImage": "codebeltnet/ubuntu-testrunner:10", - "dockerArgs": "-e AWS__CallerIdentity -e AWS__IAM__AccessKey -e AWS__IAM__SecretKey" - } - ] + "version": "1", + "environments": [ + { + "name": "WSL-Ubuntu", + "type": "wsl", + "wslDistribution": "Ubuntu-24.04" + }, + { + "name": "Docker (net9)", + "type": "docker", + "dockerImage": "mcr.microsoft.com/dotnet/sdk:9.0", + "dockerArgs": "-e AWS__CallerIdentity -e AWS__IAM__AccessKey -e AWS__IAM__SecretKey" + }, + { + "name": "Docker (net10)", + "type": "docker", + "dockerImage": "mcr.microsoft.com/dotnet/sdk:10.0", + "dockerArgs": "-e AWS__CallerIdentity -e AWS__IAM__AccessKey -e AWS__IAM__SecretKey" + } + ] }