diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..22657b8e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI +run-name: CI ${{ github.event.pull_request.title }} + +on: + pull_request: + +jobs: + ci-build: + name: Build & Test ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + defaults: + run: + working-directory: ./src/Confix.Tool + + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build + + - name: Test + run: dotnet test + + - name: Upload snapshot mismatches + if: failure() + uses: actions/upload-artifact@v4 + with: + name: snapshot-mismatches-${{ matrix.os }} + path: ./**/test/**/__mismatch__/** + if-no-files-found: ignore diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml deleted file mode 100644 index 3fd880ac..00000000 --- a/.github/workflows/dotnet.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Confix.Tool - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -jobs: - ci-build: - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./src/Confix.Tool - steps: - - uses: actions/checkout@v3 - - name: Setup .NET - uses: actions/setup-dotnet@v3 - with: - dotnet-version: | - 8.0.x - 9.0.x - 10.0.x - - name: Restore dependencies - run: dotnet restore - - name: Build - run: dotnet build - - name: Test - run: dotnet test diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 2af24cde..e470051a 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -1,4 +1,4 @@ -name: Deploy documentation with GitHub Pages +name: GitHub Pages on: # Runs on pushes targeting the default branch @@ -26,17 +26,17 @@ jobs: working-directory: ./docs steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v5 - name: Use Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v6 with: node-version: 'latest' - run: npm ci - run: npm run build - name: Upload artifact - uses: actions/upload-pages-artifact@v1 + uses: actions/upload-pages-artifact@v4 with: path: ./docs/out/ @@ -50,4 +50,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2af337c2..4a6d35f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,5 @@ -name: Confix.Tool.Release +name: Release +run-name: Release ${{ github.event.release.tag_name }} on: release: @@ -14,9 +15,9 @@ jobs: VERSION: ${{ github.event.release.tag_name }} NUGET_TOKEN: ${{ secrets.NUGET_API_KEY }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v5 with: dotnet-version: | 8.0.x diff --git a/All.sln b/All.sln deleted file mode 100644 index 0ca26a3c..00000000 --- a/All.sln +++ /dev/null @@ -1,178 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{38C599CE-FE35-487C-A7B8-03ABC8B2DB9A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common.Components", "Common.Components", "{52E98B1F-1F9A-4653-90BE-46E7FD798712}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9AD057B7-3967-4748-AAB4-E7ABE5C0B81B}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Components.DataProtection", "examples\Common.Components\src\Common.Components.DataProtection\Common.Components.DataProtection.csproj", "{F3BDF44B-E289-4721-8052-ED4DB330E013}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Components.Security", "examples\Common.Components\src\Common.Components.Security\Common.Components.Security.csproj", "{1A2ECD40-E062-4494-B910-ED73B9BEFE16}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MonoRepo", "MonoRepo", "{868FAC79-55D1-499A-A03A-49D12C13AC3F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9F10E669-96E4-402E-8BC9-A51866098208}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Service1", "Service1", "{6BD9F64C-816E-4C8D-BB49-44A8E0AE1CE0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{40BF09E1-3BA7-4EEA-B605-0C4B743A0214}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoRepo.Service1.Abstraction", "examples\MonoRepo\src\Service1\src\MonoRepo.Service1.Abstraction\MonoRepo.Service1.Abstraction.csproj", "{7EC2CC7F-2368-4E76-99AE-1B8E9D0CF958}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoRepo.Service1.Host", "examples\MonoRepo\src\Service1\src\MonoRepo.Service1.Host\MonoRepo.Service1.Host.csproj", "{2B3C05FE-9450-4E26-B684-84CB4C3F8477}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Service2", "Service2", "{8EBEEC28-DFAA-4241-91C0-112E6C08C155}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5272442E-F381-4A93-A698-282B12D95282}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoRepo.Service2.Abstraction", "examples\MonoRepo\src\Service2\src\MonoRepo.Service2.Abstraction\MonoRepo.Service2.Abstraction.csproj", "{E90FECBE-5B8D-4C95-B55C-887A8565C09E}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoRepo.Service2.DataAccess", "examples\MonoRepo\src\Service2\src\MonoRepo.Service2.DataAccess\MonoRepo.Service2.DataAccess.csproj", "{2A0458C6-D248-44A0-AC55-4E911949E333}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoRepo.Service2.Hosting", "examples\MonoRepo\src\Service2\src\MonoRepo.Service2.Hosting\MonoRepo.Service2.Hosting.csproj", "{FE172588-DE36-43D4-A7C0-FF20F69CFFA7}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{EF07F58E-E300-49AE-B58A-504EA3E5EB6F}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A1A0E658-C07F-49B4-9E74-C7EFA33DF6C5}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoRepo.Shared.AzureExtensions", "examples\MonoRepo\src\Shared\src\MonoRepo.Shared.AzureExtensions\MonoRepo.Shared.AzureExtensions.csproj", "{EDD1335A-D093-479A-984C-F3C2591FEA8A}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MonoRepo.Shared.Hosting", "examples\MonoRepo\src\Shared\src\MonoRepo.Shared.Hosting\MonoRepo.Shared.Hosting.csproj", "{FA760439-6F64-4D3A-A7ED-C1D4C1972C17}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SomeService.Repo", "SomeService.Repo", "{622E5D6E-A91E-4698-B828-B3B7D3C698BE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3DD1D4C4-6FE9-4BE6-9C2F-887002EF1F60}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SomeService.Abstractions", "examples\SomeService.Repo\src\SomeService.Abstractions\SomeService.Abstractions.csproj", "{37CEC3AC-2EE1-45DB-A343-066029202F5C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SomeService.Host", "examples\SomeService.Repo\src\SomeService.Host\SomeService.Host.csproj", "{45B05DB5-72A1-4217-B467-B70AF9B8204A}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{260F859B-A0DE-40F6-8C7A-A9AB06208BF0}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Confix.Tool", "Confix.Tool", "{5CFBA8BA-ADAD-4D90-8B80-771E995D15FE}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4D45BC4C-E2C4-433C-8FAC-DD791F7BE6D9}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Confix.Library", "src\Confix.Tool\src\Confix.Library\Confix.Library.csproj", "{82571476-D3B2-41AC-8464-03DCBE1C37EB}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Confix.Nuke", "src\Confix.Tool\src\Confix.Nuke\Confix.Nuke.csproj", "{595D0610-73AD-4473-81B0-9C47E272EEA4}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Confix.Tool", "src\Confix.Tool\src\Confix.Tool\Confix.Tool.csproj", "{8D202C18-7E5F-435D-BD31-8226088FBAA1}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{73A8A107-A7CA-4748-8062-32EF855E7BA7}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Confix.Tool.Tests", "src\Confix.Tool\test\Confix.Tool.Tests\Confix.Tool.Tests.csproj", "{BC042D47-49FE-4993-A43E-337A75EB20BC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "_build", ".build\_build.csproj", "{0CF3965A-74BA-47A6-B5A5-950723989A5D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {F3BDF44B-E289-4721-8052-ED4DB330E013}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {F3BDF44B-E289-4721-8052-ED4DB330E013}.Debug|Any CPU.Build.0 = Debug|Any CPU - {F3BDF44B-E289-4721-8052-ED4DB330E013}.Release|Any CPU.ActiveCfg = Release|Any CPU - {F3BDF44B-E289-4721-8052-ED4DB330E013}.Release|Any CPU.Build.0 = Release|Any CPU - {1A2ECD40-E062-4494-B910-ED73B9BEFE16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1A2ECD40-E062-4494-B910-ED73B9BEFE16}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1A2ECD40-E062-4494-B910-ED73B9BEFE16}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1A2ECD40-E062-4494-B910-ED73B9BEFE16}.Release|Any CPU.Build.0 = Release|Any CPU - {7EC2CC7F-2368-4E76-99AE-1B8E9D0CF958}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7EC2CC7F-2368-4E76-99AE-1B8E9D0CF958}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7EC2CC7F-2368-4E76-99AE-1B8E9D0CF958}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7EC2CC7F-2368-4E76-99AE-1B8E9D0CF958}.Release|Any CPU.Build.0 = Release|Any CPU - {2B3C05FE-9450-4E26-B684-84CB4C3F8477}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2B3C05FE-9450-4E26-B684-84CB4C3F8477}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2B3C05FE-9450-4E26-B684-84CB4C3F8477}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2B3C05FE-9450-4E26-B684-84CB4C3F8477}.Release|Any CPU.Build.0 = Release|Any CPU - {E90FECBE-5B8D-4C95-B55C-887A8565C09E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E90FECBE-5B8D-4C95-B55C-887A8565C09E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E90FECBE-5B8D-4C95-B55C-887A8565C09E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E90FECBE-5B8D-4C95-B55C-887A8565C09E}.Release|Any CPU.Build.0 = Release|Any CPU - {2A0458C6-D248-44A0-AC55-4E911949E333}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2A0458C6-D248-44A0-AC55-4E911949E333}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2A0458C6-D248-44A0-AC55-4E911949E333}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2A0458C6-D248-44A0-AC55-4E911949E333}.Release|Any CPU.Build.0 = Release|Any CPU - {FE172588-DE36-43D4-A7C0-FF20F69CFFA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FE172588-DE36-43D4-A7C0-FF20F69CFFA7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FE172588-DE36-43D4-A7C0-FF20F69CFFA7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FE172588-DE36-43D4-A7C0-FF20F69CFFA7}.Release|Any CPU.Build.0 = Release|Any CPU - {EDD1335A-D093-479A-984C-F3C2591FEA8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EDD1335A-D093-479A-984C-F3C2591FEA8A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EDD1335A-D093-479A-984C-F3C2591FEA8A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EDD1335A-D093-479A-984C-F3C2591FEA8A}.Release|Any CPU.Build.0 = Release|Any CPU - {FA760439-6F64-4D3A-A7ED-C1D4C1972C17}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA760439-6F64-4D3A-A7ED-C1D4C1972C17}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA760439-6F64-4D3A-A7ED-C1D4C1972C17}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA760439-6F64-4D3A-A7ED-C1D4C1972C17}.Release|Any CPU.Build.0 = Release|Any CPU - {37CEC3AC-2EE1-45DB-A343-066029202F5C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {37CEC3AC-2EE1-45DB-A343-066029202F5C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {37CEC3AC-2EE1-45DB-A343-066029202F5C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {37CEC3AC-2EE1-45DB-A343-066029202F5C}.Release|Any CPU.Build.0 = Release|Any CPU - {45B05DB5-72A1-4217-B467-B70AF9B8204A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {45B05DB5-72A1-4217-B467-B70AF9B8204A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {45B05DB5-72A1-4217-B467-B70AF9B8204A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {45B05DB5-72A1-4217-B467-B70AF9B8204A}.Release|Any CPU.Build.0 = Release|Any CPU - {82571476-D3B2-41AC-8464-03DCBE1C37EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {82571476-D3B2-41AC-8464-03DCBE1C37EB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {82571476-D3B2-41AC-8464-03DCBE1C37EB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {82571476-D3B2-41AC-8464-03DCBE1C37EB}.Release|Any CPU.Build.0 = Release|Any CPU - {595D0610-73AD-4473-81B0-9C47E272EEA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {595D0610-73AD-4473-81B0-9C47E272EEA4}.Debug|Any CPU.Build.0 = Debug|Any CPU - {595D0610-73AD-4473-81B0-9C47E272EEA4}.Release|Any CPU.ActiveCfg = Release|Any CPU - {595D0610-73AD-4473-81B0-9C47E272EEA4}.Release|Any CPU.Build.0 = Release|Any CPU - {8D202C18-7E5F-435D-BD31-8226088FBAA1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8D202C18-7E5F-435D-BD31-8226088FBAA1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8D202C18-7E5F-435D-BD31-8226088FBAA1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8D202C18-7E5F-435D-BD31-8226088FBAA1}.Release|Any CPU.Build.0 = Release|Any CPU - {BC042D47-49FE-4993-A43E-337A75EB20BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BC042D47-49FE-4993-A43E-337A75EB20BC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BC042D47-49FE-4993-A43E-337A75EB20BC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BC042D47-49FE-4993-A43E-337A75EB20BC}.Release|Any CPU.Build.0 = Release|Any CPU - {0CF3965A-74BA-47A6-B5A5-950723989A5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0CF3965A-74BA-47A6-B5A5-950723989A5D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0CF3965A-74BA-47A6-B5A5-950723989A5D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0CF3965A-74BA-47A6-B5A5-950723989A5D}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {52E98B1F-1F9A-4653-90BE-46E7FD798712} = {38C599CE-FE35-487C-A7B8-03ABC8B2DB9A} - {9AD057B7-3967-4748-AAB4-E7ABE5C0B81B} = {52E98B1F-1F9A-4653-90BE-46E7FD798712} - {F3BDF44B-E289-4721-8052-ED4DB330E013} = {9AD057B7-3967-4748-AAB4-E7ABE5C0B81B} - {1A2ECD40-E062-4494-B910-ED73B9BEFE16} = {9AD057B7-3967-4748-AAB4-E7ABE5C0B81B} - {868FAC79-55D1-499A-A03A-49D12C13AC3F} = {38C599CE-FE35-487C-A7B8-03ABC8B2DB9A} - {9F10E669-96E4-402E-8BC9-A51866098208} = {868FAC79-55D1-499A-A03A-49D12C13AC3F} - {6BD9F64C-816E-4C8D-BB49-44A8E0AE1CE0} = {9F10E669-96E4-402E-8BC9-A51866098208} - {40BF09E1-3BA7-4EEA-B605-0C4B743A0214} = {6BD9F64C-816E-4C8D-BB49-44A8E0AE1CE0} - {7EC2CC7F-2368-4E76-99AE-1B8E9D0CF958} = {40BF09E1-3BA7-4EEA-B605-0C4B743A0214} - {2B3C05FE-9450-4E26-B684-84CB4C3F8477} = {40BF09E1-3BA7-4EEA-B605-0C4B743A0214} - {8EBEEC28-DFAA-4241-91C0-112E6C08C155} = {9F10E669-96E4-402E-8BC9-A51866098208} - {5272442E-F381-4A93-A698-282B12D95282} = {8EBEEC28-DFAA-4241-91C0-112E6C08C155} - {E90FECBE-5B8D-4C95-B55C-887A8565C09E} = {5272442E-F381-4A93-A698-282B12D95282} - {2A0458C6-D248-44A0-AC55-4E911949E333} = {5272442E-F381-4A93-A698-282B12D95282} - {FE172588-DE36-43D4-A7C0-FF20F69CFFA7} = {5272442E-F381-4A93-A698-282B12D95282} - {EF07F58E-E300-49AE-B58A-504EA3E5EB6F} = {9F10E669-96E4-402E-8BC9-A51866098208} - {A1A0E658-C07F-49B4-9E74-C7EFA33DF6C5} = {EF07F58E-E300-49AE-B58A-504EA3E5EB6F} - {EDD1335A-D093-479A-984C-F3C2591FEA8A} = {A1A0E658-C07F-49B4-9E74-C7EFA33DF6C5} - {FA760439-6F64-4D3A-A7ED-C1D4C1972C17} = {A1A0E658-C07F-49B4-9E74-C7EFA33DF6C5} - {622E5D6E-A91E-4698-B828-B3B7D3C698BE} = {38C599CE-FE35-487C-A7B8-03ABC8B2DB9A} - {3DD1D4C4-6FE9-4BE6-9C2F-887002EF1F60} = {622E5D6E-A91E-4698-B828-B3B7D3C698BE} - {37CEC3AC-2EE1-45DB-A343-066029202F5C} = {3DD1D4C4-6FE9-4BE6-9C2F-887002EF1F60} - {45B05DB5-72A1-4217-B467-B70AF9B8204A} = {3DD1D4C4-6FE9-4BE6-9C2F-887002EF1F60} - {5CFBA8BA-ADAD-4D90-8B80-771E995D15FE} = {260F859B-A0DE-40F6-8C7A-A9AB06208BF0} - {4D45BC4C-E2C4-433C-8FAC-DD791F7BE6D9} = {5CFBA8BA-ADAD-4D90-8B80-771E995D15FE} - {82571476-D3B2-41AC-8464-03DCBE1C37EB} = {4D45BC4C-E2C4-433C-8FAC-DD791F7BE6D9} - {595D0610-73AD-4473-81B0-9C47E272EEA4} = {4D45BC4C-E2C4-433C-8FAC-DD791F7BE6D9} - {8D202C18-7E5F-435D-BD31-8226088FBAA1} = {4D45BC4C-E2C4-433C-8FAC-DD791F7BE6D9} - {73A8A107-A7CA-4748-8062-32EF855E7BA7} = {5CFBA8BA-ADAD-4D90-8B80-771E995D15FE} - {BC042D47-49FE-4993-A43E-337A75EB20BC} = {73A8A107-A7CA-4748-8062-32EF855E7BA7} - EndGlobalSection -EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props index 9969cbb2..764f22ed 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -6,10 +6,10 @@ - net8.0;net9.0 + net8.0;net9.0;net10.0 Confix enable - 11 + latest enable diff --git a/Directory.Packages.props b/Directory.Packages.props index 4665ac3b..b8d59429 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -39,6 +39,15 @@ + + + + + + + + + diff --git a/global.json b/global.json index 2bc13e80..1e7fdfa9 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.100", + "version": "10.0.100", "rollForward": "latestMinor" } } diff --git a/src/Confix.Tool/src/Confix.Library/Entities/Component/Configuration/ComponentReferenceConfiguration.cs b/src/Confix.Tool/src/Confix.Library/Entities/Component/Configuration/ComponentReferenceConfiguration.cs index b257a6f1..4c854d99 100644 --- a/src/Confix.Tool/src/Confix.Library/Entities/Component/Configuration/ComponentReferenceConfiguration.cs +++ b/src/Confix.Tool/src/Confix.Library/Entities/Component/Configuration/ComponentReferenceConfiguration.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Confix.Utilities.Json; using Confix.Utilities.Parsing; @@ -48,7 +49,7 @@ public static ComponentReferenceConfiguration Parse(string key, JsonNode node) "The component key must be in the format '@provider/componentName'."); } - if (node.GetSchemaValueType() is SchemaValueType.String) + if (node.GetValueKind() is JsonValueKind.String) { return new ComponentReferenceConfiguration( provider, @@ -58,7 +59,7 @@ public static ComponentReferenceConfiguration Parse(string key, JsonNode node) null); } - if (node.GetSchemaValueType() is SchemaValueType.Boolean) + if (node.GetValueKind() is JsonValueKind.True || node.GetValueKind() is JsonValueKind.False) { return new ComponentReferenceConfiguration( provider, @@ -76,7 +77,7 @@ public static ComponentReferenceConfiguration Parse(string key, JsonNode node) var mountingPoints = obj.TryGetNonNullPropertyValue(FieldNames.MountingPoint, out var mountingPointNode) - ? mountingPointNode.GetSchemaValueType() is SchemaValueType.Array + ? mountingPointNode.GetValueKind() is JsonValueKind.Array ? mountingPointNode .ExpectArray() .WhereNotNull() diff --git a/src/Confix.Tool/src/Confix.Library/Entities/Environment/Configuration/EnvironmentConfiguration.cs b/src/Confix.Tool/src/Confix.Library/Entities/Environment/Configuration/EnvironmentConfiguration.cs index d6c02f5a..93f7b0dc 100644 --- a/src/Confix.Tool/src/Confix.Library/Entities/Environment/Configuration/EnvironmentConfiguration.cs +++ b/src/Confix.Tool/src/Confix.Library/Entities/Environment/Configuration/EnvironmentConfiguration.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Confix.Utilities.Json; using Json.Schema; @@ -25,7 +26,7 @@ public EnvironmentConfiguration( public static EnvironmentConfiguration Parse(JsonNode node) { - if (node.GetSchemaValueType() is SchemaValueType.String) + if (node.GetValueKind() is JsonValueKind.String) { return new EnvironmentConfiguration(node.ExpectValue(), null); } diff --git a/src/Confix.Tool/src/Confix.Library/Entities/Project/Configuration/ConfigurationFileConfiguration.cs b/src/Confix.Tool/src/Confix.Library/Entities/Project/Configuration/ConfigurationFileConfiguration.cs index 54761853..237581cc 100644 --- a/src/Confix.Tool/src/Confix.Library/Entities/Project/Configuration/ConfigurationFileConfiguration.cs +++ b/src/Confix.Tool/src/Confix.Library/Entities/Project/Configuration/ConfigurationFileConfiguration.cs @@ -24,7 +24,7 @@ public ConfigurationFileConfiguration(string? type, JsonNode value) public static ConfigurationFileConfiguration Parse(JsonNode node) { - if (node.GetSchemaValueType() is SchemaValueType.String) + if (node.GetValueKind() is JsonValueKind.String) { // TODO const? return new ConfigurationFileConfiguration("inline", node); diff --git a/src/Confix.Tool/src/Confix.Library/Entities/Project/DefaultValueVisitor.cs b/src/Confix.Tool/src/Confix.Library/Entities/Project/DefaultValueVisitor.cs index 4d5e4b77..81b87f96 100644 --- a/src/Confix.Tool/src/Confix.Library/Entities/Project/DefaultValueVisitor.cs +++ b/src/Confix.Tool/src/Confix.Library/Entities/Project/DefaultValueVisitor.cs @@ -2,7 +2,7 @@ using System.Collections.ObjectModel; using System.Text.Json.Nodes; using Confix.Tool.Schema; -using Json.More; +using Confix.Utilities.Json; using Json.Schema; namespace Confix.Tool.Entities.Components.DotNet; diff --git a/src/Confix.Tool/src/Confix.Library/Entities/Schema/MetadataKeyword.cs b/src/Confix.Tool/src/Confix.Library/Entities/Schema/MetadataKeyword.cs index 8f84bf56..c895d5b9 100644 --- a/src/Confix.Tool/src/Confix.Library/Entities/Schema/MetadataKeyword.cs +++ b/src/Confix.Tool/src/Confix.Library/Entities/Schema/MetadataKeyword.cs @@ -1,7 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; -using Json.More; +using Confix.Utilities.Json; using Json.Schema; namespace Confix.Entities.Schema; @@ -78,7 +78,7 @@ public override MetadataKeyword Read( { var node = JsonSerializer.Deserialize(ref reader, options); - return new MetadataKeyword(node); + return new MetadataKeyword(node ?? []); } public override void Write( diff --git a/src/Confix.Tool/src/Confix.Library/Middlewares/LoadConfiguration/MagicPathRewriter.cs b/src/Confix.Tool/src/Confix.Library/Middlewares/LoadConfiguration/MagicPathRewriter.cs index f9478e33..16d3987c 100644 --- a/src/Confix.Tool/src/Confix.Library/Middlewares/LoadConfiguration/MagicPathRewriter.cs +++ b/src/Confix.Tool/src/Confix.Library/Middlewares/LoadConfiguration/MagicPathRewriter.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Confix.Tool.Commands.Logging; using Confix.Tool.Schema; @@ -18,12 +19,13 @@ public sealed class MagicPathRewriter : JsonDocumentRewriter { protected override JsonNode Rewrite(JsonValue value, MagicPathContext context) { - switch (value.GetSchemaValueType()) + switch (value.GetValueKind()) { - case SchemaValueType.String when MagicPath.From(value) is { } magicPath: + case JsonValueKind.String when MagicPath.From(value) is { } magicPath: + var originalValue = (string?)value ?? string.Empty; var replacedValue = magicPath.Replace(context); - App.Log.ReplacedMagicPath((string?) value, replacedValue); + App.Log.ReplacedMagicPath(originalValue, replacedValue); return JsonValue.Create(replacedValue)!; diff --git a/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/Regex/RegexDependencyProvider.cs b/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/Regex/RegexDependencyProvider.cs index d2ab7b13..25300f56 100644 --- a/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/Regex/RegexDependencyProvider.cs +++ b/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/Regex/RegexDependencyProvider.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Json.More; @@ -30,7 +31,7 @@ public RegexDependencyProvider(RegexDependencyProviderDefinition definition) public void Analyze(DependencyAnalyzerContext context, JsonNode node) { - if (node.GetSchemaValueType() is not SchemaValueType.String) + if (node.GetValueKind() is not JsonValueKind.String) { return; } diff --git a/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/ReportingDependencyConfiguration.cs b/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/ReportingDependencyConfiguration.cs index efefa642..333a3c41 100644 --- a/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/ReportingDependencyConfiguration.cs +++ b/src/Confix.Tool/src/Confix.Library/Pipelines/Reporting/Dependency/ReportingDependencyConfiguration.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Confix.Utilities.Json; using Json.Schema; @@ -60,7 +61,7 @@ file static class Extensions if (!configuration.Configuration.TryGetPropertyValue("Kind", out var kind) || kind is not JsonValue value || - value.GetSchemaValueType() is not SchemaValueType.String) + value.GetValueKind() is not JsonValueKind.String) { return null; } diff --git a/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonNodeExtensions.cs b/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonNodeExtensions.cs index b92200d1..2249d01f 100644 --- a/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonNodeExtensions.cs +++ b/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonNodeExtensions.cs @@ -4,7 +4,6 @@ using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Confix.Tool; -using Json.More; using Json.Schema; using Spectre.Console; @@ -12,6 +11,14 @@ namespace Confix.Utilities.Json; public static partial class JsonNodeExtensions { + /// + /// Checks if the JSON node is a non-null string value. + /// + /// The JSON node to check. + /// True if the node is not null and is a string; otherwise, false. + public static bool IsNonNullString(this JsonNode? node) + => node is not null && node.GetValueKind() == JsonValueKind.String; + public static bool TryGetNonNullPropertyValue( this JsonObject obj, string propertyName, @@ -43,8 +50,8 @@ public static bool TryGetNonNullPropertyValue( (_, JsonValue nodeValue) => nodeValue, _ => throw new InvalidOperationException($""" Cannot merge nodes of different types: - Source: {source.GetSchemaValueType()} - Node: {node.GetSchemaValueType()} + Source: {source.GetValueKind()} + Node: {node.GetValueKind()} """) }; @@ -210,4 +217,178 @@ public static async Task SerializeToStreamAsync( [GeneratedRegex(@"^(?.+?)\[(?\d+)]$")] private static partial Regex ParseSegmentRegex(); + + /// + /// Creates a deep copy of the JSON node. + /// + /// The JSON node to copy. + /// A deep copy of the JSON node. + public static JsonNode? Copy(this JsonNode? node) + { + if (node is null) + { + return null; + } + + return node.GetValueKind() switch + { + JsonValueKind.Object => CopyObject((JsonObject)node), + JsonValueKind.Array => CopyArray((JsonArray)node), + _ => JsonValue.Create(JsonSerializer.Deserialize(node.ToJsonString())) + }; + } + + private static JsonObject CopyObject(JsonObject source) + { + var copy = new JsonObject(); + foreach (var (key, value) in source) + { + copy[key] = Copy(value); + } + return copy; + } + + private static JsonArray CopyArray(JsonArray source) + { + var copy = new JsonArray(); + foreach (var item in source) + { + copy.Add(Copy(item)); + } + return copy; + } + + /// + /// Determines if two JSON nodes are equivalent. + /// + /// The first JSON node. + /// The second JSON node. + /// True if the nodes are equivalent; otherwise, false. + public static bool IsEquivalentTo(this JsonNode? a, JsonNode? b) + { + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a is null || b is null) + { + return false; + } + + var aKind = a.GetValueKind(); + var bKind = b.GetValueKind(); + + if (aKind != bKind) + { + return false; + } + + return aKind switch + { + JsonValueKind.Object => AreObjectsEquivalent((JsonObject)a, (JsonObject)b), + JsonValueKind.Array => AreArraysEquivalent((JsonArray)a, (JsonArray)b), + JsonValueKind.String => AreValuesEquivalent(a, b), + JsonValueKind.Number => AreValuesEquivalent(a, b), + JsonValueKind.True => true, + JsonValueKind.False => true, + JsonValueKind.Null => true, + _ => false + }; + } + + private static bool AreObjectsEquivalent(JsonObject a, JsonObject b) + { + if (a.Count != b.Count) + { + return false; + } + + foreach (var (key, aValue) in a) + { + if (!b.TryGetPropertyValue(key, out var bValue)) + { + return false; + } + + if (!IsEquivalentTo(aValue, bValue)) + { + return false; + } + } + + return true; + } + + private static bool AreArraysEquivalent(JsonArray a, JsonArray b) + { + if (a.Count != b.Count) + { + return false; + } + + for (var i = 0; i < a.Count; i++) + { + if (!IsEquivalentTo(a[i], b[i])) + { + return false; + } + } + + return true; + } + + private static bool AreValuesEquivalent(JsonNode a, JsonNode b) + { + var aString = a.ToJsonString(); + var bString = b.ToJsonString(); + return aString == bString; + } + + /// + /// Gets a hash code for the JSON node that is consistent with equivalence comparison. + /// + /// The JSON node. + /// A hash code value. + public static int GetEquivalenceHashCode(this JsonNode? node) + { + if (node is null) + { + return 0; + } + + var kind = node.GetValueKind(); + + return kind switch + { + JsonValueKind.Object => GetObjectHashCode((JsonObject)node), + JsonValueKind.Array => GetArrayHashCode((JsonArray)node), + JsonValueKind.String or JsonValueKind.Number => node.ToJsonString().GetHashCode(), + JsonValueKind.True => true.GetHashCode(), + JsonValueKind.False => false.GetHashCode(), + JsonValueKind.Null => 0, + _ => 0 + }; + } + + private static int GetObjectHashCode(JsonObject obj) + { + var hash = new HashCode(); + foreach (var (key, value) in obj.OrderBy(x => x.Key)) + { + hash.Add(key); + hash.Add(GetEquivalenceHashCode(value)); + } + return hash.ToHashCode(); + } + + private static int GetArrayHashCode(JsonArray array) + { + var hash = new HashCode(); + foreach (var item in array) + { + hash.Add(GetEquivalenceHashCode(item)); + } + return hash.ToHashCode(); + } } diff --git a/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonSchemaBuilderExtensions.cs b/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonSchemaBuilderExtensions.cs index bbbe56a0..c7d9f4be 100644 --- a/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonSchemaBuilderExtensions.cs +++ b/src/Confix.Tool/src/Confix.Library/Utilities/Json/JsonSchemaBuilderExtensions.cs @@ -1,7 +1,6 @@ using System.Text.Json.Nodes; using Confix.Entities.Schema; using Confix.Utilities.Json; -using Json.More; using Json.Schema; namespace Confix.Tool.Schema; diff --git a/src/Confix.Tool/src/Confix.Library/Utilities/Json/PrefixJsonNamesRewriter.cs b/src/Confix.Tool/src/Confix.Library/Utilities/Json/PrefixJsonNamesRewriter.cs index 799f8331..f9e0f008 100644 --- a/src/Confix.Tool/src/Confix.Library/Utilities/Json/PrefixJsonNamesRewriter.cs +++ b/src/Confix.Tool/src/Confix.Library/Utilities/Json/PrefixJsonNamesRewriter.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Json.More; using Json.Schema; @@ -28,7 +29,7 @@ protected override JsonNode Rewrite(JsonObject obj, PrefixJsonNamesContext conte if (field is RefKeyword.Name && value is JsonValue refValue && - refValue.GetSchemaValueType() is SchemaValueType.String) + refValue.GetValueKind() is JsonValueKind.String) { var parts = refValue.GetValue().ToString()!.Split("/"); parts[^1] = $"{prefix}{parts[^1]}"; diff --git a/src/Confix.Tool/src/Confix.Library/Utilities/Json/VariableIntellisenseRewriter.cs b/src/Confix.Tool/src/Confix.Library/Utilities/Json/VariableIntellisenseRewriter.cs index 6ab83ac0..ccc3e6e3 100644 --- a/src/Confix.Tool/src/Confix.Library/Utilities/Json/VariableIntellisenseRewriter.cs +++ b/src/Confix.Tool/src/Confix.Library/Utilities/Json/VariableIntellisenseRewriter.cs @@ -1,6 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; -using Json.More; +using Confix.Utilities.Json; using Json.Schema; namespace Confix.Tool.Schema; diff --git a/src/Confix.Tool/src/Confix.Library/Variables/JsonVariableRewriter.cs b/src/Confix.Tool/src/Confix.Library/Variables/JsonVariableRewriter.cs index a95e46f4..1586f42a 100644 --- a/src/Confix.Tool/src/Confix.Library/Variables/JsonVariableRewriter.cs +++ b/src/Confix.Tool/src/Confix.Library/Variables/JsonVariableRewriter.cs @@ -2,7 +2,7 @@ using System.Text.Json.Nodes; using Confix.Tool; using Confix.Tool.Schema; -using Json.More; +using Confix.Utilities.Json; using Json.Schema; namespace Confix.Variables; @@ -10,9 +10,9 @@ namespace Confix.Variables; public sealed class JsonVariableRewriter : JsonDocumentRewriter { protected override JsonNode Rewrite(JsonValue value, JsonVariableRewriterContext context) - => value.GetSchemaValueType() switch + => value.GetValueKind() switch { - SchemaValueType.String => RewriteVariable((string) value!, context), + JsonValueKind.String => RewriteVariable((string)value!, context), _ => value.Deserialize()! }; diff --git a/src/Confix.Tool/src/Confix.Library/Variables/Providers/AzureKeyVault/AzureKeyVaultProvider.cs b/src/Confix.Tool/src/Confix.Library/Variables/Providers/AzureKeyVault/AzureKeyVaultProvider.cs index fe310200..6eddaf8d 100644 --- a/src/Confix.Tool/src/Confix.Library/Variables/Providers/AzureKeyVault/AzureKeyVaultProvider.cs +++ b/src/Confix.Tool/src/Confix.Library/Variables/Providers/AzureKeyVault/AzureKeyVaultProvider.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Azure.Identity; using Azure.Security.KeyVault.Secrets; @@ -64,7 +65,7 @@ public Task> ResolveManyAsync( public Task SetAsync(string path, JsonNode value, IVariableProviderContext context) => KeyVaultExtension.HandleKeyVaultException(async () => { - if (value.GetSchemaValueType() != SchemaValueType.String) + if (value.GetValueKind() != JsonValueKind.String) { throw new NotSupportedException("KeyVault only supports String secrets"); } diff --git a/src/Confix.Tool/src/Confix.Library/Variables/Providers/Local/LocalVariableProvider.cs b/src/Confix.Tool/src/Confix.Library/Variables/Providers/Local/LocalVariableProvider.cs index f0200cce..b764fb01 100644 --- a/src/Confix.Tool/src/Confix.Library/Variables/Providers/Local/LocalVariableProvider.cs +++ b/src/Confix.Tool/src/Confix.Library/Variables/Providers/Local/LocalVariableProvider.cs @@ -6,7 +6,6 @@ using Confix.Tool.Schema; using Confix.Utilities.Json; using HotChocolate.Types; -using Json.More; namespace Confix.Variables; diff --git a/src/Confix.Tool/src/Confix.Library/Variables/VariableExtractorService.cs b/src/Confix.Tool/src/Confix.Library/Variables/VariableExtractorService.cs index bede9c11..2af4aca9 100644 --- a/src/Confix.Tool/src/Confix.Library/Variables/VariableExtractorService.cs +++ b/src/Confix.Tool/src/Confix.Library/Variables/VariableExtractorService.cs @@ -1,5 +1,7 @@ +using System.Text.Json; using System.Text.Json.Nodes; using Confix.Tool.Commands.Logging; +using Confix.Utilities.Json; using Json.More; using Json.Schema; @@ -43,10 +45,10 @@ public async Task> ExtractAsync( providerName, providerType, variable.Path.Path, - resolvedValue.GetSchemaValueType() switch + resolvedValue.GetValueKind() switch { - SchemaValueType.String => resolvedValue.GetValue(), - SchemaValueType.Null => "null", + JsonValueKind.String => resolvedValue.GetValue(), + JsonValueKind.Null => "null", _ => resolvedValue.ToJsonString() }, variable.Node.GetPointerFromRoot()); @@ -60,7 +62,7 @@ public async Task> ExtractAsync( private static IEnumerable Extract(JsonNode node) => JsonParser.ParseNode(node) .Values - .Where(v => v.GetSchemaValueType() == SchemaValueType.String) + .Where(v => v.IsNonNullString()) .SelectMany(v => { var extractedVariables = new List(); diff --git a/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs b/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs index 96496744..e463c6d0 100644 --- a/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs +++ b/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs @@ -1,6 +1,8 @@ using System.Collections.Immutable; +using System.Text.Json; using System.Text.Json.Nodes; using Confix.Tool.Commands.Logging; +using Confix.Utilities.Json; using Json.Schema; namespace Confix.Variables; @@ -38,13 +40,13 @@ private static IEnumerable GetVariables(JsonNode node) { if (node is JsonValue value) { - return value.GetSchemaValueType() == SchemaValueType.String + return value.GetValueKind() == JsonValueKind.String ? value.ToString().GetVariables() : Enumerable.Empty(); } return JsonParser.ParseNode(node).Values - .Where(v => v.GetSchemaValueType() == SchemaValueType.String) + .Where(v => v.IsNonNullString()) .SelectMany(v => v!.ToString().GetVariables()); } diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Entities/Schema/MetadataKeywordTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Entities/Schema/MetadataKeywordTests.cs new file mode 100644 index 00000000..6d7e8619 --- /dev/null +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Entities/Schema/MetadataKeywordTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Confix.Entities.Schema; +using Confix.Utilities.Json; + +namespace Confix.Tool.Tests.Entities.Schema; + +public class MetadataKeywordTests +{ + [Fact] + public void Constructor_WithValidArray_SetsValue() + { + // arrange + var array = new JsonArray("item1", "item2"); + + // act + var keyword = new MetadataKeyword(array); + + // assert + Assert.NotNull(keyword.Value); + Assert.Equal(2, keyword.Value.Count); + } + + [Fact] + public void Constructor_WithEmptyArray_SetsEmptyValue() + { + // arrange + var array = new JsonArray(); + + // act + var keyword = new MetadataKeyword(array); + + // assert + Assert.NotNull(keyword.Value); + Assert.Empty(keyword.Value); + } + + [Fact] + public void Equals_SameValues_ReturnsTrue() + { + // arrange + var keyword1 = new MetadataKeyword(new JsonArray("item1", "item2")); + var keyword2 = new MetadataKeyword(new JsonArray("item1", "item2")); + + // act & assert + Assert.True(keyword1.Equals(keyword2)); + } + + [Fact] + public void Equals_DifferentValues_ReturnsFalse() + { + // arrange + var keyword1 = new MetadataKeyword(new JsonArray("item1")); + var keyword2 = new MetadataKeyword(new JsonArray("item2")); + + // act & assert + Assert.False(keyword1.Equals(keyword2)); + } + + [Fact] + public void Equals_Null_ReturnsFalse() + { + // arrange + var keyword = new MetadataKeyword(new JsonArray("item1")); + + // act & assert + Assert.False(keyword.Equals(null)); + } + + [Fact] + public void Equals_SameReference_ReturnsTrue() + { + // arrange + var keyword = new MetadataKeyword(new JsonArray("item1")); + + // act & assert + Assert.True(keyword.Equals(keyword)); + } + + [Fact] + public void GetHashCode_SameValues_ReturnsSameHash() + { + // arrange + var keyword1 = new MetadataKeyword(new JsonArray("item1", "item2")); + var keyword2 = new MetadataKeyword(new JsonArray("item1", "item2")); + + // act & assert + Assert.Equal(keyword1.GetHashCode(), keyword2.GetHashCode()); + } + + [Fact] + public void JsonConverter_DeserializeValidArray_ReturnsKeyword() + { + // arrange + var json = """["item1", "item2", "item3"]"""; + + // act + var keyword = JsonSerializer.Deserialize(json); + + // assert + Assert.NotNull(keyword); + Assert.Equal(3, keyword.Value.Count); + } + + [Fact] + public void JsonConverter_DeserializeEmptyArray_ReturnsKeywordWithEmptyArray() + { + // arrange + var json = """[]"""; + + // act + var keyword = JsonSerializer.Deserialize(json); + + // assert + Assert.NotNull(keyword); + Assert.Empty(keyword.Value); + } + + [Fact] + public void JsonConverter_DeserializeNull_ReturnsNull() + { + // arrange - when JSON is literal "null", the serializer returns null + // without calling the converter (standard System.Text.Json behavior) + var json = """null"""; + + // act + var keyword = JsonSerializer.Deserialize(json); + + // assert + Assert.Null(keyword); + } + + [Fact] + public void JsonConverter_DeserializeComplexArray_ReturnsKeyword() + { + // arrange + var json = """[{"key": "value"}, null, "string", 42]"""; + + // act + var keyword = JsonSerializer.Deserialize(json); + + // assert + Assert.NotNull(keyword); + Assert.Equal(4, keyword.Value.Count); + } + + [Fact] + public void JsonConverter_SerializeKeyword_ReturnsValidJson() + { + // arrange + var keyword = new MetadataKeyword(new JsonArray("item1", "item2")); + var options = new JsonSerializerOptions { WriteIndented = false }; + + // act + var json = JsonSerializer.Serialize(keyword, options); + + // assert + // Note: The custom converter writes property name, so we wrap it + Assert.Contains("metadata", json); + } + + [Fact] + public void JsonConverter_RoundTrip_PreservesValue() + { + // arrange + var originalArray = new JsonArray("item1", 42, true); + var keyword = new MetadataKeyword(originalArray); + + // act - serialize then deserialize the array directly + var json = keyword.Value.ToJsonString(); + var deserializedKeyword = JsonSerializer.Deserialize(json); + + // assert + Assert.NotNull(deserializedKeyword); + Assert.True(keyword.Value.IsEquivalentTo(deserializedKeyword.Value)); + } +} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Entities/Variables/VariableProviderDefinitionTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Entities/Variables/VariableProviderDefinitionTests.cs index 8863d3fe..cf933d12 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Entities/Variables/VariableProviderDefinitionTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Entities/Variables/VariableProviderDefinitionTests.cs @@ -1,8 +1,8 @@ using System.Text.Json.Nodes; using Confix.Tool; using Confix.Tool.Abstractions; +using Confix.Utilities.Json; using FluentAssertions; -using Json.More; namespace Confix.Entities.Component.Configuration; diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Extensions/TestExtensions.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Extensions/TestExtensions.cs index 7a7f6a87..a066da22 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Extensions/TestExtensions.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Extensions/TestExtensions.cs @@ -14,8 +14,54 @@ public static string ToJsonString(this object node) }); public static string ReplacePath(this string str, TestConfixCommandline info, string name) - => str.Replace(info.Directories.Content.Parent!.FullName, $"<<{name}>>"); - + { + var path = info.Directories.Content.Parent!.FullName; + var result = str; + + // Replace original path + result = result.Replace(path, $"<<{name}>>"); + + // On Windows, also replace forward-slash version and JSON-escaped versions + if (Path.DirectorySeparatorChar == '\\') + { + var forwardSlashPath = path.Replace('\\', '/'); + result = result.Replace(forwardSlashPath, $"<<{name}>>"); + + // Handle JSON-escaped paths (backslashes doubled) + var jsonEscapedBackslash = path.Replace("\\", "\\\\"); + result = result.Replace(jsonEscapedBackslash, $"<<{name}>>"); + + // Handle JSON-escaped forward slash paths + var jsonEscapedForward = forwardSlashPath.Replace("/", "\\/"); + result = result.Replace(jsonEscapedForward, $"<<{name}>>"); + } + + return result; + } + public static string ReplacePath(this string str, TestMiddlewareContext info, string name) - => str.Replace(info.Directories.Content.Parent!.FullName, $"<<{name}>>"); + { + var path = info.Directories.Content.Parent!.FullName; + var result = str; + + // Replace original path + result = result.Replace(path, $"<<{name}>>"); + + // On Windows, also replace forward-slash version and JSON-escaped versions + if (Path.DirectorySeparatorChar == '\\') + { + var forwardSlashPath = path.Replace('\\', '/'); + result = result.Replace(forwardSlashPath, $"<<{name}>>"); + + // Handle JSON-escaped paths (backslashes doubled) + var jsonEscapedBackslash = path.Replace("\\", "\\\\"); + result = result.Replace(jsonEscapedBackslash, $"<<{name}>>"); + + // Handle JSON-escaped forward slash paths + var jsonEscapedForward = forwardSlashPath.Replace("/", "\\/"); + result = result.Replace(jsonEscapedForward, $"<<{name}>>"); + } + + return result; + } } diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/MagicPathRewriterTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/MagicPathRewriterTests.cs index 06724eb0..6710512c 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/MagicPathRewriterTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/MagicPathRewriterTests.cs @@ -1,6 +1,7 @@ using System.Text.Json.Nodes; +using Confix.Inputs; using Confix.Tool.Middlewares; -using Json.More; +using Confix.Utilities.Json; using Snapshooter.Xunit; namespace Confix.Entities.Component.Configuration.Middlewares; @@ -50,11 +51,13 @@ public void Rewrite_ObjectWithMagicStrings_Replaced() var result = rewriter.Rewrite(sampleObject, _context); // assert - Snapshot.Match(result.ToJsonString(new() { WriteIndented = true })); + var json = result.ToJsonString(new() { WriteIndented = true }); + Snapshot.Match(SnapshotBuilder.NormalizePaths(json)); } [Fact] - public void Rewrite_ProjectInNonProjectScope_Ignores(){ + public void Rewrite_ProjectInNonProjectScope_Ignores() + { // arrange var sampleObject = new JsonObject() { @@ -70,7 +73,8 @@ public void Rewrite_ProjectInNonProjectScope_Ignores(){ } [Fact] - public void Rewrite_SolutionInNonSolutionScope_Ignores(){ + public void Rewrite_SolutionInNonSolutionScope_Ignores() + { // arrange var sampleObject = new JsonObject() { @@ -84,4 +88,156 @@ public void Rewrite_SolutionInNonSolutionScope_Ignores(){ // assert Assert.True(sampleObject.IsEquivalentTo(result)); } + + [Fact] + public void Rewrite_ObjectWithNullValue_DoesNotThrow() + { + // arrange + var sampleObject = new JsonObject() + { + ["nullValue"] = null, + ["normalValue"] = "test" + }; + var rewriter = new MagicPathRewriter(); + + // act + var result = rewriter.Rewrite(sampleObject, _context); + + // assert + Assert.NotNull(result); + var resultObj = result as JsonObject; + Assert.NotNull(resultObj); + Assert.True(resultObj.ContainsKey("nullValue")); + } + + [Fact] + public void Rewrite_ObjectWithNestedNullValues_DoesNotThrow() + { + // arrange + var sampleObject = new JsonObject() + { + ["nested"] = new JsonObject() + { + ["nullValue"] = null, + ["magicPath"] = "$home:/foo/bar" + } + }; + var rewriter = new MagicPathRewriter(); + + // act + var result = rewriter.Rewrite(sampleObject, _context); + + // assert + Assert.NotNull(result); + } + + [Fact] + public void Rewrite_ArrayWithNullElements_DoesNotThrow() + { + // arrange + var sampleObject = new JsonObject() + { + ["items"] = new JsonArray( + null, + JsonValue.Create("./foo/bar"), + null, + JsonValue.Create("$home:/test") + ) + }; + var rewriter = new MagicPathRewriter(); + + // act + var result = rewriter.Rewrite(sampleObject, _context); + + // assert + Assert.NotNull(result); + } + + [Fact] + public void Rewrite_MixedNullAndMagicPaths_ReplacesCorrectly() + { + // arrange + var sampleObject = new JsonObject() + { + ["nullValue"] = null, + ["homePath"] = "$home:/config", + ["anotherNull"] = null, + ["filePath"] = "./local/file" + }; + var rewriter = new MagicPathRewriter(); + + // act + var result = rewriter.Rewrite(sampleObject, _context); + + // assert + Assert.NotNull(result); + var resultObj = result as JsonObject; + Assert.NotNull(resultObj); + + // Null values should be preserved + Assert.Null(resultObj["nullValue"]); + Assert.Null(resultObj["anotherNull"]); + + // Magic paths should be replaced (use platform-agnostic check) + Assert.Contains("home-location", resultObj["homePath"]?.ToString()); + Assert.Contains("file-location", resultObj["filePath"]?.ToString()); + } + + [Fact] + public void Rewrite_NonStringValues_Untouched() + { + // arrange + var sampleObject = new JsonObject() + { + ["number"] = 42, + ["boolean"] = true, + ["decimal"] = 3.14, + ["nullValue"] = null + }; + var rewriter = new MagicPathRewriter(); + + // act + var result = rewriter.Rewrite(sampleObject, _context); + + // assert + Assert.NotNull(result); + var resultObj = result as JsonObject; + Assert.NotNull(resultObj); + Assert.Equal(42, resultObj["number"]?.GetValue()); + Assert.True(resultObj["boolean"]?.GetValue()); + Assert.Equal(3.14, resultObj["decimal"]?.GetValue()); + } + + [Fact] + public void Rewrite_DeeplyNestedWithNulls_DoesNotThrow() + { + // arrange + var sampleObject = new JsonObject() + { + ["level1"] = new JsonObject() + { + ["level2"] = new JsonObject() + { + ["level3"] = null, + ["items"] = new JsonArray( + null, + new JsonObject() + { + ["nested"] = null, + ["path"] = "$solution:/deep/path" + } + ) + }, + ["nullHere"] = null + }, + ["topLevelNull"] = null + }; + var rewriter = new MagicPathRewriter(); + + // act + var result = rewriter.Rewrite(sampleObject, _context); + + // assert + Assert.NotNull(result); + } } \ No newline at end of file diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/__snapshots__/MagicPathRewriterTests.Rewrite_ObjectWithMagicStrings_Replaced.snap b/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/__snapshots__/MagicPathRewriterTests.Rewrite_ObjectWithMagicStrings_Replaced.snap index 5119c817..91ee7bc8 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/__snapshots__/MagicPathRewriterTests.Rewrite_ObjectWithMagicStrings_Replaced.snap +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Middlewares/__snapshots__/MagicPathRewriterTests.Rewrite_ObjectWithMagicStrings_Replaced.snap @@ -1,6 +1,6 @@ { "fileLinux": "/file-location/foo/bar", - "fileWindows": "/file-location/foo\\bar", + "fileWindows": "/file-location/foo/bar", "home": "/home-location/foo/bar", "tilde": "/home-location/foo/bar", "solution": "/solution-location/foo/bar", diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/TestHelpers/SnapshotBuilder.cs b/src/Confix.Tool/test/Confix.Tool.Tests/TestHelpers/SnapshotBuilder.cs index ecfb1566..935ef9b5 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/TestHelpers/SnapshotBuilder.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/TestHelpers/SnapshotBuilder.cs @@ -22,7 +22,15 @@ public SnapshotBuilder Append(string name, string content) public SnapshotBuilder AddReplacement(string original, string replacement) { - _processors.Add(x => x.Replace(original, replacement)); + // On Windows, convert the path to forward slashes since normalization happens first + var normalizedOriginal = original.Replace('\\', '/'); + + _processors.Add(x => x.Replace(normalizedOriginal, replacement)); + + // Also handle JSON-escaped paths (forward slashes escaped as \/) + var jsonEscaped = normalizedOriginal.Replace("/", "\\/"); + _processors.Add(x => x.Replace(jsonEscaped, replacement)); + return this; } @@ -34,18 +42,53 @@ private void AddSeparator() public void MatchSnapshot() { var content = _builder.ToString(); + + // FIRST: Normalize Windows path separators to forward slashes for consistent cross-platform snapshots. + // This must happen BEFORE replacement processors run so they can match forward-slash paths. + + // Protect JSON escapes that are NOT followed by word characters (to distinguish \t from \test) + content = JsonEscapeNotInPathRegex().Replace(content, "\x00$1"); + + // Replace all remaining backslashes with forward slashes + content = content.Replace("\\", "/"); + + // Restore protected JSON escapes + content = content.Replace("\x00", "\\"); + + // Collapse multiple forward slashes to single (but preserve :// for URLs) + content = MultipleSlashesRegex().Replace(content, "/"); + + // THEN: Apply replacement processors (which now work on normalized forward-slash paths) content = _processors .Aggregate(content, (current, processor) => processor(current)); content.MatchSnapshot(); } + /// + /// Matches JSON escape sequences that are NOT followed by word characters. + /// This distinguishes real JSON escapes (\t, \n, etc.) from paths (\test, \new, etc.) + /// + [GeneratedRegex(@"\\([""\/bfnrt](?![a-zA-Z0-9_])|u[0-9a-fA-F]{4})")] + private static partial Regex JsonEscapeNotInPathRegex(); + + /// + /// Matches two or more consecutive forward slashes that should be collapsed to one. + /// Preserves :// in URLs (https://, http://, file://) but collapses X:// for Windows drive letters. + /// + [GeneratedRegex(@"(? x.Split(Environment.NewLine) - .Where(y => !y.StartsWith(value)) - .Aggregate((a, b) => a + Environment.NewLine + b)); + x => + { + // Split on both \r\n and \n to handle different line endings + var lines = x.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + var filtered = lines.Where(y => !y.StartsWith(value)); + return string.Join("\n", filtered); + }); return this; } @@ -60,6 +103,36 @@ public SnapshotBuilder RemoveDateTimes() public static SnapshotBuilder New() => new(); + /// + /// Normalizes Windows paths in a string to Unix-style forward slashes. + /// Use this when calling Snapshot.Match directly with content that may contain paths. + /// + public static string NormalizePaths(string content) + { + // Protect JSON escapes that are NOT followed by word characters (to distinguish \t from \test) + content = JsonEscapeNotInPathRegex().Replace(content, "\x00$1"); + + // Replace all remaining backslashes with forward slashes + content = content.Replace("\\", "/"); + + // Restore protected JSON escapes + content = content.Replace("\x00", "\\"); + + // Collapse multiple forward slashes to single (but preserve :// for URLs) + content = MultipleSlashesRegex().Replace(content, "/"); + + // Remove Windows drive letters (e.g., "D:/" -> "/") + content = WindowsDriveLetterRegex().Replace(content, "/"); + + return content; + } + + /// + /// Matches Windows drive letters like C:/ or D:/ + /// + [GeneratedRegex(@"[A-Za-z]:/")] + private static partial Regex WindowsDriveLetterRegex(); + /// /// we cannot match the date times fully. We can only match the date part, /// but still want to replace the whole string. diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/JsonParserTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/JsonParserTests.cs index daf82ccd..ea3414d5 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/JsonParserTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/JsonParserTests.cs @@ -1,7 +1,7 @@ using System.Text.Json.Nodes; +using Confix.Utilities.Json; using Confix.Variables; using FluentAssertions; -using Json.More; namespace Confix.Tool.Tests; diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/LocalVariableProviderTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/LocalVariableProviderTests.cs index 548d4bf1..be16628e 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/LocalVariableProviderTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/LocalVariableProviderTests.cs @@ -1,215 +1,215 @@ using System.Text.Json.Nodes; using Confix.Inputs; +using Confix.Utilities.Json; using Confix.Variables; using FluentAssertions; -using Json.More; namespace Confix.Tool.Tests; public class LocalVariableProviderTests : IDisposable { - private readonly string tmpFilePath = $"./LocalVariableProviderTests_{Guid.NewGuid():N}.json"; - - [Fact] - public async Task ListAsync_ValidFile_CorrectResult() - { - // arrange - await PrepareFile( - """ + private readonly string tmpFilePath = $"./LocalVariableProviderTests_{Guid.NewGuid():N}.json"; + + [Fact] + public async Task ListAsync_ValidFile_CorrectResult() + { + // arrange + await PrepareFile( + """ { "foo": 42, "bar": "baz" } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - - // act - var result = await provider.ListAsync(default); - - // assert - result.Should().HaveCount(2); - result.Should().Contain("foo"); - result.Should().Contain("bar"); - } - - [Fact] - public async Task ResolveAsync_ExistingPath_CorrectResult() - { - // arrange - await PrepareFile( - """ + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + + // act + var result = await provider.ListAsync(default); + + // assert + result.Should().HaveCount(2); + result.Should().Contain("foo"); + result.Should().Contain("bar"); + } + + [Fact] + public async Task ResolveAsync_ExistingPath_CorrectResult() + { + // arrange + await PrepareFile( + """ { "foo": 42, "bar": "baz" } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - // act - var result = await provider.ResolveAsync("foo", default); + // act + var result = await provider.ResolveAsync("foo", default); - // assert - Assert.True(result.IsEquivalentTo(JsonValue.Create(42))); - } + // assert + Assert.True(result.IsEquivalentTo(JsonValue.Create(42))); + } - [Fact] - public async Task ResolveAsync_WithArray_CorrectResult() - { - // arrange - await PrepareFile( - """ + [Fact] + public async Task ResolveAsync_WithArray_CorrectResult() + { + // arrange + await PrepareFile( + """ { "someArray": ["a", "b", "c"] } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - // act - var result = await provider.ResolveAsync("someArray", default); + // act + var result = await provider.ResolveAsync("someArray", default); - // assert - Assert.True(result.IsEquivalentTo(JsonNode.Parse("""["a","b","c"]"""))); - } + // assert + Assert.True(result.IsEquivalentTo(JsonNode.Parse("""["a","b","c"]"""))); + } - [Fact] - public async Task ResolveAsync_NonExistingPath_VariableNotFoundException() - { - // arrange - await PrepareFile( - """ + [Fact] + public async Task ResolveAsync_NonExistingPath_VariableNotFoundException() + { + // arrange + await PrepareFile( + """ { "foo": 42, "bar": "baz" } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - - // act & assert - await Assert.ThrowsAsync(() - => provider.ResolveAsync("nonexistent", default)); - } - - [Fact] - public async Task ResolveManyAsync_ExistingPaths_CorrectResult() - { - // arrange - await PrepareFile( - """ + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + + // act & assert + await Assert.ThrowsAsync(() + => provider.ResolveAsync("nonexistent", default)); + } + + [Fact] + public async Task ResolveManyAsync_ExistingPaths_CorrectResult() + { + // arrange + await PrepareFile( + """ { "foo": 42, "bar": "baz" } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - var paths = new List { "foo", "bar" }; - var context = new VariableProviderContext(null, CancellationToken.None); - - // act - var result = await provider.ResolveManyAsync(paths, context); - - // assert - result.Should().HaveCount(2); - Assert.True(result["foo"].IsEquivalentTo(JsonValue.Create(42))); - Assert.True(result["bar"].IsEquivalentTo(JsonValue.Create("baz"))); - } - - [Fact] - public async Task ResolveManyAsync_MixedPaths_AggregateException() - { - // arrange - await PrepareFile( - """ + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + var paths = new List { "foo", "bar" }; + var context = new VariableProviderContext(null, CancellationToken.None); + + // act + var result = await provider.ResolveManyAsync(paths, context); + + // assert + result.Should().HaveCount(2); + Assert.True(result["foo"].IsEquivalentTo(JsonValue.Create(42))); + Assert.True(result["bar"].IsEquivalentTo(JsonValue.Create("baz"))); + } + + [Fact] + public async Task ResolveManyAsync_MixedPaths_AggregateException() + { + // arrange + await PrepareFile( + """ { "foo": 42, "bar": "baz" } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - var paths = new List { "foo", "nonexistent" }; - var context = new VariableProviderContext(null, CancellationToken.None); - - // act & assert - var exception = - await Assert.ThrowsAsync(() - => provider.ResolveManyAsync(paths, context)); - exception.InnerExceptions.Should().HaveCount(1); - exception.InnerExceptions[0].Should().BeOfType(); - } - - [Fact] - public async Task ListAsync_Should_NotFail_When_NoFile() - { - // arrange - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - - // act - var result = await provider.ListAsync(default); - - // assert - result.Should().HaveCount(0); - } - - [Fact] - public async Task ListAsync_Should_NotCreateFile_When_FileDoesNotExists() - { - // arrange - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - - // act - await provider.ListAsync(default); - - // assert - Assert.False(File.Exists(tmpFilePath)); - } - - [Fact] - public async Task GetAsync_Should_CreateFile_When_FileDoesNotExist() - { - // arrange - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - - // act - var exception = - await Record.ExceptionAsync(async () => await provider.ResolveAsync("foo", default)); - - // assert - Assert.IsType(exception); - Assert.True(File.Exists(tmpFilePath)); - } - - [Fact] - public async Task SetAsync_Should_SetAndReturn() - { - // arrange - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - - // act - var result = await provider.SetAsync("foo", JsonValue.Create("bar")!, default); - - // assert - result.Should().Be("foo"); - Assert.Equal( - """ + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + var paths = new List { "foo", "nonexistent" }; + var context = new VariableProviderContext(null, CancellationToken.None); + + // act & assert + var exception = + await Assert.ThrowsAsync(() + => provider.ResolveManyAsync(paths, context)); + exception.InnerExceptions.Should().HaveCount(1); + exception.InnerExceptions[0].Should().BeOfType(); + } + + [Fact] + public async Task ListAsync_Should_NotFail_When_NoFile() + { + // arrange + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + + // act + var result = await provider.ListAsync(default); + + // assert + result.Should().HaveCount(0); + } + + [Fact] + public async Task ListAsync_Should_NotCreateFile_When_FileDoesNotExists() + { + // arrange + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + + // act + await provider.ListAsync(default); + + // assert + Assert.False(File.Exists(tmpFilePath)); + } + + [Fact] + public async Task GetAsync_Should_CreateFile_When_FileDoesNotExist() + { + // arrange + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + + // act + var exception = + await Record.ExceptionAsync(async () => await provider.ResolveAsync("foo", default)); + + // assert + Assert.IsType(exception); + Assert.True(File.Exists(tmpFilePath)); + } + + [Fact] + public async Task SetAsync_Should_SetAndReturn() + { + // arrange + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + + // act + var result = await provider.SetAsync("foo", JsonValue.Create("bar")!, default); + + // assert + result.Should().Be("foo"); + Assert.Equal( + """ { "foo": "bar" } """, - await File.ReadAllTextAsync(tmpFilePath)); - } - - [Fact] - public async Task SetAsync_Should_SetAndReturn_Deep() - { - // arrange - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - - // act - var result = await provider.SetAsync("foo.bar.baz", JsonValue.Create("bar")!, default); - - // assert - result.Should().Be("foo.bar.baz"); - Assert.Equal( - """ + await File.ReadAllTextAsync(tmpFilePath)); + } + + [Fact] + public async Task SetAsync_Should_SetAndReturn_Deep() + { + // arrange + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + + // act + var result = await provider.SetAsync("foo.bar.baz", JsonValue.Create("bar")!, default); + + // assert + result.Should().Be("foo.bar.baz"); + Assert.Equal( + """ { "foo": { "bar": { @@ -218,41 +218,41 @@ public async Task SetAsync_Should_SetAndReturn_Deep() } } """, - await File.ReadAllTextAsync(tmpFilePath)); - } - - [Fact] - public async Task SetAsync_Should_SetAndReturn_When_FileHasContent() - { - // arrange - await PrepareFile( - """ + await File.ReadAllTextAsync(tmpFilePath)); + } + + [Fact] + public async Task SetAsync_Should_SetAndReturn_When_FileHasContent() + { + // arrange + await PrepareFile( + """ { "foo": "bar" } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - // act - var result = await provider.SetAsync("foo", JsonValue.Create("baz")!, default); + // act + var result = await provider.SetAsync("foo", JsonValue.Create("baz")!, default); - // assert - result.Should().Be("foo"); - Assert.Equal( - """ + // assert + result.Should().Be("foo"); + Assert.Equal( + """ { "foo": "baz" } """, - await File.ReadAllTextAsync(tmpFilePath)); - } - - [Fact] - public async Task SetAsync_Should_SetAndReturn_When_FileHasContentDeep() - { - // arrange - await PrepareFile( - """ + await File.ReadAllTextAsync(tmpFilePath)); + } + + [Fact] + public async Task SetAsync_Should_SetAndReturn_When_FileHasContentDeep() + { + // arrange + await PrepareFile( + """ { "foo": { "bar": { @@ -262,15 +262,15 @@ await PrepareFile( } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - // act - var result = await provider.SetAsync("foo.bar.baz", JsonValue.Create("baz")!, default); + // act + var result = await provider.SetAsync("foo.bar.baz", JsonValue.Create("baz")!, default); - // assert - result.Should().Be("foo.bar.baz"); - Assert.Equal( - """ + // assert + result.Should().Be("foo.bar.baz"); + Assert.Equal( + """ { "foo": { "bar": { @@ -279,15 +279,15 @@ await PrepareFile( } } """, - await File.ReadAllTextAsync(tmpFilePath)); - } - - [Fact] - public async Task SetAsync_Should_ThrowExitException_When_FileHasArray() - { - // arrange - await PrepareFile( - """ + await File.ReadAllTextAsync(tmpFilePath)); + } + + [Fact] + public async Task SetAsync_Should_ThrowExitException_When_FileHasArray() + { + // arrange + await PrepareFile( + """ { "foo": [ { "bar": "baz"} @@ -295,13 +295,13 @@ await PrepareFile( } """); - LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); + LocalVariableProvider provider = new(new LocalVariableProviderDefinition(tmpFilePath)); - // act & assert - await provider.SetAsync("foo[0].bar", JsonValue.Create("qux"), default); + // act & assert + await provider.SetAsync("foo[0].bar", JsonValue.Create("qux"), default); - Assert.Equal( - """ + Assert.Equal( + """ { "foo": [ { @@ -310,13 +310,13 @@ await PrepareFile( ] } """, - await File.ReadAllTextAsync(tmpFilePath)); - } + await File.ReadAllTextAsync(tmpFilePath)); + } - private Task PrepareFile(string content) => File.WriteAllTextAsync(tmpFilePath, content); + private Task PrepareFile(string content) => File.WriteAllTextAsync(tmpFilePath, content); - public void Dispose() - { - File.Delete(tmpFilePath); - } + public void Dispose() + { + File.Delete(tmpFilePath); + } } diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/SecretVariableProviderTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/SecretVariableProviderTests.cs index 733fa49a..0d471b33 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/SecretVariableProviderTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/SecretVariableProviderTests.cs @@ -1,7 +1,7 @@ using System.Text.Json.Nodes; +using Confix.Utilities.Json; using Confix.Variables; using FluentAssertions; -using Json.More; namespace Confix.Tool.Tests; diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableExtractorServiceNullHandlingTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableExtractorServiceNullHandlingTests.cs new file mode 100644 index 00000000..e5fbcbb8 --- /dev/null +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableExtractorServiceNullHandlingTests.cs @@ -0,0 +1,334 @@ +using System.Text.Json.Nodes; +using Confix.Variables; +using Moq; + +namespace Confix.Tool.Tests; + +/// +/// Tests for edge cases where JSON contains null values that could cause +/// null reference exceptions in . +/// These tests verify the fix for the warnings: +/// "Dereference of a possibly null reference" at line 64 in VariableExtractorService.cs +/// +public class VariableExtractorServiceNullHandlingTests +{ + [Fact] + public async Task ExtractAsync_JsonObjectWithNullValue_DoesNotThrow() + { + // arrange - JSON with explicit null value + JsonNode node = JsonNode.Parse(""" + { + "foo": null, + "bar": "baz" + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.ExtractAsync(node, default); + + Assert.NotNull(result); + Assert.Empty(result); // No variables in this JSON + } + + [Fact] + public async Task ExtractAsync_JsonObjectWithNestedNullValue_DoesNotThrow() + { + // arrange - JSON with null value in nested object + JsonNode node = JsonNode.Parse(""" + { + "foo": { + "bar": null, + "baz": "value" + } + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.ExtractAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task ExtractAsync_JsonArrayWithNullElement_DoesNotThrow() + { + // arrange - JSON array with null element + JsonNode node = JsonNode.Parse(""" + { + "items": [ + "value1", + null, + "value2" + ] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.ExtractAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task ExtractAsync_JsonArrayWithOnlyNullElements_DoesNotThrow() + { + // arrange - JSON array with only null elements + JsonNode node = JsonNode.Parse(""" + { + "items": [null, null, null] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.ExtractAsync(node, default); + + Assert.NotNull(result); + Assert.Empty(result); // No variables in null elements + } + + [Fact] + public async Task ExtractAsync_DeeplyNestedNullValues_DoesNotThrow() + { + // arrange - deeply nested structure with null values at various levels + JsonNode node = JsonNode.Parse(""" + { + "level1": { + "level2": { + "level3": null, + "items": [ + null, + { + "nested": null + } + ] + }, + "nullHere": null + }, + "topLevelNull": null + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.ExtractAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task ExtractAsync_MixedNullAndVariables_ExtractsCorrectly() + { + // arrange - JSON with both null values and variables + JsonNode node = JsonNode.Parse(""" + { + "nullValue": null, + "variable": "$test:some.variable", + "nested": { + "anotherNull": null, + "anotherVariable": "$test:another.variable" + } + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, IVariableProviderContext _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + result[key] = JsonValue.Create("Resolved: " + key.Path)!; + } + return result; + }); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act + var result = await service.ExtractAsync(node, default); + + // assert - should extract only the variables, not null values + Assert.NotNull(result); + var variables = result.ToList(); + Assert.Equal(2, variables.Count); + Assert.Contains(variables, v => v.VariableName == "some.variable"); + Assert.Contains(variables, v => v.VariableName == "another.variable"); + } + + [Fact] + public async Task ExtractAsync_ArrayWithMixedNullAndVariables_ExtractsCorrectly() + { + // arrange - JSON array with nulls and variables + JsonNode node = JsonNode.Parse(""" + { + "items": [ + null, + "$test:variable.one", + null, + "$test:variable.two", + null + ] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, IVariableProviderContext _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + result[key] = JsonValue.Create("Resolved: " + key.Path)!; + } + return result; + }); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act + var result = await service.ExtractAsync(node, default); + + // assert + Assert.NotNull(result); + var variables = result.ToList(); + Assert.Equal(2, variables.Count); + Assert.Contains(variables, v => v.VariableName == "variable.one"); + Assert.Contains(variables, v => v.VariableName == "variable.two"); + } + + [Fact] + public async Task ExtractAsync_RootArrayWithNulls_DoesNotThrow() + { + // arrange - Root level array with null elements + JsonNode node = JsonNode.Parse(""" + [ + null, + "string", + null, + {"key": "value"}, + null + ] + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.ExtractAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task ExtractAsync_AllNullStructure_DoesNotThrow() + { + // arrange - Structure where all leaf values are null + JsonNode node = JsonNode.Parse(""" + { + "a": null, + "b": { + "c": null + }, + "d": [null] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + variableResolverMock.Setup(x => x.GetProviderType(It.IsAny())) + .Returns("test-provider"); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.ExtractAsync(node, default); + + Assert.NotNull(result); + Assert.Empty(result); // No variables in null structure + } + + [Fact] + public async Task ExtractAsync_NullNode_ReturnsEmpty() + { + // arrange - null input + JsonNode? node = null; + + Mock variableResolverMock = new(MockBehavior.Strict); + + VariableExtractorService service = new(variableResolverMock.Object); + + // act + var result = await service.ExtractAsync(node, default); + + // assert + Assert.NotNull(result); + Assert.Empty(result); + } +} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableReplacerServiceNullHandlingTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableReplacerServiceNullHandlingTests.cs new file mode 100644 index 00000000..a32bdc05 --- /dev/null +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableReplacerServiceNullHandlingTests.cs @@ -0,0 +1,307 @@ +using System.Text.Json.Nodes; +using Confix.Variables; +using Moq; + +namespace Confix.Tool.Tests; + +/// +/// Tests for edge cases where JSON contains null values that could cause +/// null reference exceptions in . +/// These tests verify the fix for the warnings: +/// "Dereference of a possibly null reference" at line R48 in VariableReplacerService.cs +/// +public class VariableReplacerServiceNullHandlingTests +{ + [Fact] + public async Task RewriteAsync_JsonObjectWithNullValue_DoesNotThrow() + { + // arrange - JSON with explicit null value + JsonNode node = JsonNode.Parse(""" + { + "foo": null, + "bar": "baz" + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.RewriteAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task RewriteAsync_JsonObjectWithNestedNullValue_DoesNotThrow() + { + // arrange - JSON with null value in nested object + JsonNode node = JsonNode.Parse(""" + { + "foo": { + "bar": null, + "baz": "value" + } + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.RewriteAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task RewriteAsync_JsonArrayWithNullElement_DoesNotThrow() + { + // arrange - JSON array with null element + JsonNode node = JsonNode.Parse(""" + { + "items": [ + "value1", + null, + "value2" + ] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.RewriteAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task RewriteAsync_JsonArrayWithOnlyNullElements_DoesNotThrow() + { + // arrange - JSON array with only null elements + JsonNode node = JsonNode.Parse(""" + { + "items": [null, null, null] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.RewriteAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task RewriteAsync_DeeplyNestedNullValues_DoesNotThrow() + { + // arrange - deeply nested structure with null values at various levels + JsonNode node = JsonNode.Parse(""" + { + "level1": { + "level2": { + "level3": null, + "items": [ + null, + { + "nested": null + } + ] + }, + "nullHere": null + }, + "topLevelNull": null + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.RewriteAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task RewriteAsync_MixedNullAndVariables_ResolvesCorrectly() + { + // arrange - JSON with both null values and variables + JsonNode node = JsonNode.Parse(""" + { + "nullValue": null, + "variable": "$test:some.variable", + "nested": { + "anotherNull": null, + "anotherVariable": "$test:another.variable" + } + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, IVariableProviderContext _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + result[key] = JsonValue.Create("Resolved: " + key.Path)!; + } + return result; + }); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act + var result = await service.RewriteAsync(node, default); + + // assert - should resolve variables while preserving null values + Assert.NotNull(result); + var resultObj = result as JsonObject; + Assert.NotNull(resultObj); + + // Null values should be preserved + Assert.True(resultObj.ContainsKey("nullValue")); + Assert.Null(resultObj["nullValue"]); + + // Variables should be resolved + Assert.Equal("Resolved: some.variable", resultObj["variable"]?.ToString()); + } + + [Fact] + public async Task RewriteAsync_ArrayWithMixedNullAndVariables_ResolvesCorrectly() + { + // arrange - JSON array with nulls and variables + JsonNode node = JsonNode.Parse(""" + { + "items": [ + null, + "$test:variable.one", + null, + "$test:variable.two", + null + ] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, IVariableProviderContext _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + result[key] = JsonValue.Create("Resolved: " + key.Path)!; + } + return result; + }); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act + var result = await service.RewriteAsync(node, default); + + // assert + Assert.NotNull(result); + var resultObj = result as JsonObject; + Assert.NotNull(resultObj); + var items = resultObj["items"] as JsonArray; + Assert.NotNull(items); + + // Check that nulls are preserved at correct positions + Assert.Null(items[0]); + Assert.Equal("Resolved: variable.one", items[1]?.ToString()); + Assert.Null(items[2]); + Assert.Equal("Resolved: variable.two", items[3]?.ToString()); + Assert.Null(items[4]); + } + + [Fact] + public async Task RewriteAsync_RootArrayWithNulls_DoesNotThrow() + { + // arrange - Root level array with null elements + JsonNode node = JsonNode.Parse(""" + [ + null, + "string", + null, + {"key": "value"}, + null + ] + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.RewriteAsync(node, default); + + Assert.NotNull(result); + } + + [Fact] + public async Task RewriteAsync_AllNullStructure_DoesNotThrow() + { + // arrange - Structure where all leaf values are null + JsonNode node = JsonNode.Parse(""" + { + "a": null, + "b": { + "c": null + }, + "d": [null] + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new Dictionary()); + + VariableReplacerService service = new(variableResolverMock.Object); + + // act & assert - should not throw NullReferenceException + var result = await service.RewriteAsync(node, default); + + Assert.NotNull(result); + } +} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs index 03d68ad5..1d9a211f 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs @@ -1,8 +1,8 @@ using System.Text.Json.Nodes; using Confix.Tool.Middlewares; +using Confix.Utilities.Json; using Confix.Variables; using FluentAssertions; -using Json.More; using Moq; namespace Confix.Tool.Tests; @@ -144,7 +144,7 @@ public async Task ResolveVariable_ProviderNotFound_ThrowsExitException() // Arrange var factoryMock = new Mock(); var configurations = new List(); - var resolver = new VariableResolver(factoryMock.Object,new VariableListCache(), configurations); + var resolver = new VariableResolver(factoryMock.Object, new VariableListCache(), configurations); var context = new VariableProviderContext(null!, CancellationToken.None); // Act & Assert