diff --git a/.bot/README.md b/.bot/README.md new file mode 100644 index 0000000..de97dce --- /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/.github/copilot-instructions.md b/.github/copilot-instructions.md index c646bce..199810c 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/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml index 50de1b4..68a82d8 100644 --- a/.github/workflows/ci-pipeline.yml +++ b/.github/workflows/ci-pipeline.yml @@ -12,6 +12,10 @@ on: options: - Debug - Release + run_mac_tests: + type: boolean + description: Run the macOS test matrix despite the additional cost and runtime. + default: false permissions: contents: read @@ -21,6 +25,7 @@ jobs: name: initialize runs-on: ubuntu-24.04 outputs: + run-mac-tests: ${{ steps.vars.outputs.run-mac-tests }} run-privileged-jobs: ${{ steps.vars.outputs.run-privileged-jobs }} strong-name-key-filename: ${{ steps.vars.outputs.strong-name-key-filename }} build-switches: ${{ steps.vars.outputs.build-switches }} @@ -29,6 +34,12 @@ jobs: name: calculate workflow variables shell: bash run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.run_mac_tests }}" == "true" ]]; then + echo "run-mac-tests=true" >> "$GITHUB_OUTPUT" + else + echo "run-mac-tests=false" >> "$GITHUB_OUTPUT" + fi + if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.repository }}" ]]; then echo "run-privileged-jobs=false" >> "$GITHUB_OUTPUT" echo "strong-name-key-filename=" >> "$GITHUB_OUTPUT" @@ -100,11 +111,73 @@ jobs: restore: true # net48 requires restore build: true # net48 requires build download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_mac: + if: ${{ needs.init.outputs.run-mac-tests == 'true' }} + name: call-test-mac + needs: [init, build] + strategy: + fail-fast: false + matrix: + arch: [X64, ARM64] + configuration: [Debug, Release] + uses: codebeltnet/jobs-dotnet-test/.github/workflows/default.yml@v3 + with: + runs-on: ${{ matrix.arch == 'ARM64' && 'macos-26' || 'macos-26-intel' }} + configuration: ${{ matrix.configuration }} + build-switches: -p:SkipSignAssembly=true + restore: true + build: true + download-pattern: build-${{ matrix.configuration }}-${{ matrix.arch }} + + test_qualitygate: + if: ${{ always() }} + name: test-qualitygate + needs: [init, test_linux, test_windows, test_mac] + runs-on: ubuntu-24.04 + steps: + - name: Evaluate test results + shell: bash + env: + RUN_MAC_TESTS: ${{ needs.init.outputs.run-mac-tests }} + TEST_LINUX_RESULT: ${{ needs.test_linux.result }} + TEST_WINDOWS_RESULT: ${{ needs.test_windows.result }} + TEST_MAC_RESULT: ${{ needs.test_mac.result }} + run: | + require_success() { + local job_name="$1" + local job_result="$2" + + if [[ "$job_result" != "success" ]]; then + echo "::error::$job_name finished with '$job_result'." + exit 1 + fi + } + + require_success_or_skip() { + local job_name="$1" + local job_enabled="$2" + local job_result="$3" + + if [[ "$job_enabled" == "true" ]]; then + require_success "$job_name" "$job_result" + return + fi + + if [[ "$job_result" != "success" && "$job_result" != "skipped" ]]; then + echo "::error::$job_name finished with '$job_result' while disabled." + exit 1 + fi + } + + require_success "test_linux" "$TEST_LINUX_RESULT" + require_success "test_windows" "$TEST_WINDOWS_RESULT" + require_success_or_skip "test_mac" "$RUN_MAC_TESTS" "$TEST_MAC_RESULT" sonarcloud: - if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} name: call-sonarcloud - needs: [init, build, test_linux, test_windows] + needs: [init, build, test_qualitygate] uses: codebeltnet/jobs-sonarcloud/.github/workflows/default.yml@v3 with: organization: geekle @@ -113,18 +186,18 @@ jobs: secrets: inherit codecov: - if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} name: call-codecov - needs: [init, build, test_linux, test_windows] + needs: [init, build, test_qualitygate] uses: codebeltnet/jobs-codecov/.github/workflows/default.yml@v1 with: - repository: codebeltnet/newtonsoft + repository: codebeltnet/newtonsoft-json secrets: inherit codeql: - if: ${{ needs.init.outputs.run-privileged-jobs == 'true' }} + if: ${{always() && needs.init.outputs.run-privileged-jobs == 'true' && needs.build.result == 'success' && needs.test_qualitygate.result == 'success'}} name: call-codeql - needs: [init, build, test_linux, test_windows] + needs: [init, build, test_qualitygate] uses: codebeltnet/jobs-codeql/.github/workflows/default.yml@v3 permissions: security-events: write @@ -132,7 +205,7 @@ jobs: deploy: if: github.event_name != 'pull_request' name: call-nuget - needs: [build, pack, test_linux, test_windows, sonarcloud, codecov, codeql] + needs: [build, pack, test_qualitygate, 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 b2d91bd..2c4c6f0 100644 --- a/.gitignore +++ b/.gitignore @@ -374,4 +374,8 @@ FodyWeavers.xsd *.code-workspace # Strong-Name Key -*.snk \ No newline at end of file +*.snk + +# Bot workspace (local-only AI agent ideation, PRDs, and agentic loop state) +.bot/* +!.bot/README.md \ No newline at end of file diff --git a/.nuget/Codebelt.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt b/.nuget/Codebelt.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt index 832592a..b78022b 100644 --- a/.nuget/Codebelt.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt +++ b/.nuget/Codebelt.Extensions.Newtonsoft.Json/PackageReleaseNotes.txt @@ -1,187 +1,191 @@ Version: 10.1.4 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 # ALM - CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) +# Bug Fixes +- FIXED TransientFaultExceptionConverter now handles null evidence gracefully by creating a default TransientFaultEvidence instance +- FIXED JDataResultExtensions now validates PropertyName is not null or empty before attempting wildcard matching + Version: 10.1.3 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.1.2 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.1.1 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.1.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.0.3 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.0.2 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.0.1 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 10.0.0 +Availability: .NET 10, .NET 9 and .NET Standard 2.0 # ALM +- REMOVED Support for TFM .NET 8 (LTS) +- ADDED TFM for .NET 10 (LTS) - CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) -Version: 10.1.2 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 10.1.1 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 10.1.0 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 10.0.3 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 10.0.2 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 10.0.1 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 10.0.0 -Availability: .NET 10, .NET 9 and .NET Standard 2.0 - -# ALM -- REMOVED Support for TFM .NET 8 (LTS) -- ADDED TFM for .NET 10 (LTS) -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.8 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.7 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.6 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.5 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.4 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.3 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) - -Version: 9.0.2 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 9.0.1 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 9.0.0 -Availability: .NET 9, .NET 8 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs -- REMOVED Support for TFM .NET 6 (LTS) - -# Breaking Changes -- RENAMED DynamicJsonConverter class in the Codebelt.Extensions.Newtonsoft.Json namespace to JsonConverterFactory - -# New Features -- ADDED FailureConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to convert FailureConverter to JSON - -# Improvements -- EXTENDED JsonConverterCollectionExtensions class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to include one new extension method: AddFailureConverter - -# Quality Analysis Actions -- CHANGED ValidatorExtensions class in the Codebelt.Extensions.Newtonsoft.Json namespace to be compliant with https://rules.sonarsource.com/csharp/type/Bug/RSPEC-3343/ (breaking change) - -Version: 8.4.0 -Availability: .NET 8, .NET 6 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 8.3.2 -Availability: .NET 8, .NET 6 and .NET Standard 2.0 - -# ALM -- REMOVED Support for TFM .NET 7 (STS) - -Version: 8.3.0 -Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 - -# Bug Fixes -- FIXED ExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to use Environment.NewLine instead of Alphanumeric.NewLine (vital for non-Windows operating systems) - -Version: 8.2.0 -Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -Version: 8.1.0 -Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -# Improvements -- CHANGED NewtonsoftJsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to derive from IExceptionDescriptorOptions - -Version: 8.0.1 -Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 - -# ALM -- CHANGED Dependencies to latest and greatest with respect to TFMs - -# Improvements -- CHANGED NewtonsoftJsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to be consistent with general date time handling; applied DateFormatString = "O" - -Version: 8.0.0 -Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 - -# ALM -- ADDED TFM for net8.0 -- CHANGED Dependencies to latest and greatest with respect to TFMs - -# Breaking Changes -- CHANGED Create{T} method signature on DynamicContractResolver in the Codebelt.Extensions.Newtonsoft.Json namespace to support an additional argument (PropertyInfo) in the params Action{PropertyInfo, JsonProperty} array -- RENAMED JsonFormatter class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to NewtonsoftJsonFormatter -- RENAMED JsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to NewtonsoftJsonFormatterOptions - -# New Features -- ADDED TransientFaultExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters to convert TransientFaultException to and from JSON -- EXTENDED JsonConverterCollectionExtensions class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace with a new extension method for the JsonConverter class: AddTransientFaultExceptionConverter -- EXTENDED JsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to include a new default converter: AddTransientFaultExceptionConverter - -# Improvements -- CHANGED ExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to support deserialization of Exception types - -# Quality Analysis Actions -- CHANGED ExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to be compliant with https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1822 +Version: 9.0.8 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.7 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.6 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.5 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.4 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.3 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs) + +Version: 9.0.2 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 9.0.1 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 9.0.0 +Availability: .NET 9, .NET 8 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs +- REMOVED Support for TFM .NET 6 (LTS) + +# Breaking Changes +- RENAMED DynamicJsonConverter class in the Codebelt.Extensions.Newtonsoft.Json namespace to JsonConverterFactory + +# New Features +- ADDED FailureConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to convert FailureConverter to JSON + +# Improvements +- EXTENDED JsonConverterCollectionExtensions class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to include one new extension method: AddFailureConverter + +# Quality Analysis Actions +- CHANGED ValidatorExtensions class in the Codebelt.Extensions.Newtonsoft.Json namespace to be compliant with https://rules.sonarsource.com/csharp/type/Bug/RSPEC-3343/ (breaking change) + +Version: 8.4.0 +Availability: .NET 8, .NET 6 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 8.3.2 +Availability: .NET 8, .NET 6 and .NET Standard 2.0 + +# ALM +- REMOVED Support for TFM .NET 7 (STS) + +Version: 8.3.0 +Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 + +# Bug Fixes +- FIXED ExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to use Environment.NewLine instead of Alphanumeric.NewLine (vital for non-Windows operating systems) + +Version: 8.2.0 +Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +Version: 8.1.0 +Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +# Improvements +- CHANGED NewtonsoftJsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to derive from IExceptionDescriptorOptions + +Version: 8.0.1 +Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 + +# ALM +- CHANGED Dependencies to latest and greatest with respect to TFMs + +# Improvements +- CHANGED NewtonsoftJsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to be consistent with general date time handling; applied DateFormatString = "O" + +Version: 8.0.0 +Availability: .NET 8, .NET 7, .NET 6 and .NET Standard 2.0 + +# ALM +- ADDED TFM for net8.0 +- CHANGED Dependencies to latest and greatest with respect to TFMs + +# Breaking Changes +- CHANGED Create{T} method signature on DynamicContractResolver in the Codebelt.Extensions.Newtonsoft.Json namespace to support an additional argument (PropertyInfo) in the params Action{PropertyInfo, JsonProperty} array +- RENAMED JsonFormatter class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to NewtonsoftJsonFormatter +- RENAMED JsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to NewtonsoftJsonFormatterOptions + +# New Features +- ADDED TransientFaultExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters to convert TransientFaultException to and from JSON +- EXTENDED JsonConverterCollectionExtensions class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace with a new extension method for the JsonConverter class: AddTransientFaultExceptionConverter +- EXTENDED JsonFormatterOptions class in the Codebelt.Extensions.Newtonsoft.Json.Formatters namespace to include a new default converter: AddTransientFaultExceptionConverter + +# Improvements +- CHANGED ExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to support deserialization of Exception types + +# Quality Analysis Actions +- CHANGED ExceptionConverter class in the Codebelt.Extensions.Newtonsoft.Json.Converters namespace to be compliant with https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1822 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8048938 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Agent Instructions for Codebelt.Extensions.Newtonsoft.Json + +This document provides guidance for AI agents working in this repository. + +## Project Overview + +Codebelt.Extensions.Newtonsoft.Json is a suite of .NET libraries providing uniform, opinionated, and extensible APIs for working with Newtonsoft.Json (JSON.NET). The solution targets .NET 10.0, .NET 9.0, and .NET Standard 2.0. It includes: + +- **Codebelt.Extensions.Newtonsoft.Json** — Core extensions for JSON serialization, dynamic contracts, and converters. +- **Codebelt.Extensions.AspNetCore.Newtonsoft.Json** — ASP.NET Core integration with bootstrapping and dependency injection support. +- **Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json** — ASP.NET Core MVC input/output formatters with JSON serialization. + +## Coding Standards + +- **Text encoding:** UTF-8 for text files (enforced via `.editorconfig`) +- **Template rewrites:** Preserve UTF-8 explicitly when scripts or tools rewrite text files; avoid locale-dependent encoding defaults +- **Namespaces:** File-scoped namespaces are required (enforced via `.editorconfig`) +- **Top-level statements:** Not allowed (enforced via `.editorconfig`) +- **Language version:** Always use the latest C# features (`LangVersion=latest`) +- **Nullable:** Enable nullable reference types in all new code +- **XML documentation:** All public APIs must have XML documentation comments +- **Testing:** Use xUnit v3 with Codebelt.Extensions.Xunit.App base classes + +## Project Structure + +- `src/` — Production source code + - `Codebelt.Extensions.Newtonsoft.Json/` — Core JSON extensions + - `Codebelt.Extensions.AspNetCore.Newtonsoft.Json/` — ASP.NET Core integration + - `Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json/` — ASP.NET Core MVC formatters + - `Codebelt.Extensions.Newtonsoft.Json.App/` — Application-level tooling and utilities +- `test/` — Unit and integration tests (project names end with `Tests`) +- `.nuget/` — Per-package NuGet metadata (icon, README, release notes) +- `.docfx/` — DocFX documentation configuration +- `.github/` — CI/CD workflows, contributing guidelines, Copilot instructions + +## Test Conventions + +- Test project names must end with `Tests` (e.g., `{PROJECT_NAME}.Tests`) +- Test classes should inherit from the `Test` base class in `Codebelt.Extensions.Xunit` +- Use `Microsoft.Testing.Platform` as the test runner (`UseMicrosoftTestingPlatformRunner=true`) +- All tests are executable (`OutputType=Exe`) +- Test namespaces must match the SUT namespace (System Under Test), without `.Tests` suffix +- See `.github/copilot-instructions.md` for detailed test writing guidelines + +## Build & CI + +- Centralized package versions via `Directory.Packages.props` +- Resolve new or updated `Directory.Packages.props` versions from NuGet.org and keep them on the latest stable listed releases +- Centralized build configuration via `Directory.Build.props` +- MinVer for semantic versioning from Git tags +- Strong-name signing is enabled in CI environments (`CI=true`) +- Keep `.github/dependabot.yml` enabled at the repo root so central NuGet package management stays current + +## .bot/ Folder + +If a `.bot/` folder exists at the root, it contains **confidential, local-only** working material for AI agents — product requirement documents (PRDs), design proposals, agentic loop state, and brainstorming outputs. This folder is gitignored and never committed. + +When starting creative or design work (new features, architecture decisions, PRD drafts), use the [brainstorming skill](https://skills.sh/obra/superpowers/brainstorming) and save outputs to `.bot/`. Only move finalized, non-confidential instructions into `AGENTS.md` or `.github/copilot-instructions.md`. + +## Git Operations Safeguards + +Agents must never automatically commit code changes or push to remote repositories. Both actions require explicit user approval: + +- **Commits**: Always request confirmation from the user before staging and committing code. Present a clear summary of the changes and wait for approval before executing the commit. +- **Remote Operations**: Do not push, pull, fetch, or interact with `origin` or any remote repository without explicit user instruction. These operations modify repository history and can cause data loss if performed unexpectedly. + +**Rationale:** Automatic commits can clutter history with incomplete work, temporary debugging code, or unintended changes. Unexpected remote operations risk overwriting or losing commits on shared branches. Always require explicit user approval before performing these actions. + +## Official Documentation + +- Public API conventions belong in `.docfx/api/namespaces/` and should be treated as the official documentation source for library behavior and naming vocabulary. +- When adding or renaming public APIs, update the relevant namespace page in `.docfx/api/namespaces/` if the change introduces or clarifies a convention. +- Keep internal reasoning, exploratory notes, and agent discussion out of DocFX pages; summarize only stable public guidance. diff --git a/CHANGELOG.md b/CHANGELOG.md index a69db7b..a3907d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,27 @@ For more details, please refer to `PackageReleaseNotes.txt` on a per assembly ba ## [10.1.4] - 2026-06-05 -This is a service update that focuses on package dependencies. +This is a patch release focused on expanding test coverage infrastructure, enhancing CI/CD capabilities, and establishing official guidance for AI agent contributions. + +### Added + +- AGENTS.md as official repository guidance for AI agents working in this codebase, including project overview, coding standards, test conventions, build & CI practices, and git operation safeguards, +- .bot/ directory to gitignore to exclude local AI agent ideation material (with allowance for .bot/README.md), +- Comprehensive unit test suite with new test files to improve code coverage: DynamicContractResolverTest, ExceptionConverterTest, StringFlagsEnumConverterTest, TransientFaultExceptionConverterTest, JDataResultTest, JDataResultExtensionsTest, JsonConverterFactoryTest, JsonSerializerSettingsExtensionsTest, and JsonWriterExtensionsTest. + +### Changed + +- Copilot instructions expanded with explicit guidelines prohibiting ExcludeFromCodeCoverage attributes across all code paths, emphasizing code refactoring over metrics exclusion, +- CI pipeline enhanced with optional macOS testing matrix (X64 and ARM64 variants) controlled via workflow_dispatch input, +- CI pipeline refactored with new test_qualitygate job to orchestrate test result validation and ensure all required test suites complete successfully before downstream quality and deployment jobs, +- Test coverage expanded with additional test methods across MvcBuilderExtensionsTests, NewtonsoftJsonFormatterTest, ContractResolverExtensionsTest, and ValidatorExtensionsTest, +- Microsoft.NET.Test.SDK upgraded from 18.5.1 to 18.6.0. + +### Fixed + +- TransientFaultExceptionConverter class to handle null evidence by providing default initialization, +- JDataResultExtensions class to prevent null reference exceptions by validating PropertyName before path comparison, +- Codecov repository reference corrected from 'codebeltnet/newtonsoft' to 'codebeltnet/newtonsoft-json' in CI pipeline. ## [10.1.3] - 2026-05-22 @@ -236,3 +256,14 @@ This major release is first and foremost focused on ironing out any wrinkles tha - Any types found in the Codebelt.Serialization.Json namespace was merged into the Codebelt.Extensions.Newtonsoft.Json namespace - JsonReaderResultExtensions class from the Codebelt.Extensions.Newtonsoft.Json namespace - JsonReaderParser class from the Codebelt.Extensions.Newtonsoft.Json namespace + +[Unreleased]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.1.4...HEAD +[10.1.4]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.1.3...v10.1.4 +[10.1.3]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.1.2...v10.1.3 +[10.1.2]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.1.1...v10.1.2 +[10.1.1]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.1.0...v10.1.1 +[10.1.0]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.0.3...v10.1.0 +[10.0.3]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.0.2...v10.0.3 +[10.0.2]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.0.1...v10.0.2 +[10.0.1]: https://github.com/codebeltnet/newtonsoft-json/compare/v10.0.0...v10.0.1 +[10.0.0]: https://github.com/codebeltnet/newtonsoft-json/compare/v9.0.8...v10.0.0 diff --git a/Directory.Build.props b/Directory.Build.props index 44daf02..f366c5a 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,6 +3,7 @@ $(MSBuildProjectName.EndsWith('Tests')) $([MSBuild]::IsOSPlatform('Linux')) + $([MSBuild]::IsOSPlatform('OSX')) $([MSBuild]::IsOSPlatform('Windows')) true false @@ -48,7 +49,7 @@ - + net10.0;net9.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 06f8545..76f4cb4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,7 @@ - + diff --git a/src/Codebelt.Extensions.Newtonsoft.Json/Converters/TransientFaultExceptionConverter.cs b/src/Codebelt.Extensions.Newtonsoft.Json/Converters/TransientFaultExceptionConverter.cs index 140a442..35c136a 100644 --- a/src/Codebelt.Extensions.Newtonsoft.Json/Converters/TransientFaultExceptionConverter.cs +++ b/src/Codebelt.Extensions.Newtonsoft.Json/Converters/TransientFaultExceptionConverter.cs @@ -63,6 +63,11 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist innerException = converter?.ReadJson(innerExceptionJson.CreateReader(), Formatter.GetType(innerExceptionJson["type"].Value()), existingValue, serializer) as Exception; } + if (evidence == null) + { + evidence = new TransientFaultEvidence(0, TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero, new MethodSignature(string.Empty, string.Empty, Array.Empty(), Array.Empty())); + } + return new TransientFaultException(message, innerException, evidence); } diff --git a/src/Codebelt.Extensions.Newtonsoft.Json/DynamicContractResolver.cs b/src/Codebelt.Extensions.Newtonsoft.Json/DynamicContractResolver.cs index 754ee63..07dcf2d 100644 --- a/src/Codebelt.Extensions.Newtonsoft.Json/DynamicContractResolver.cs +++ b/src/Codebelt.Extensions.Newtonsoft.Json/DynamicContractResolver.cs @@ -50,20 +50,45 @@ protected override JsonProperty CreateProperty(MemberInfo member, MemberSerializ } } - internal sealed class DynamicCamelCasePropertyNamesContractResolver : CamelCasePropertyNamesContractResolver - { - internal DynamicCamelCasePropertyNamesContractResolver(IEnumerable> jsonPropertyHandlers) - { - JsonPropertyHandlers = jsonPropertyHandlers; - IgnoreSerializableInterface = true; - } - - private IEnumerable> JsonPropertyHandlers { get; set; } - - protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) - { - var property = base.CreateProperty(member, memberSerialization); - foreach (var handler in JsonPropertyHandlers) + internal sealed class DynamicCamelCasePropertyNamesContractResolver : CamelCasePropertyNamesContractResolver + { + private readonly object _contractCacheLock = new(); + private readonly Dictionary _contractCache = new(); + + internal DynamicCamelCasePropertyNamesContractResolver(Action[] jsonPropertyHandlers) + { + JsonPropertyHandlers = jsonPropertyHandlers; + HasJsonPropertyHandlers = jsonPropertyHandlers?.Length > 0; + IgnoreSerializableInterface = true; + } + + private Action[] JsonPropertyHandlers { get; set; } + + private bool HasJsonPropertyHandlers { get; } + + public override JsonContract ResolveContract(Type type) + { + if (type == null) { throw new ArgumentNullException(nameof(type)); } + + if (!HasJsonPropertyHandlers) { return base.ResolveContract(type); } + + // CamelCasePropertyNamesContractResolver shares contracts across instances; handler-backed resolvers need instance-local contracts. + lock (_contractCacheLock) + { + if (!_contractCache.TryGetValue(type, out var contract)) + { + contract = CreateContract(type); + _contractCache[type] = contract; + } + + return contract; + } + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var property = base.CreateProperty(member, memberSerialization); + foreach (var handler in JsonPropertyHandlers) { handler(member as PropertyInfo, property); } diff --git a/src/Codebelt.Extensions.Newtonsoft.Json/JDataResultExtensions.cs b/src/Codebelt.Extensions.Newtonsoft.Json/JDataResultExtensions.cs index 1aa6c28..b24c1c4 100644 --- a/src/Codebelt.Extensions.Newtonsoft.Json/JDataResultExtensions.cs +++ b/src/Codebelt.Extensions.Newtonsoft.Json/JDataResultExtensions.cs @@ -44,7 +44,7 @@ public static void ExtractObjectValues(this IEnumerable source, str foreach (var jr in source) { - if (names.Exists(s => s.Equals(jr.Path, StringComparison.OrdinalIgnoreCase))) + if (!string.IsNullOrEmpty(jr.PropertyName) && names.Exists(s => s.Equals(jr.Path, StringComparison.OrdinalIgnoreCase))) { partial.Add(jr); } @@ -74,7 +74,7 @@ public static void ExtractArrayValues(this IEnumerable source, stri foreach (var jr in source) { - if (names.Exists(s => s.Equals(jr.Path, StringComparison.OrdinalIgnoreCase) || (HasMatchWithAsterisk(s, jr.Path)))) + if (!string.IsNullOrEmpty(jr.PropertyName) && names.Exists(s => s.Equals(jr.Path, StringComparison.OrdinalIgnoreCase) || (HasMatchWithAsterisk(s, jr.Path)))) { partial.Add(jr); } diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/JsonSerializationMvcOptionsSetupTest.cs b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/JsonSerializationMvcOptionsSetupTest.cs new file mode 100644 index 0000000..b5b9fed --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/JsonSerializationMvcOptionsSetupTest.cs @@ -0,0 +1,58 @@ +using System.Linq; +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json +{ + public class JsonSerializationMvcOptionsSetupTest : Test + { + public JsonSerializationMvcOptionsSetupTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldAddInputAndOutputFormatters_ToMvcOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(_ => { }); + services.AddSingleton, JsonSerializationMvcOptionsSetup>(); + + var provider = services.BuildServiceProvider(); + var mvcOptions = new MvcOptions(); + foreach (var configurator in provider.GetServices>()) + { + configurator.Configure(mvcOptions); + } + + var outputFormatters = mvcOptions.OutputFormatters.OfType().ToList(); + var inputFormatters = mvcOptions.InputFormatters.OfType().ToList(); + + Assert.Single(outputFormatters); + Assert.Single(inputFormatters); + } + + [Fact] + public void Ctor_ShouldInsertFormattersAtPositionZero_InMvcOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.Configure(_ => { }); + services.AddSingleton, JsonSerializationMvcOptionsSetup>(); + + var provider = services.BuildServiceProvider(); + var mvcOptions = new MvcOptions(); + foreach (var configurator in provider.GetServices>()) + { + configurator.Configure(mvcOptions); + } + + Assert.IsType(mvcOptions.OutputFormatters[0]); + Assert.IsType(mvcOptions.InputFormatters[0]); + } + } +} diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/JsonSerializerSettingsExtensionsTest.cs b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/JsonSerializerSettingsExtensionsTest.cs new file mode 100644 index 0000000..3fb1431 --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/JsonSerializerSettingsExtensionsTest.cs @@ -0,0 +1,83 @@ +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Cuemon.Configuration; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json +{ + internal class CustomJsonSerializerSettings : JsonSerializerSettings, IParameterObject + { + public CustomJsonSerializerSettings() + { + Formatting = Formatting.Indented; + NullValueHandling = NullValueHandling.Ignore; + ContractResolver = new CamelCasePropertyNamesContractResolver(); + } + } + + public class JsonSerializerSettingsExtensionsTest : Test + { + public JsonSerializerSettingsExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Use_ShouldCopyAllSettings_FromSourceToTarget() + { + var target = new JsonSerializerSettings(); + + target.Use(); + + Assert.Equal(Formatting.Indented, target.Formatting); + Assert.Equal(NullValueHandling.Ignore, target.NullValueHandling); + Assert.IsType(target.ContractResolver); + } + + [Fact] + public void Use_ShouldCopySettings_WithCustomSetup() + { + var target = new JsonSerializerSettings(); + + target.Use(setup => + { + setup.Formatting = Formatting.None; + setup.NullValueHandling = NullValueHandling.Include; + }); + + Assert.Equal(Formatting.None, target.Formatting); + Assert.Equal(NullValueHandling.Include, target.NullValueHandling); + } + + [Fact] + public void Use_ShouldCopyAllDefinedProperties_FromNewtonsoftJsonFormatterOptions() + { + var target = new JsonSerializerSettings(); + target.Use(); + + // Verify all major setting fields are copied over + var source = new CustomJsonSerializerSettings(); + Assert.Equal(source.CheckAdditionalContent, target.CheckAdditionalContent); + Assert.Equal(source.ConstructorHandling, target.ConstructorHandling); + Assert.Equal(source.DateFormatHandling, target.DateFormatHandling); + Assert.Equal(source.DateFormatString, target.DateFormatString); + Assert.Equal(source.DateParseHandling, target.DateParseHandling); + Assert.Equal(source.DateTimeZoneHandling, target.DateTimeZoneHandling); + Assert.Equal(source.DefaultValueHandling, target.DefaultValueHandling); + Assert.Equal(source.FloatFormatHandling, target.FloatFormatHandling); + Assert.Equal(source.FloatParseHandling, target.FloatParseHandling); + Assert.Equal(source.Formatting, target.Formatting); + Assert.Equal(source.MaxDepth, target.MaxDepth); + Assert.Equal(source.MetadataPropertyHandling, target.MetadataPropertyHandling); + Assert.Equal(source.MissingMemberHandling, target.MissingMemberHandling); + Assert.Equal(source.NullValueHandling, target.NullValueHandling); + Assert.Equal(source.ObjectCreationHandling, target.ObjectCreationHandling); + Assert.Equal(source.PreserveReferencesHandling, target.PreserveReferencesHandling); + Assert.Equal(source.ReferenceLoopHandling, target.ReferenceLoopHandling); + Assert.Equal(source.StringEscapeHandling, target.StringEscapeHandling); + Assert.Equal(source.TypeNameAssemblyFormatHandling, target.TypeNameAssemblyFormatHandling); + Assert.Equal(source.TypeNameHandling, target.TypeNameHandling); + } + } +} diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcBuilderExtensionsTests.cs b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcBuilderExtensionsTests.cs index e695274..0c96f76 100644 --- a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcBuilderExtensionsTests.cs +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcBuilderExtensionsTests.cs @@ -1,4 +1,5 @@ -using System.Net.Http.Headers; +using System; +using System.Net.Http.Headers; using System.Threading.Tasks; using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets; using Codebelt.Extensions.Xunit; @@ -549,5 +550,19 @@ public async Task OnException_ShouldCaptureException_RenderAsDefault_UsingNewton break; } } + + [Fact] + public void AddNewtonsoftJsonFormatters_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws(() => + MvcBuilderExtensions.AddNewtonsoftJsonFormatters(null)); + } + + [Fact] + public void AddNewtonsoftJsonFormattersOptions_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws(() => + MvcBuilderExtensions.AddNewtonsoftJsonFormattersOptions(null)); + } } } diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcCoreBuilderExtensionsTest.cs b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcCoreBuilderExtensionsTest.cs new file mode 100644 index 0000000..24d6bde --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcCoreBuilderExtensionsTest.cs @@ -0,0 +1,84 @@ +using System; +using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets; +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.Diagnostics; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json +{ + public class MvcCoreBuilderExtensionsTest : Test + { + public MvcCoreBuilderExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void AddNewtonsoftJsonFormatters_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws(() => + MvcCoreBuilderExtensions.AddNewtonsoftJsonFormatters(null)); + } + + [Fact] + public void AddNewtonsoftJsonFormattersOptions_ShouldThrowArgumentNullException_WhenBuilderIsNull() + { + Assert.Throws(() => + MvcCoreBuilderExtensions.AddNewtonsoftJsonFormattersOptions(null)); + } + + [Fact] + public void AddNewtonsoftJsonFormatters_ShouldRegisterFormatters_ViaMvcCoreBuilder() + { + using var host = WebHostTestFactory.Create(services => + { + services.AddMvcCore() + .AddApplicationPart(typeof(FakeController).Assembly) + .AddNewtonsoftJsonFormatters(); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => routes.MapControllers()); + }, hostFixture: null); + + Assert.NotNull(host); + } + + [Fact] + public void AddNewtonsoftJsonFormatters_ShouldRegisterFormatters_WithSetup_ViaMvcCoreBuilder() + { + using var host = WebHostTestFactory.Create(services => + { + services.AddMvcCore() + .AddApplicationPart(typeof(FakeController).Assembly) + .AddNewtonsoftJsonFormatters(o => o.SensitivityDetails = FaultSensitivityDetails.None); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => routes.MapControllers()); + }, hostFixture: null); + + Assert.NotNull(host); + } + + [Fact] + public void AddNewtonsoftJsonFormattersOptions_ShouldRegisterOptions_ViaMvcCoreBuilder() + { + using var host = WebHostTestFactory.Create(services => + { + services.AddMvcCore() + .AddApplicationPart(typeof(FakeController).Assembly) + .AddNewtonsoftJsonFormattersOptions(o => o.SensitivityDetails = FaultSensitivityDetails.None); + }, app => + { + app.UseRouting(); + app.UseEndpoints(routes => routes.MapControllers()); + }, hostFixture: null); + + Assert.NotNull(host); + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/ExceptionConverterTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/ExceptionConverterTest.cs new file mode 100644 index 0000000..1bbc533 --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/ExceptionConverterTest.cs @@ -0,0 +1,217 @@ +using System; +using System.IO; +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json.Converters +{ + public class ExceptionConverterTest : Test + { + public ExceptionConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Ctor_ShouldSetDefaultValues() + { + var sut = new ExceptionConverter(); + + Assert.False(sut.IncludeStackTrace); + Assert.False(sut.IncludeData); + } + + [Fact] + public void Ctor_ShouldSetSpecifiedValues() + { + var sut = new ExceptionConverter(includeStackTrace: true, includeData: true); + + Assert.True(sut.IncludeStackTrace); + Assert.True(sut.IncludeData); + } + + [Fact] + public void CanConvert_ShouldReturnTrue_ForExceptionTypes() + { + var sut = new ExceptionConverter(); + + Assert.True(sut.CanConvert(typeof(Exception))); + Assert.True(sut.CanConvert(typeof(ArgumentException))); + Assert.True(sut.CanConvert(typeof(InvalidOperationException))); + Assert.True(sut.CanConvert(typeof(OutOfMemoryException))); + } + + [Fact] + public void CanConvert_ShouldReturnFalse_ForNonExceptionTypes() + { + var sut = new ExceptionConverter(); + + Assert.False(sut.CanConvert(typeof(string))); + Assert.False(sut.CanConvert(typeof(int))); + Assert.False(sut.CanConvert(typeof(object))); + } + + [Fact] + public void WriteJson_ShouldSerializeException_WithoutStackTraceAndData() + { + var exception = new ArgumentException("Test message", "paramName"); + var sut = new ExceptionConverter(includeStackTrace: false, includeData: false); + var json = SerializeException(sut, exception); + + TestOutput.WriteLine(json); + + Assert.Contains("\"Type\": \"System.ArgumentException\"", json); + Assert.Contains("\"Message\":", json); + Assert.Contains("Test message", json); + Assert.DoesNotContain("\"Stack\":", json); + Assert.DoesNotContain("\"Data\":", json); + } + + [Fact] + public void WriteJson_ShouldSerializeException_WithStackTrace() + { + Exception exception = null; + try + { + throw new ArgumentException("Test message", "paramName"); + } + catch (Exception e) + { + exception = e; + } + + var sut = new ExceptionConverter(includeStackTrace: true, includeData: false); + var json = SerializeException(sut, exception); + + TestOutput.WriteLine(json); + + Assert.Contains("\"Type\": \"System.ArgumentException\"", json); + Assert.Contains("\"Stack\":", json); + Assert.DoesNotContain("\"Data\":", json); + } + + [Fact] + public void WriteJson_ShouldSerializeException_WithData() + { + var exception = new ArgumentException("Test message"); + exception.Data["key1"] = "value1"; + + var sut = new ExceptionConverter(includeStackTrace: false, includeData: true); + var json = SerializeException(sut, exception); + + TestOutput.WriteLine(json); + + Assert.Contains("\"Type\": \"System.ArgumentException\"", json); + Assert.DoesNotContain("\"Stack\":", json); + Assert.Contains("\"Data\":", json); + Assert.Contains("\"key1\": \"value1\"", json); + } + + [Fact] + public void WriteJson_ShouldSerializeException_WithInnerException() + { + var inner = new InvalidOperationException("inner message"); + var exception = new ArgumentException("outer message", inner); + + var sut = new ExceptionConverter(includeStackTrace: false, includeData: false); + var json = SerializeException(sut, exception); + + TestOutput.WriteLine(json); + + Assert.Contains("\"Type\": \"System.ArgumentException\"", json); + Assert.Contains("\"Inner\":", json); + Assert.Contains("\"Type\": \"System.InvalidOperationException\"", json); + Assert.Contains("\"Message\": \"inner message\"", json); + } + + [Fact] + public void WriteJson_ShouldSerializeAggregateException_WithMultipleInnerExceptions() + { + var agg = new AggregateException( + new InvalidOperationException("e1"), + new ArgumentNullException("e2")); + + var sut = new ExceptionConverter(includeStackTrace: false, includeData: false); + var json = SerializeException(sut, agg); + + TestOutput.WriteLine(json); + + Assert.Contains("\"Type\": \"System.AggregateException\"", json); + Assert.Contains("\"Inner\":", json); + Assert.Contains("\"System.InvalidOperationException\"", json); + Assert.Contains("\"System.ArgumentNullException\"", json); + } + + [Fact] + public void ReadJson_ShouldDeserializeException_RoundTrip() + { + var original = new ArgumentException("Round-trip message"); + var sut = new ExceptionConverter(); + + var json = SerializeException(sut, original); + TestOutput.WriteLine(json); + + var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; + settings.Converters.Add(sut); + var serializer = JsonSerializer.Create(settings); + + using var sr = new StringReader(json); + using var jr = new JsonTextReader(sr); + var deserialized = serializer.Deserialize(jr, typeof(ArgumentException)) as Exception; + + Assert.NotNull(deserialized); + } + + [Fact] + public void ReadJson_ShouldDeserializeException_WithInnerException() + { + var inner = new InvalidOperationException("inner"); + var original = new ArgumentException("outer", inner); + var sut = new ExceptionConverter(); + + var json = SerializeException(sut, original); + TestOutput.WriteLine(json); + + var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; + settings.Converters.Add(sut); + var serializer = JsonSerializer.Create(settings); + + using var sr = new StringReader(json); + using var jr = new JsonTextReader(sr); + var deserialized = serializer.Deserialize(jr, typeof(ArgumentException)) as Exception; + + Assert.NotNull(deserialized); + } + + [Fact] + public void WriteJson_ShouldSerializeException_ViaFormatter_WithCamelCase() + { + var exception = new ArgumentException("Test message"); + var formatter = new NewtonsoftJsonFormatter(); + var json = formatter.Serialize(exception).ToEncodedString(); + + TestOutput.WriteLine(json); + + Assert.Contains("\"type\": \"System.ArgumentException\"", json); + Assert.Contains("\"message\":", json); + } + + private string SerializeException(ExceptionConverter converter, Exception exception) + { + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ContractResolver = new DefaultContractResolver() + }; + settings.Converters.Add(converter); + var serializer = JsonSerializer.Create(settings); + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + serializer.Serialize(jw, exception); + return sw.ToString(); + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/StringFlagsEnumConverterTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/StringFlagsEnumConverterTest.cs new file mode 100644 index 0000000..cea195a --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/StringFlagsEnumConverterTest.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json.Converters +{ + [Flags] + internal enum TestFlagsEnum + { + None = 0, + Option1 = 1, + Option2 = 2, + Option3 = 4 + } + + internal enum TestNonFlagsEnum + { + Value1 = 1, + Value2 = 2, + Value3 = 3 + } + + public class StringFlagsEnumConverterTest : Test + { + public StringFlagsEnumConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CanConvert_ShouldReturnTrue_ForFlagsEnum() + { + var sut = new StringFlagsEnumConverter(); + + Assert.True(sut.CanConvert(typeof(TestFlagsEnum))); + } + + [Fact] + public void CanConvert_ShouldReturnFalse_ForNonFlagsEnum() + { + var sut = new StringFlagsEnumConverter(); + + Assert.False(sut.CanConvert(typeof(TestNonFlagsEnum))); + } + + [Fact] + public void CanConvert_ShouldReturnFalse_ForNonEnumType() + { + var sut = new StringFlagsEnumConverter(); + + Assert.False(sut.CanConvert(typeof(string))); + Assert.False(sut.CanConvert(typeof(int))); + } + + [Fact] + public void WriteJson_ShouldWriteArray_ForFlagsEnum() + { + var value = TestFlagsEnum.Option1 | TestFlagsEnum.Option2; + var sut = new StringFlagsEnumConverter(); + + var json = Serialize(sut, value); + + TestOutput.WriteLine(json); + + Assert.Contains("[", json); + Assert.Contains("option1", json); + Assert.Contains("option2", json); + Assert.Contains("]", json); + } + + [Fact] + public void WriteJson_ShouldWriteArray_ForFlagsEnum_WithNamingStrategy() + { + var value = TestFlagsEnum.Option1 | TestFlagsEnum.Option2; + var sut = new StringFlagsEnumConverter(new DefaultNamingStrategy()); + + var json = Serialize(sut, value); + + TestOutput.WriteLine(json); + + Assert.Contains("[", json); + Assert.Contains("Option1", json); + Assert.Contains("Option2", json); + Assert.Contains("]", json); + } + + [Fact] + public void WriteJson_ShouldWriteSingleValue_ForNonFlagsEnum_WhenCalledDirectly() + { + var value = TestNonFlagsEnum.Value1; + var sut = new StringFlagsEnumConverter(); + + // The CanConvert returns false for non-flags enums, but WriteJson has this code path. + // Call WriteJson directly to exercise the non-flags branch. + var settings = new JsonSerializerSettings(); + var serializer = JsonSerializer.Create(settings); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + sut.WriteJson(jw, value, serializer); + var json = sw.ToString(); + + TestOutput.WriteLine(json); + + // Non-flags enum values are serialized as camelCase string names + Assert.Equal("\"value1\"", json); + } + + [Fact] + public void WriteJson_ShouldWriteNull_WhenValueIsNull() + { + var sut = new StringFlagsEnumConverter(); + + // Call WriteJson directly since CanConvert blocks normal serialization path for null + var settings = new JsonSerializerSettings(); + var serializer = JsonSerializer.Create(settings); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + sut.WriteJson(jw, null, serializer); + var json = sw.ToString(); + + TestOutput.WriteLine(json); + + Assert.Equal("null", json); + } + + [Fact] + public void ReadJson_ShouldDeserializeFlagsArray() + { + var sut = new StringFlagsEnumConverter(); + var settings = new JsonSerializerSettings(); + settings.Converters.Add(sut); + var serializer = JsonSerializer.Create(settings); + + var json = "[\"Option1\",\"Option2\"]"; + using var sr = new StringReader(json); + using var jr = new JsonTextReader(sr); + + var result = serializer.Deserialize(jr, typeof(TestFlagsEnum)); + + TestOutput.WriteLine($"Deserialized: {result}"); + + var intResult = (int)result; + Assert.Equal((int)(TestFlagsEnum.Option1 | TestFlagsEnum.Option2), intResult); + } + + [Fact] + public void WriteJson_ShouldWriteNumericValue_ForUndefinedEnumValue_WhenCalledDirectly() + { + var sut = new StringFlagsEnumConverter(); + var settings = new JsonSerializerSettings(); + var serializer = JsonSerializer.Create(settings); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + + // An undefined enum value (e.g., 99) is represented as its number string + var undefinedValue = (TestNonFlagsEnum)99; + sut.WriteJson(jw, undefinedValue, serializer); + var json = sw.ToString(); + + TestOutput.WriteLine(json); + + // Undefined numeric enum values should be serialized as their numeric representation + Assert.Equal("99", json); + } + + private static string Serialize(StringFlagsEnumConverter converter, object value) + { + var settings = new JsonSerializerSettings(); + settings.Converters.Add(converter); + var serializer = JsonSerializer.Create(settings); + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + serializer.Serialize(jw, value); + return sw.ToString(); + } + } +} \ No newline at end of file diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/TransientFaultExceptionConverterTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/TransientFaultExceptionConverterTest.cs new file mode 100644 index 0000000..14e652b --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Converters/TransientFaultExceptionConverterTest.cs @@ -0,0 +1,175 @@ +using System; +using System.IO; +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Cuemon.Reflection; +using Cuemon.Resilience; +using Newtonsoft.Json; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json.Converters +{ + public class TransientFaultExceptionConverterTest : Test + { + private static TransientFaultEvidence CreateEvidence() + { + var sig = new MethodSignature("TestCaller", "TestMethod", Array.Empty(), Array.Empty()); + return new TransientFaultEvidence(3, TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(50), sig); + } + + public TransientFaultExceptionConverterTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void CanConvert_ShouldReturnTrue_ForTransientFaultException() + { + var sut = new TransientFaultExceptionConverter(); + + Assert.True(sut.CanConvert(typeof(TransientFaultException))); + } + + [Fact] + public void CanConvert_ShouldReturnFalse_ForOtherTypes() + { + var sut = new TransientFaultExceptionConverter(); + + Assert.False(sut.CanConvert(typeof(Exception))); + Assert.False(sut.CanConvert(typeof(string))); + Assert.False(sut.CanConvert(typeof(int))); + } + + [Fact] + public void WriteJson_ShouldSerializeTransientFaultException_WhenExceptionConverterPresent() + { + var inner = new TimeoutException("Simulated timeout"); + var sut1 = new TransientFaultException("Transient fault occurred", inner, CreateEvidence()); + + var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; + settings.Converters.AddTransientFaultExceptionConverter(); + settings.Converters.AddExceptionConverter(false, false); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + var serializer = JsonSerializer.Create(settings); + serializer.Serialize(jw, sut1); + var json = sw.ToString(); + + TestOutput.WriteLine(json); + + Assert.Contains("\"Type\": \"Cuemon.Resilience.TransientFaultException\"", json); + Assert.Contains("\"Message\":", json); + } + + [Fact] + public void WriteJson_ShouldProduceNoOutput_WhenExceptionConverterNotPresent() + { + var sut1 = new TransientFaultException("Transient fault", null, CreateEvidence()); + + var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; + // Intentionally no ExceptionConverter added + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + jw.WriteStartObject(); + jw.WritePropertyName("test"); + var serializer = JsonSerializer.Create(settings); + var converter = new TransientFaultExceptionConverter(); + converter.WriteJson(jw, sut1, serializer); + jw.WriteEndObject(); + var json = sw.ToString(); + + TestOutput.WriteLine(json); + + // When no ExceptionConverter found, WriteJson does nothing + Assert.Contains("{", json); + } + + [Fact] + public void WriteAndReadJson_ShouldRoundTrip_WithFormatter() + { + var inner = new ArgumentException("inner arg"); + var sut1 = new TransientFaultException("Transient fault", inner, CreateEvidence()); + + var formatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.Converters.AddTransientFaultExceptionConverter(); + o.Settings.Converters.AddExceptionConverter(false, false); + }); + + var stream = formatter.Serialize(sut1, typeof(TransientFaultException)); + var json = new StreamReader(stream).ReadToEnd(); + + TestOutput.WriteLine(json); + + Assert.Contains("transientFaultException", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ReadJson_ShouldDeserializeTransientFaultException_WithoutEvidence() + { + var sut = new TransientFaultExceptionConverter(); + + // When the JSON has no evidence section, a minimal evidence is created so message is preserved + var json = """ + { + "message": "Transient fault occurred" + } + """; + + var settings = new JsonSerializerSettings { Formatting = Formatting.Indented }; + settings.Converters.Add(sut); + settings.Converters.AddExceptionConverter(false, false); + + var serializer = JsonSerializer.Create(settings); + using var sr = new StringReader(json); + using var jr = new JsonTextReader(sr); + var result = serializer.Deserialize(jr, typeof(TransientFaultException)) as TransientFaultException; + + TestOutput.WriteLine(result?.Message ?? "null"); + + // Message should be preserved even when evidence is missing in JSON + Assert.NotNull(result); + Assert.IsType(result); + Assert.Equal("Transient fault occurred", result.Message); + } + + [Fact] + public void ReadJson_ShouldDeserializeTransientFaultException_WithEvidence() + { + var inner = new ArgumentException("inner error"); + var original = new TransientFaultException("Transient fault", inner, CreateEvidence()); + + var formatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.Converters.AddTransientFaultExceptionConverter(); + o.Settings.Converters.AddExceptionConverter(false, false); + }); + + var stream = formatter.Serialize(original, typeof(TransientFaultException)); + var json = new StreamReader(stream).ReadToEnd(); + + TestOutput.WriteLine(json); + + Assert.Contains("transientFaultException", json, StringComparison.OrdinalIgnoreCase); + + stream.Position = 0; + var result = formatter.Deserialize(stream, typeof(TransientFaultException)) as TransientFaultException; + + Assert.NotNull(result); + Assert.Equal("Transient fault", result.Message); + Assert.NotNull(result.InnerException); + Assert.IsType(result.InnerException); + Assert.Equal("inner error", result.InnerException.Message); + + var evidence = result.Evidence; + Assert.NotNull(evidence); + Assert.Equal(3, evidence.Attempts); + Assert.Equal(TimeSpan.FromMilliseconds(100), evidence.RecoveryWaitTime); + Assert.Equal(TimeSpan.FromMilliseconds(300), evidence.TotalRecoveryWaitTime); + Assert.Equal(TimeSpan.FromMilliseconds(50), evidence.Latency); + Assert.Equal("TestCaller", evidence.Descriptor.Caller); + Assert.Equal("TestMethod", evidence.Descriptor.MethodName); + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/DynamicContractResolverTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/DynamicContractResolverTest.cs new file mode 100644 index 0000000..9f4dbfc --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/DynamicContractResolverTest.cs @@ -0,0 +1,135 @@ +using System; +using System.Reflection; +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json +{ + public class DynamicContractResolverTest : Test + { + public DynamicContractResolverTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_ShouldReturnCamelCaseResolver_ForCamelCasePropertyNamesContractResolver() + { + var sut = DynamicContractResolver.Create(); + + Assert.NotNull(sut); + Assert.IsAssignableFrom(sut); + } + + [Fact] + public void Create_ShouldReturnDefaultContractResolver_ForDefaultContractResolver() + { + var sut = DynamicContractResolver.Create(); + + Assert.NotNull(sut); + Assert.IsAssignableFrom(sut); + } + + [Fact] + public void Create_WithHandlers_ShouldApplyHandlersForCamelCaseResolver() + { + var handlerInvoked = false; + var sut = DynamicContractResolver.Create( + (pi, jp) => { handlerInvoked = true; }); + + Assert.NotNull(sut); + + // Trigger CreateProperty by serializing an object + var formatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.ContractResolver = sut; + }); + formatter.Serialize(new { Name = "test", Value = 42 }).ToEncodedString(); + + Assert.True(handlerInvoked); + } + + [Fact] + public void Create_WithHandlers_ShouldApplyHandlersForCamelCaseResolver_WhenContractWasCachedByAnotherResolver() + { + var cachedResolver = DynamicContractResolver.Create(); + var cachedFormatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.ContractResolver = cachedResolver; + }); + cachedFormatter.Serialize(new SampleDto { FirstName = "John", LastName = "Doe" }).ToEncodedString(); + + var handlerInvoked = false; + var sut = DynamicContractResolver.Create( + (pi, jp) => { handlerInvoked = true; }); + var formatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.ContractResolver = sut; + }); + formatter.Serialize(new SampleDto { FirstName = "Jane", LastName = "Doe" }).ToEncodedString(); + + Assert.True(handlerInvoked); + } + + [Fact] + public void Create_WithHandlers_ShouldApplyHandlersForDefaultContractResolver() + { + var handlerInvoked = false; + var sut = DynamicContractResolver.Create( + (pi, jp) => { handlerInvoked = true; }); + + Assert.NotNull(sut); + + // Trigger CreateProperty by serializing an object + var formatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.ContractResolver = sut; + }); + formatter.Serialize(new { Name = "test", Value = 42 }).ToEncodedString(); + + Assert.True(handlerInvoked); + } + + [Fact] + public void Create_ShouldApplyCamelCaseNaming_WhenCamelCaseResolverUsed() + { + var sut = DynamicContractResolver.Create(); + var formatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.ContractResolver = sut; + }); + + var json = formatter.Serialize(new SampleDto { FirstName = "John", LastName = "Doe" }).ToEncodedString(); + + TestOutput.WriteLine(json); + + Assert.Contains("\"firstName\":", json); + Assert.Contains("\"lastName\":", json); + } + + [Fact] + public void Create_ShouldApplyPascalCaseNaming_WhenDefaultContractResolverUsed() + { + var sut = DynamicContractResolver.Create(); + var formatter = new NewtonsoftJsonFormatter(o => + { + o.Settings.ContractResolver = sut; + }); + + var json = formatter.Serialize(new SampleDto { FirstName = "John", LastName = "Doe" }).ToEncodedString(); + + TestOutput.WriteLine(json); + + Assert.Contains("\"FirstName\":", json); + Assert.Contains("\"LastName\":", json); + } + + private class SampleDto + { + public string FirstName { get; set; } + public string LastName { get; set; } + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Formatters/NewtonsoftJsonFormatterTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Formatters/NewtonsoftJsonFormatterTest.cs index c5871a2..7cc1c7b 100644 --- a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Formatters/NewtonsoftJsonFormatterTest.cs +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Formatters/NewtonsoftJsonFormatterTest.cs @@ -7,6 +7,7 @@ using Cuemon; using Cuemon.Diagnostics; using Cuemon.Extensions.IO; +using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using Xunit; @@ -143,5 +144,58 @@ public void Serialize_ShouldSerializeUsingExceptionConverter_WithPascalCase() r.Dispose(); } } + + [Fact] + public void Serialize_ShouldUseJsonConvertDefaultSettings_WhenSynchronizeIsTrue() + { + var originalSettings = JsonConvert.DefaultSettings; + try + { + var f = new NewtonsoftJsonFormatter(o => + { + o.SynchronizeWithJsonConvert = true; + o.Settings.Formatting = Formatting.Indented; + }); + + // JsonConvert.DefaultSettings should now be set + Assert.NotNull(JsonConvert.DefaultSettings); + + var obj = new { Name = "Test", Value = 42 }; + var r = f.Serialize(obj); + var json = new StreamReader(r).ReadToEnd(); + + TestOutput.WriteLine(json); + + Assert.Contains("\"name\":", json); + } + finally + { + JsonConvert.DefaultSettings = originalSettings; + } + } + + [Fact] + public void Deserialize_ShouldUseJsonConvertDefaultSettings_WhenSynchronizeIsTrue() + { + var originalSettings = JsonConvert.DefaultSettings; + try + { + var f = new NewtonsoftJsonFormatter(o => + { + o.SynchronizeWithJsonConvert = true; + }); + + var json = "\"2022-06-26T22:39:14.3512950Z\"".ToStream(); + var dt = f.Deserialize(json); + + TestOutput.WriteLine(dt.ToString("O")); + + Assert.Equal(2022, dt.Year); + } + finally + { + JsonConvert.DefaultSettings = originalSettings; + } + } } } \ No newline at end of file diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JDataResultExtensionsTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JDataResultExtensionsTest.cs new file mode 100644 index 0000000..b820bc3 --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JDataResultExtensionsTest.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json +{ + public class JDataResultExtensionsTest : Test + { + public JDataResultExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Flatten_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable source = null; + Assert.Throws(() => source.Flatten().ToList()); + } + + [Fact] + public void Flatten_ShouldReturnFlatList_FromNestedHierarchy() + { + var formatter = new NewtonsoftJsonFormatter(); + var json = """ + { + "outer": { + "inner": "value" + } + } + """; + using var stream = json.ToStream(); + var results = JData.ReadAll(stream).Flatten().ToList(); + + TestOutput.WriteLine(string.Join(Environment.NewLine, results)); + + Assert.NotEmpty(results); + } + + [Fact] + public void ExtractObjectValues_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable source = null; + Assert.Throws(() => + source.ExtractObjectValues("path", dict => { })); + } + + [Fact] + public void ExtractObjectValues_ShouldThrowArgumentException_WhenPropertyNamesIsNullOrWhiteSpace() + { + var source = new List(); + Assert.Throws(() => + source.ExtractObjectValues(null, dict => { })); + Assert.Throws(() => + source.ExtractObjectValues(" ", dict => { })); + } + + [Fact] + public void ExtractObjectValues_ShouldThrowArgumentNullException_WhenExtractorIsNull() + { + var source = new List(); + Assert.Throws(() => + source.ExtractObjectValues("path", null)); + } + + [Fact] + public void ExtractObjectValues_ShouldExtractValues_ByPropertyPath() + { + var json = """ + [ + { "name": "Alice", "age": 30 }, + { "name": "Bob", "age": 25 } + ] + """; + using var stream = json.ToStream(); + var results = JData.ReadAll(stream).ToList(); + var flatResults = results.Flatten().ToList(); + + TestOutput.WriteLine(string.Join(Environment.NewLine, flatResults.Select(r => $"Path={r.Path}, Name={r.PropertyName}, Value={r.Value}"))); + + // Paths inside array elements have a dot prefix, e.g. ".name" + var extracted = new List(); + flatResults.ExtractObjectValues(".name", dict => + { + if (dict.TryGetValue("name", out var jr)) + { + extracted.Add(jr.Value?.ToString()); + } + }); + + Assert.Equal(2, extracted.Count); + Assert.Contains("Alice", extracted); + Assert.Contains("Bob", extracted); + } + + [Fact] + public void ExtractObjectValues_ShouldExtractMultipleValues_ByCommaSeparatedPaths() + { + var json = """ + [ + { "name": "Alice", "age": 30 }, + { "name": "Bob", "age": 25 } + ] + """; + using var stream = json.ToStream(); + var results = JData.ReadAll(stream).Flatten().ToList(); + + TestOutput.WriteLine(string.Join(Environment.NewLine, results.Select(r => $"Path={r.Path}, Name={r.PropertyName}, Value={r.Value}"))); + + // Paths inside array elements have a dot prefix, e.g. ".name", ".age" + var pairs = new List<(string name, object age)>(); + results.ExtractObjectValues(".name, .age", dict => + { + var name = dict.TryGetValue("name", out var n) ? n.Value?.ToString() : null; + var age = dict.TryGetValue("age", out var a) ? a.Value : null; + pairs.Add((name, age)); + }); + + Assert.Equal(2, pairs.Count); + Assert.Contains(pairs, p => p.name == "Alice"); + Assert.Contains(pairs, p => p.name == "Bob"); + } + + [Fact] + public void ExtractArrayValues_ShouldThrowArgumentNullException_WhenSourceIsNull() + { + IEnumerable source = null; + Assert.Throws(() => + source.ExtractArrayValues("path", dict => { })); + } + + [Fact] + public void ExtractArrayValues_ShouldThrowArgumentException_WhenPropertyNamesIsNullOrWhiteSpace() + { + var source = new List(); + Assert.Throws(() => + source.ExtractArrayValues(null, dict => { })); + Assert.Throws(() => + source.ExtractArrayValues(" ", dict => { })); + } + + [Fact] + public void ExtractArrayValues_ShouldThrowArgumentNullException_WhenExtractorIsNull() + { + var source = new List(); + Assert.Throws(() => + source.ExtractArrayValues("path", null)); + } + + [Fact] + public void ExtractArrayValues_ShouldExtractArrayChildren_ByPropertyPath() + { + var json = """ + { + "items": [ "a", "b", "c" ], + "other": [ "x", "y" ] + } + """; + using var stream = json.ToStream(); + var results = JData.ReadAll(stream).ToList(); + + TestOutput.WriteLine(string.Join(Environment.NewLine, results.Select(r => $"Path={r.Path}, Name={r.PropertyName}, Children={r.Children.Count}"))); + + var extractedLists = new List>(); + results.ExtractArrayValues("items", dict => + { + if (dict.TryGetValue("items", out var jr)) + { + extractedLists.Add(jr); + } + }); + + Assert.Single(extractedLists); + Assert.Equal(3, extractedLists[0].Count()); + } + + [Fact] + public void ExtractArrayValues_ShouldExtractArrayWithAsterisk() + { + // Using a nested structure where arrays are properties of a parent object + // After flattening, paths like "group.items1" and "group.items2" are produced + // "group.*" pattern will match all properties under "group" + var json = """ + { + "group": { + "items1": [ "a", "b" ], + "items2": [ "c", "d" ] + } + } + """; + using var stream = json.ToStream(); + var allResults = JData.ReadAll(stream).ToList(); + var flatResults = allResults.Flatten().ToList(); + + TestOutput.WriteLine(string.Join(Environment.NewLine, flatResults.Select(r => $"Path={r.Path}, Name={r.PropertyName}, Children={r.Children.Count}"))); + + var extractedGroups = new List>(); + flatResults.ExtractArrayValues("group.*", dict => + { + foreach (var kv in dict) + { + extractedGroups.Add(kv.Value); + } + }); + + Assert.Equal(2, extractedGroups.Count); + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JDataResultTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JDataResultTest.cs new file mode 100644 index 0000000..15776e0 --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JDataResultTest.cs @@ -0,0 +1,111 @@ +using System; +using System.Linq; +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json +{ + public class JDataResultTest : Test + { + public JDataResultTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ToString_ShouldReturnFormattedString_WithPath() + { + var json = """{ "name": "test" }"""; + var results = JData.ReadAll(json).ToList(); + + // The result has children (from the object) + var withChildren = results.FirstOrDefault(r => r.Children.Count > 0); + if (withChildren != null) + { + var str = withChildren.ToString(); + TestOutput.WriteLine(str); + Assert.Contains("Children:", str); + } + else + { + // Fall back: verify any result has a usable ToString + foreach (var r in results) + { + var str = r.ToString(); + TestOutput.WriteLine(str); + Assert.Contains("Children:", str); + } + } + } + + [Fact] + public void ToString_ShouldReturnFormattedString_ShowingPathAndChildCount() + { + var json = """{ "id": 1, "name": "test" }"""; + var results = JData.ReadAll(json).ToList(); + + TestOutput.WriteLine(string.Join(Environment.NewLine, results)); + + Assert.NotEmpty(results); + foreach (var r in results) + { + var str = r.ToString(); + Assert.Contains("Children:", str); + } + } + + [Fact] + public void ReadAll_ShouldParseJsonString() + { + var json = """ + { "id": 1, "name": "test" } + """; + var results = JData.ReadAll(json); + + TestOutput.WriteLine(string.Join(Environment.NewLine, results)); + + Assert.NotNull(results); + } + + [Fact] + public void ReadAll_ShouldThrowArgumentException_ForInvalidJson() + { + var invalidJson = "not json"; + Assert.Throws(() => JData.ReadAll(invalidJson)); + } + + [Fact] + public void ReadAll_ShouldThrowArgumentNullException_ForNullJsonString() + { + Assert.Throws(() => JData.ReadAll((string)null)); + } + + [Fact] + public void JDataResult_ShouldHaveDefaultProperties() + { + var sut = new JDataResult(); + + Assert.Null(sut.Path); + Assert.Null(sut.PropertyName); + Assert.Null(sut.Value); + Assert.Null(sut.Type); + Assert.Null(sut.Parent); + Assert.NotNull(sut.Children); + Assert.Empty(sut.Children); + } + + [Fact] + public void ReadAll_ShouldExposeParentRelationship_InNestedObject() + { + var formatter = new NewtonsoftJsonFormatter(); + var exception = new ArgumentException("Test"); + using var stream = formatter.Serialize(exception); + var allResults = JData.ReadAll(stream).ToList(); + + TestOutput.WriteLine(string.Join(Environment.NewLine, allResults.Select(r => $"Path={r.Path}, Name={r.PropertyName}, Children={r.Children.Count}"))); + + // Root level has children (object properties) + Assert.NotEmpty(allResults); + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonConverterFactoryTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonConverterFactoryTest.cs new file mode 100644 index 0000000..257cb45 --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonConverterFactoryTest.cs @@ -0,0 +1,138 @@ +using System; +using System.IO; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json +{ + public class JsonConverterFactoryTest : Test + { + public JsonConverterFactoryTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void Create_WithTypeAndWriter_ShouldProduceConverter_WithCanWriteTrue() + { + var converter = JsonConverterFactory.Create( + typeof(DateTime), + (writer, value, serializer) => writer.WriteValue(((DateTime)value).ToString("O"))); + + Assert.True(converter.CanWrite); + Assert.False(converter.CanRead); + Assert.True(converter.CanConvert(typeof(DateTime))); + Assert.False(converter.CanConvert(typeof(string))); + } + + [Fact] + public void Create_WithTypeAndReader_ShouldProduceConverter_WithCanReadTrue() + { + var converter = JsonConverterFactory.Create( + typeof(DateTime), + writer: null, + reader: (reader, type, existing, serializer) => DateTime.Parse(reader.Value.ToString())); + + Assert.False(converter.CanWrite); + Assert.True(converter.CanRead); + Assert.True(converter.CanConvert(typeof(DateTime))); + } + + [Fact] + public void Create_WithPredicateAndWriter_ShouldProduceConverter() + { + var converter = JsonConverterFactory.Create( + type => type == typeof(Guid), + (writer, value, serializer) => writer.WriteValue(value.ToString()), + reader: null); + + Assert.True(converter.CanWrite); + Assert.False(converter.CanRead); + Assert.True(converter.CanConvert(typeof(Guid))); + Assert.False(converter.CanConvert(typeof(string))); + } + + [Fact] + public void Create_WithPredicateAndReader_ShouldProduceConverter() + { + var converter = JsonConverterFactory.Create( + type => type == typeof(Guid), + writer: null, + reader: (reader, type, existing, serializer) => Guid.Parse(reader.Value.ToString())); + + Assert.False(converter.CanWrite); + Assert.True(converter.CanRead); + Assert.True(converter.CanConvert(typeof(Guid))); + } + + [Fact] + public void Create_WithNullWriter_ShouldThrowNotImplementedException_OnWriteJson() + { + var converter = JsonConverterFactory.Create(writer: null); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + var serializer = JsonSerializer.Create(); + + Assert.Throws(() => converter.WriteJson(jw, 42, serializer)); + } + + [Fact] + public void Create_WithNullReader_ShouldThrowNotImplementedException_OnReadJson() + { + var converter = JsonConverterFactory.Create(reader: null); + + using var sr = new StringReader("42"); + using var jr = new JsonTextReader(sr); + var serializer = JsonSerializer.Create(); + + Assert.Throws(() => converter.ReadJson(jr, typeof(int), null, serializer)); + } + + [Fact] + public void Create_WithExistingConverter_ShouldWrapConverter() + { + var original = new global::Newtonsoft.Json.Converters.StringEnumConverter(); + var wrapped = JsonConverterFactory.Create(original); + + Assert.True(wrapped.CanWrite); + Assert.True(wrapped.CanRead); + Assert.True(wrapped.CanConvert(typeof(DayOfWeek))); + } + + [Fact] + public void Create_GenericWithWriter_ShouldSerializeType() + { + var converter = JsonConverterFactory.Create((writer, value, serializer) => + { + writer.WriteValue(value.TotalSeconds); + }); + + var settings = new JsonSerializerSettings(); + settings.Converters.Add(converter); + + var ts = TimeSpan.FromSeconds(90); + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + var serializer = JsonSerializer.Create(settings); + serializer.Serialize(jw, ts); + var json = sw.ToString(); + + TestOutput.WriteLine(json); + + Assert.Equal("90.0", json); + } + + [Fact] + public void Create_GenericWithPredicate_ShouldOnlyConvertMatchingType() + { + var converter = JsonConverterFactory.Create( + predicate: type => type == typeof(string), + writer: (writer, value, serializer) => writer.WriteValue(value.ToUpperInvariant())); + + Assert.True(converter.CanConvert(typeof(string))); + Assert.False(converter.CanConvert(typeof(int))); + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonSerializerSettingsExtensionsTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonSerializerSettingsExtensionsTest.cs new file mode 100644 index 0000000..e99d8f6 --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonSerializerSettingsExtensionsTest.cs @@ -0,0 +1,58 @@ +using Codebelt.Extensions.Newtonsoft.Json.Formatters; +using Codebelt.Extensions.Xunit; +using Cuemon.Extensions.IO; +using Newtonsoft.Json; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json +{ + public class JsonSerializerSettingsExtensionsTest : Test + { + public JsonSerializerSettingsExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void ApplyToDefaultSettings_ShouldSetJsonConvertDefaultSettings() + { + var originalSettings = JsonConvert.DefaultSettings; + try + { + var sut = new NewtonsoftJsonFormatterOptions().Settings; + sut.Formatting = Formatting.None; + sut.ApplyToDefaultSettings(); + + Assert.NotNull(JsonConvert.DefaultSettings); + var retrieved = JsonConvert.DefaultSettings(); + Assert.Same(sut, retrieved); + } + finally + { + JsonConvert.DefaultSettings = originalSettings; + } + } + + [Fact] + public void ApplyToDefaultSettings_ShouldAllowNewtonsoft_ToUseAppliedSettings() + { + var originalSettings = JsonConvert.DefaultSettings; + try + { + var settings = new JsonSerializerSettings + { + Formatting = Formatting.Indented + }; + settings.ApplyToDefaultSettings(); + + var result = JsonConvert.SerializeObject(new { value = 42 }); + TestOutput.WriteLine(result); + + Assert.Contains("\n", result); + } + finally + { + JsonConvert.DefaultSettings = originalSettings; + } + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonWriterExtensionsTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonWriterExtensionsTest.cs new file mode 100644 index 0000000..4e8091e --- /dev/null +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/JsonWriterExtensionsTest.cs @@ -0,0 +1,101 @@ +using System.IO; +using Codebelt.Extensions.Xunit; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; +using Xunit; + +namespace Codebelt.Extensions.Newtonsoft.Json +{ + public class JsonWriterExtensionsTest : Test + { + public JsonWriterExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public void WritePropertyName_ShouldWritePropertyName_WithNullSerializer() + { + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + + jw.WriteStartObject(); + jw.WritePropertyName("MyProperty", (JsonSerializer)null); + jw.WriteValue("value"); + jw.WriteEndObject(); + + var json = sw.ToString(); + TestOutput.WriteLine(json); + + Assert.Contains("\"MyProperty\"", json); + Assert.Contains("\"value\"", json); + } + + [Fact] + public void WritePropertyName_ShouldApplyCamelCase_WithCamelCaseContractResolver() + { + var settings = new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }; + var serializer = JsonSerializer.Create(settings); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + + jw.WriteStartObject(); + jw.WritePropertyName("MyProperty", serializer); + jw.WriteValue("value"); + jw.WriteEndObject(); + + var json = sw.ToString(); + TestOutput.WriteLine(json); + + Assert.Contains("\"myProperty\"", json); + } + + [Fact] + public void WritePropertyName_ShouldPreservePascalCase_WithDefaultContractResolver() + { + var settings = new JsonSerializerSettings + { + ContractResolver = new DefaultContractResolver() + }; + var serializer = JsonSerializer.Create(settings); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + + jw.WriteStartObject(); + jw.WritePropertyName("MyProperty", serializer); + jw.WriteValue("value"); + jw.WriteEndObject(); + + var json = sw.ToString(); + TestOutput.WriteLine(json); + + Assert.Contains("\"MyProperty\"", json); + } + + [Fact] + public void WriteObject_ShouldSerializeObject_ToJsonWriter() + { + var settings = new JsonSerializerSettings(); + var serializer = JsonSerializer.Create(settings); + + using var sw = new StringWriter(); + using var jw = new JsonTextWriter(sw); + + jw.WriteStartObject(); + jw.WritePropertyName("nested"); + jw.WriteObject(new { x = 1, y = 2 }, serializer); + jw.WriteEndObject(); + + var json = sw.ToString(); + TestOutput.WriteLine(json); + + Assert.Contains("\"nested\"", json); + Assert.Contains("\"x\"", json); + Assert.Contains("1", json); + } + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Serialization/ContractResolverExtensionsTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Serialization/ContractResolverExtensionsTest.cs index b77748d..4eff102 100644 --- a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Serialization/ContractResolverExtensionsTest.cs +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Serialization/ContractResolverExtensionsTest.cs @@ -208,5 +208,28 @@ public void ResolveNamingStrategyOrDefault_ShouldResolveKebabCaseNamingStrategy( }".ReplaceLineEndings(), json); #endif } + + [Fact] + public void ResolveNamingStrategyOrDefault_ShouldReturnCamelCase_WhenContractResolverIsNull() + { + IContractResolver resolver = null; + var result = resolver.ResolveNamingStrategyOrDefault(); + + Assert.IsType(result); + } + + [Fact] + public void ResolveNamingStrategyOrDefault_ShouldReturnCamelCase_ForCustomResolverWithNoNamingStrategy() + { + var resolver = new CustomContractResolverWithoutNamingStrategy(); + var result = resolver.ResolveNamingStrategyOrDefault(); + + Assert.IsType(result); + } + + private class CustomContractResolverWithoutNamingStrategy : IContractResolver + { + public JsonContract ResolveContract(Type type) => new DefaultContractResolver().ResolveContract(type); + } } } diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/ValidatorExtensionsTest.cs b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/ValidatorExtensionsTest.cs index 14e6c83..f22377a 100644 --- a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/ValidatorExtensionsTest.cs +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/ValidatorExtensionsTest.cs @@ -41,6 +41,15 @@ public void InvalidJsonDocument_ShouldNotThrowArgumentException() Validator.ThrowIf.InvalidJsonDocument(ref reader); } + [Fact] + public void InvalidJsonDocument_ShouldNotThrow_WhenReaderIsNull() + { + // The null reader case is a special no-op case + JsonReader reader = null; + Validator.ThrowIf.InvalidJsonDocument(ref reader); + Assert.Null(reader); + } + private JsonReader GetJsonReader(string json) { var sr = new StringReader(json);