diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 723b0f95..8d5cef92 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,18 @@ "isRoot": true, "tools": { "docfx": { - "version": "2.76.0", + "version": "2.77.0", "commands": [ "docfx" - ] + ], + "rollForward": false + }, + "dotnet-reportgenerator-globaltool": { + "version": "5.4.3", + "commands": [ + "reportgenerator" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index c05326b9..9e605ef9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,31 +24,31 @@ dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case dotnet_naming_symbols.interface.applicable_kinds = interface dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.interface.required_modifiers = +dotnet_naming_symbols.interface.required_modifiers = dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.types.required_modifiers = +dotnet_naming_symbols.types.required_modifiers = dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected -dotnet_naming_symbols.non_field_members.required_modifiers = +dotnet_naming_symbols.non_field_members.required_modifiers = # Naming styles dotnet_naming_style.begins_with_i.required_prefix = I -dotnet_naming_style.begins_with_i.required_suffix = -dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_style_operator_placement_when_wrapping = beginning_of_line tab_width = 4 @@ -68,6 +68,9 @@ dotnet_diagnostic.CA2007.severity = warning dotnet_code_quality_unused_parameters = all:suggestion dotnet_diagnostic.CA1806.severity = error dotnet_diagnostic.CA2208.severity = error +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion [*.cs] csharp_indent_labels = one_less_than_current @@ -105,4 +108,8 @@ csharp_style_unused_value_assignment_preference = discard_variable:suggestion dotnet_diagnostic.IDE0051.severity = error dotnet_diagnostic.IDE0060.severity = error dotnet_diagnostic.IDE0073.severity = error -csharp_style_prefer_primary_constructors = true:suggestion \ No newline at end of file +csharp_style_prefer_primary_constructors = false:suggestion +csharp_prefer_system_threading_lock = true:suggestion + +# Organize usings +dotnet_sort_system_directives_first = true \ No newline at end of file diff --git a/.github/actions/setup-dotnet/action.yml b/.github/actions/setup-dotnet/action.yml index f859d309..8a9e2e22 100644 --- a/.github/actions/setup-dotnet/action.yml +++ b/.github/actions/setup-dotnet/action.yml @@ -11,15 +11,11 @@ runs: key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} restore-keys: | ${{ runner.os }}-nuget- - - name: Setup .NET 6.0 - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 6.0.x - - name: Setup .NET 7.0 + - name: Setup .NET 8.0 uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x - - name: Setup .NET 8.0 + dotnet-version: 8.0.x + - name: Setup .NET 9.0 uses: actions/setup-dotnet@v4 with: - dotnet-version: 8.0.x \ No newline at end of file + dotnet-version: 9.0.x \ No newline at end of file diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 68dd39ad..b4675506 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -2,117 +2,128 @@ name: Create Release Workflow on: - workflow_call: - inputs: - runs-on-config: - required: true - type: string - release-version: - required: true - type: string - is-pre-release: - required: true - type: boolean + workflow_call: + inputs: + runs-on-config: + required: true + type: string + release-version: + required: true + type: string + is-pre-release: + required: true + type: boolean jobs: - create-release: - runs-on: ${{ inputs.runs-on-config }} - name: Create a New Release - steps: - - name: Create Release - id: create-release - uses: actions/github-script@v7 - env: - RELEASE_VERSION: ${{ inputs.release-version }} - IS_PRERELEASE: ${{ inputs.is-pre-release }} - with: - result-encoding: string - script: | - const { RELEASE_VERSION, IS_PRERELEASE } = process.env + create-release: + runs-on: ${{ inputs.runs-on-config }} + name: Create a New Release + steps: + - name: Create Release + id: create-release + uses: actions/github-script@v7 + env: + RELEASE_VERSION: ${{ inputs.release-version }} + IS_PRERELEASE: ${{ inputs.is-pre-release }} + with: + result-encoding: string + script: | + // Extract environment variables + const { RELEASE_VERSION, IS_PRERELEASE } = process.env - const release_version = `release/${RELEASE_VERSION}` - const is_prerelease = IS_PRERELEASE === 'true' + // Define release version and pre-release flag + const release_version = `release/${RELEASE_VERSION}` + const is_prerelease = IS_PRERELEASE === 'true' - const createReleaseResponse = await github.rest.repos.createRelease({ - owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. - repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. - tag_name: release_version, // (Required) The name of the tag. - target_commitish: context.sha, // (Optional) Specifies the commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. Unused if the Git tag already exists. Default: the repository's default branch. - name: release_version, // (Optional) The name of the release. - // body: '', // (Optional) Text describing the contents of the tag. - // draft: true, // (Optional) true to create a draft (unpublished) release, false to create a published one. - prerelease: is_prerelease, // (Optional) true to identify the release as a prerelease. false to identify the release as a full release. - // discussion_category_name: , // (Optional) If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. For more information, see "Managing categories for discussions in your repository." - // make_latest: false, // (Optional) Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Defaults to true for newly published releases. legacy specifies that the latest release should be determined based on the release creation date and higher semantic version. - generate_release_notes: true // (Optional) Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes. - }) + // Create a new release + const createReleaseResponse = await github.rest.repos.createRelease({ + owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. + repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. + tag_name: release_version, // (Required) The name of the tag. + target_commitish: context.sha, // (Optional) Specifies the commitish value that determines where the Git tag is created from. Can be any branch or commit SHA. Unused if the Git tag already exists. Default: the repository's default branch. + name: release_version, // (Optional) The name of the release. + // body: '', // (Optional) Text describing the contents of the tag. + // draft: true, // (Optional) true to create a draft (unpublished) release, false to create a published one. + prerelease: is_prerelease, // (Optional) true to identify the release as a prerelease. false to identify the release as a full release. + // discussion_category_name: , // (Optional) If specified, a discussion of the specified category is created and linked to the release. The value must be a category that already exists in the repository. For more information, see "Managing categories for discussions in your repository." + // make_latest: false, // (Optional) Specifies whether this release should be set as the latest release for the repository. Drafts and prereleases cannot be set as latest. Defaults to true for newly published releases. legacy specifies that the latest release should be determined based on the release creation date and higher semantic version. + generate_release_notes: true // (Optional) Whether to automatically generate the name and body for this release. If name is specified, the specified name will be used; otherwise, a name will be automatically generated. If body is specified, the body will be pre-pended to the automatically generated notes. + }) - return createReleaseResponse.data.id - - uses: actions/download-artifact@v4 - with: - name: docs - - uses: actions/download-artifact@v4 - with: - name: nuget-package - - uses: actions/download-artifact@v4 - with: - name: pypi-package - - name: Generate Bundle - run: | - zip -r bundle.zip ./Tableau.Migration.${{ inputs.release-version }}.nupkg docs.zip tableau_migration-pypi.zip - - name: Upload Release Assets - id: upload-docs - uses: actions/github-script@v7 - env: - RELEASE_VERSION: ${{ inputs.release-version }} - RELEASE_ID: ${{ steps.create-release.outputs.result }} - with: - script: | - const fs = require('fs') - - const { RELEASE_VERSION, RELEASE_ID } = process.env + // Return the release ID + return createReleaseResponse.data.id + - uses: actions/download-artifact@v4 + with: + name: docs + - uses: actions/download-artifact@v4 + with: + name: nuget-package + - uses: actions/download-artifact@v4 + with: + name: pypi-package + - name: Generate Bundle + run: | + zip -r bundle.zip ./Tableau.Migration.${{ inputs.release-version }}.nupkg docs.zip tableau_migration-pypi.zip + - name: Upload Release Assets + id: upload-docs + uses: actions/github-script@v7 + env: + RELEASE_VERSION: ${{ inputs.release-version }} + RELEASE_ID: ${{ steps.create-release.outputs.result }} + with: + script: | + const fs = require('fs') + + // Extract environment variables + const { RELEASE_VERSION, RELEASE_ID } = process.env - const nuget_package = `Tableau.Migration.${RELEASE_VERSION}.nupkg` + // Define the NuGet package name + const nuget_package = `Tableau.Migration.${RELEASE_VERSION}.nupkg` - const uploadDocsResponse = await await github.rest.repos.uploadReleaseAsset({ - owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. - repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. - release_id: RELEASE_ID, // (Required) The unique identifier of the release. - name: 'docs.zip', // (Required) - label: 'Docs (zip)', // (Optional) - data: fs.readFileSync('./docs.zip') // (Optional) The raw file data. - }) + // Upload documentation zip file + const uploadDocsResponse = await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. + repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. + release_id: RELEASE_ID, // (Required) The unique identifier of the release. + name: 'docs.zip', // (Required) + label: 'Docs (zip)', // (Optional) + data: fs.readFileSync('./docs.zip') // (Optional) The raw file data. + }) - const uploadNugetResponse = await github.rest.repos.uploadReleaseAsset({ - owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. - repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. - release_id: RELEASE_ID, // (Required) The unique identifier of the release. - name: 'Tableau.Migration.nupkg', // (Required) - label: 'Nuget Package (nupkg)', // (Optional) - data: fs.readFileSync(nuget_package) // (Optional) The raw file data. - }) + // Upload NuGet package + const uploadNugetResponse = await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. + repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. + release_id: RELEASE_ID, // (Required) The unique identifier of the release. + name: 'Tableau.Migration.nupkg', // (Required) + label: 'Nuget Package (nupkg)', // (Optional) + data: fs.readFileSync(nuget_package) // (Optional) The raw file data. + }) - const uploadPypiResponse = await github.rest.repos.uploadReleaseAsset({ - owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. - repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. - release_id: RELEASE_ID, // (Required) The unique identifier of the release. - name: 'tableau_migration-pypi.zip', // (Required) - label: 'Pypi Package (zip)', // (Optional) - data: fs.readFileSync('./tableau_migration-pypi.zip') // (Optional) The raw file data. - }) + // Upload PyPI package + const uploadPypiResponse = await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. + repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. + release_id: RELEASE_ID, // (Required) The unique identifier of the release. + name: 'tableau_migration-pypi.zip', // (Required) + label: 'Pypi Package (zip)', // (Optional) + data: fs.readFileSync('./tableau_migration-pypi.zip') // (Optional) The raw file data. + }) - const uploadBundleResponse = await github.rest.repos.uploadReleaseAsset({ - owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. - repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. - release_id: RELEASE_ID, // (Required) The unique identifier of the release. - name: 'bundle.zip', // (Required) - label: 'Bundle Package (zip)', // (Optional) - data: fs.readFileSync('./bundle.zip') // (Optional) The raw file data. - }) + // Upload bundle zip file + const uploadBundleResponse = await github.rest.repos.uploadReleaseAsset({ + owner: context.repo.owner, // (Required) The account owner of the repository. The name is not case sensitive. + repo: context.repo.repo, // (Required) The name of the repository without the .git extension. The name is not case sensitive. + release_id: RELEASE_ID, // (Required) The unique identifier of the release. + name: 'bundle.zip', // (Required) + label: 'Bundle Package (zip)', // (Optional) + data: fs.readFileSync('./bundle.zip') // (Optional) The raw file data. + }) - return { - docs: uploadDocsResponse.data, - nuget: uploadNugetResponse.data, - pypi: uploadPypiResponse.data, - bundle: uploadBundleResponse.data - } + // Return the upload responses + return { + docs: uploadDocsResponse.data, + nuget: uploadNugetResponse.data, + pypi: uploadPypiResponse.data, + bundle: uploadBundleResponse.data + } diff --git a/.github/workflows/dotnet-build.yml b/.github/workflows/dotnet-build.yml index 8a912e4d..e4e03c4e 100644 --- a/.github/workflows/dotnet-build.yml +++ b/.github/workflows/dotnet-build.yml @@ -3,58 +3,77 @@ name: .Net Build Workflow on: - workflow_call: - inputs: - beta-version: - required: true - type: string + workflow_call: + inputs: + beta-version: + required: true + type: string env: - MIGRATIONSDK_BUILD_DOCS: 'no' - VERSION_REPLACE_ARGS: '' + MIGRATIONSDK_BUILD_DOCS: 'no' + VERSION_REPLACE_ARGS: '' jobs: - build: - strategy: - fail-fast: false - matrix: - os: ${{ fromJSON(vars.BUILD_OS) }} - config: ${{ fromJSON(vars.BUILD_CONFIGURATIONS) }} - runs-on: ${{ matrix.os }} - name: .Net Build ${{ matrix.os }}, ${{ matrix.config }} - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-dotnet - - name: Set Replaced Version Windows - if: ${{ runner.os == 'Windows' && inputs.beta-version != '' }} - run: echo "VERSION_REPLACE_ARGS=-p:Version='${{ inputs.beta-version }}'" | Out-File -FilePath $env:GITHUB_ENV -Append # no need for -Encoding utf8 - - name: Set Replaced Version Not Windows - if: ${{ runner.os != 'Windows' && inputs.beta-version != '' }} - run: echo "VERSION_REPLACE_ARGS=-p:Version='${{ inputs.beta-version }}'" >> $GITHUB_ENV - - name: Net Build Library ${{ matrix.config }} Beta Version ${{ inputs.beta-version }} - run: dotnet build '${{ vars.BUILD_SOLUTION }}' -c ${{ matrix.config }} ${{ env.VERSION_REPLACE_ARGS }} - - name: Net Publish Library ${{ matrix.config }} - if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} - run: dotnet publish --no-build -p:DebugType=None -p:DebugSymbols=false -c ${{ matrix.config }} -f ${{ vars.PYTHON_NETPACKAGE_FRAMEWORK }} -o './src/Python/src/tableau_migration/bin/' '${{ vars.BUILD_PROJECT }}' - - name: Net Publish Tests ${{ matrix.config }} - if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} - run: dotnet publish --no-build -p:DebugType=None -p:DebugSymbols=false -c ${{ matrix.config }} -f ${{ vars.PYTHON_NETPACKAGE_FRAMEWORK }} -o './dist/tests/' './tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj' - - name: Upload Published Artifacts - uses: actions/upload-artifact@v4 - if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} - with: - name: published-${{ matrix.config }} - path: './src/Python/src/tableau_migration/bin/**' - - name: Upload Tests Artifacts - uses: actions/upload-artifact@v4 - if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} - with: - name: tests-published-${{ matrix.config }} - path: './dist/tests/**' - - name: Upload Nupkg Artifact - uses: actions/upload-artifact@v4 - if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} - with: - name: nuget-package - path: './src/${{ vars.NUGET_PACKAGE_FOLDER }}/bin/${{ matrix.config }}/*.nupkg' - if-no-files-found: error + build: + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(vars.BUILD_OS) }} + config: ${{ fromJSON(vars.BUILD_CONFIGURATIONS) }} + runs-on: ${{ matrix.os }} + name: .Net Build ${{ matrix.os }}, ${{ matrix.config }} + steps: + # Checkout the repository + - uses: actions/checkout@v4 + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + + # Set replaced version for Windows + - name: Set Replaced Version Windows + if: ${{ runner.os == 'Windows' && inputs.beta-version != '' }} + run: echo "VERSION_REPLACE_ARGS=-p:Version='${{ inputs.beta-version }}'" | Out-File -FilePath $env:GITHUB_ENV -Append # no need for -Encoding utf8 + + # Set replaced version for non-Windows + - name: Set Replaced Version Not Windows + if: ${{ runner.os != 'Windows' && inputs.beta-version != '' }} + run: echo "VERSION_REPLACE_ARGS=-p:Version='${{ inputs.beta-version }}'" >> $GITHUB_ENV + + # Build the .NET library + - name: Net Build Library ${{ matrix.config }} Beta Version ${{ inputs.beta-version }} + run: dotnet build '${{ vars.BUILD_SOLUTION }}' -c ${{ matrix.config }} ${{ env.VERSION_REPLACE_ARGS }} + + # Publish the .NET library + - name: Net Publish Library ${{ matrix.config }} + if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} + run: dotnet publish --no-build -p:DebugType=None -p:DebugSymbols=false -c ${{ matrix.config }} -f ${{ vars.PYTHON_NETPACKAGE_FRAMEWORK }} -o './src/Python/src/tableau_migration/bin/' '${{ vars.BUILD_PROJECT }}' + + # Publish the .NET tests + - name: Net Publish Tests ${{ matrix.config }} + if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} + run: dotnet publish --no-build -p:DebugType=None -p:DebugSymbols=false -c ${{ matrix.config }} -f ${{ vars.PYTHON_NETPACKAGE_FRAMEWORK }} -o './dist/tests/' './tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj' + + # Upload published artifacts + - name: Upload Published Artifacts + uses: actions/upload-artifact@v4 + if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} + with: + name: published-${{ matrix.config }} + path: './src/Python/src/tableau_migration/bin/**' + + # Upload test artifacts + - name: Upload Tests Artifacts + uses: actions/upload-artifact@v4 + if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} + with: + name: tests-published-${{ matrix.config }} + path: './dist/tests/**' + + # Upload NuGet package artifact + - name: Upload Nupkg Artifact + uses: actions/upload-artifact@v4 + if: ${{ matrix.os == vars.PUBLISH_OS && matrix.config == 'Release' }} + with: + name: nuget-package + path: './src/${{ vars.NUGET_PACKAGE_FOLDER }}/bin/${{ matrix.config }}/*.nupkg' + if-no-files-found: error diff --git a/.github/workflows/dotnet-package.yml b/.github/workflows/dotnet-package.yml index e51cae20..13f762a8 100644 --- a/.github/workflows/dotnet-package.yml +++ b/.github/workflows/dotnet-package.yml @@ -2,56 +2,64 @@ name: .Net Publish Package Workflow on: - workflow_call: - secrets: - NUGET_PUBLISH_API_KEY: - required: false - inputs: - published-os: - required: true - type: string - runs-on-config: - required: true - type: string - build-config: - required: true - type: string - publish-environment: - required: true - type: string - package-version: - required: false - type: string - default: '' + workflow_call: + secrets: + NUGET_PUBLISH_API_KEY: + required: false + inputs: + published-os: + required: true + type: string + runs-on-config: + required: true + type: string + build-config: + required: true + type: string + publish-environment: + required: true + type: string + package-version: + required: false + type: string + default: '' env: - PUBLISH_PACKAGE_KEY: ${{ secrets.NUGET_PUBLISH_API_KEY }} + PUBLISH_PACKAGE_KEY: ${{ secrets.NUGET_PUBLISH_API_KEY }} jobs: - publish-package: - environment: - name: ${{ inputs.publish-environment }} - runs-on: ${{ inputs.runs-on-config }} - name: Publish Package from ${{ inputs.published-os }} with ${{ inputs.build-config }} configuration - steps: - - uses: actions/checkout@v4 - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - - uses: ./.github/actions/setup-dotnet - if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.runs-on-config != 'self-hosted' }} - - uses: actions/download-artifact@v4 - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - with: - name: nuget-package - - name: Publish the package to a Nuget Repository - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - shell: pwsh - env: - NUGET_API_KEY: ${{ secrets.NUGET_PUBLISH_API_KEY }} - run: dotnet nuget push Tableau.Migration.*.nupkg -k $env:NUGET_API_KEY -s ${{ vars.NUGET_PACKAGE_REPOSITORY }} --skip-duplicate - - name: Remove the package from Nuget Repository - if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.package-version != '' && inputs.publish-environment == 'public-dryrun' }} - shell: pwsh - env: - NUGET_API_KEY: ${{ secrets.NUGET_PUBLISH_API_KEY }} - run: dotnet nuget delete Tableau.Migration '${{ inputs.package-version }}' -k $env:NUGET_API_KEY -s ${{ vars.NUGET_PACKAGE_REPOSITORY }} --non-interactive - + publish-package: + environment: + name: ${{ inputs.publish-environment }} + runs-on: ${{ inputs.runs-on-config }} + name: Publish Package from ${{ inputs.published-os }} with ${{ inputs.build-config }} configuration + steps: + # Checkout the repository + - uses: actions/checkout@v4 + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.runs-on-config != 'self-hosted' }} + + # Download the NuGet package artifact + - uses: actions/download-artifact@v4 + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + with: + name: nuget-package + + # Publish the package to a NuGet Repository + - name: Publish the package to a Nuget Repository + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.NUGET_PUBLISH_API_KEY }} + run: dotnet nuget push Tableau.Migration.*.nupkg -k $env:NUGET_API_KEY -s ${{ vars.NUGET_PACKAGE_REPOSITORY }} --skip-duplicate + + # Remove the package from NuGet Repository if it's a public dry run + - name: Remove the package from Nuget Repository + if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.package-version != '' && inputs.publish-environment == 'public-dryrun' }} + shell: pwsh + env: + NUGET_API_KEY: ${{ secrets.NUGET_PUBLISH_API_KEY }} + run: dotnet nuget delete Tableau.Migration '${{ inputs.package-version }}' -k $env:NUGET_API_KEY -s ${{ vars.NUGET_PACKAGE_REPOSITORY }} --non-interactive diff --git a/.github/workflows/dotnet-test.yml b/.github/workflows/dotnet-test.yml index 77e17f76..dcffc9b4 100644 --- a/.github/workflows/dotnet-test.yml +++ b/.github/workflows/dotnet-test.yml @@ -2,36 +2,58 @@ name: .Net Test Workflow on: - workflow_call: + workflow_call: env: - MIGRATIONSDK_BUILD_DOCS: 'no' - MIGRATIONSDK_SKIP_GITHUB_RUNNER_TESTS: ${{ vars.MIGRATIONSDK_SKIP_GITHUB_RUNNER_TESTS }} - MIGRATIONSDK_SKIP_FLAKY_TESTS: ${{ vars.MIGRATIONSDK_SKIP_FLAKY_TESTS }} - MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_TIMESPAN: ${{ vars.MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_TIMESPAN }} + MIGRATIONSDK_BUILD_DOCS: 'no' + MIGRATIONSDK_SKIP_GITHUB_RUNNER_TESTS: ${{ vars.MIGRATIONSDK_SKIP_GITHUB_RUNNER_TESTS }} + MIGRATIONSDK_SKIP_FLAKY_TESTS: ${{ vars.MIGRATIONSDK_SKIP_FLAKY_TESTS }} + MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_TIMESPAN: ${{ vars.MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_TIMESPAN }} jobs: - test: - strategy: - fail-fast: false - matrix: - os: ${{ fromJSON(vars.BUILD_OS) }} - config: ${{ fromJSON(vars.BUILD_CONFIGURATIONS) }} - runs-on: ${{ matrix.os }} - name: .Net Test ${{ matrix.os }}, ${{ matrix.config }} - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-dotnet - - name: Build solution with ${{ matrix.config }} configuration - run: dotnet build '${{ vars.BUILD_SOLUTION }}' -c ${{ matrix.config }} - - name: Test solution with ${{ matrix.config }} configuration - run: | - dotnet test '${{ vars.BUILD_SOLUTION }}' --no-build -c ${{ matrix.config }} --verbosity normal --logger junit --results-directory "TestResults-${{ matrix.os }}-${{ matrix.config }}" -- RunConfiguration.TestSessionTimeout=${{ vars.MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_MILLISECONDS }} - - name: Upload test results - # Use always() to always run this step to publish test results when there are test failures - if: ${{ always() }} - uses: actions/upload-artifact@v4 - with: - name: dotnet-results-${{ matrix.os }}-${{ matrix.config }} - path: TestResults-${{ matrix.os }}-${{ matrix.config }} - if-no-files-found: error + test: + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(vars.BUILD_OS) }} + config: ${{ fromJSON(vars.BUILD_CONFIGURATIONS) }} + runs-on: ${{ matrix.os }} + name: .Net Test ${{ matrix.os }}, ${{ matrix.config }} + steps: + # Checkout the repository + - uses: actions/checkout@v4 + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + + # Restore Tools (i.e. report generator) + - name: Restore .NET Tools + run: dotnet tool restore + + # Build the solution + - name: Build Solution with ${{ matrix.config }} configuration + run: dotnet build '${{ vars.BUILD_SOLUTION }}' -c ${{ matrix.config }} + + # Test the solution + - name: Test Solution with ${{ matrix.config }} Configuration + run: | + dotnet test '${{ vars.BUILD_SOLUTION }}' --no-build -c ${{ matrix.config }} --verbosity normal --collect:"XPlat Code Coverage" --logger junit --results-directory "TestResults-${{ matrix.os }}-${{ matrix.config }}" -- RunConfiguration.TestSessionTimeout=${{ vars.MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_MILLISECONDS }} + + # Build code coverage reports + - name: Build Coverage Summary + run: dotnet reportgenerator -reports:"TestResults-${{ matrix.os }}-${{ matrix.config }}/*/coverage.cobertura.xml" -targetdir:"TestResults-${{ matrix.os }}-${{ matrix.config }}/coverage-reports" -reporttypes:"Html;MarkdownSummaryGithub" + + # Create job summary + - name: Set Job Summary + run: cat "TestResults-${{ matrix.os }}-${{ matrix.config }}/coverage-reports/SummaryGithub.md" >> $GITHUB_STEP_SUMMARY + shell: bash + + # Upload test results + - name: Upload Test Results + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: dotnet-results-${{ matrix.os }}-${{ matrix.config }} + path: TestResults-${{ matrix.os }}-${{ matrix.config }} + if-no-files-found: error diff --git a/.github/workflows/publishdocs-dryrun.yml b/.github/workflows/publishdocs-dryrun.yml index 77d35191..f38e6755 100644 --- a/.github/workflows/publishdocs-dryrun.yml +++ b/.github/workflows/publishdocs-dryrun.yml @@ -3,36 +3,45 @@ name: .Net Publish Docs - Dry-Run on: - workflow_call: - inputs: - runs-on-config: - required: true - type: string - build-config: - required: true - type: string - python-version: - required: true - type: string + workflow_call: + inputs: + runs-on-config: + required: true + type: string + build-config: + required: true + type: string + python-version: + required: true + type: string jobs: - publish-docs: - runs-on: ${{ inputs.runs-on-config }} - name: Publish docs - Dry-Run - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-dotnet - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python-version }} - - name: Generate API Reference Docs.. - shell: pwsh - run: | - ./scripts/generate-docs.ps1 -SkipPreClean - Compress-Archive ./docs/* -Destination docs.zip - - name: Upload Docs Artifact - uses: actions/upload-artifact@v4 - with: - name: docs - path: docs.zip \ No newline at end of file + publish-docs: + runs-on: ${{ inputs.runs-on-config }} + name: Publish docs - Dry-Run + steps: + # Checkout the repository + - uses: actions/checkout@v4 + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + + # Setup Python environment + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + # Generate API Reference Docs + - name: Generate API Reference Docs + shell: pwsh + run: | + ./scripts/generate-docs.ps1 -SkipPreClean + Compress-Archive ./docs/* -Destination docs.zip + + # Upload Docs Artifact + - name: Upload Docs Artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs.zip diff --git a/.github/workflows/publishdocs.yml b/.github/workflows/publishdocs.yml index f6966427..2b77e6f8 100644 --- a/.github/workflows/publishdocs.yml +++ b/.github/workflows/publishdocs.yml @@ -3,59 +3,74 @@ name: .Net Publish Docs on: - workflow_call: - inputs: - runs-on-config: - required: true - type: string - build-config: - required: true - type: string - python-version: - required: true - type: string + workflow_call: + inputs: + runs-on-config: + required: true + type: string + build-config: + required: true + type: string + python-version: + required: true + type: string # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: - contents: read - pages: write - id-token: write + contents: read + pages: write + id-token: write # Allow one concurrent deployment concurrency: - group: "pages" - cancel-in-progress: true + group: "pages" + cancel-in-progress: true jobs: - publish-docs: - environment: - name: docs - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ${{ inputs.runs-on-config }} - name: Publish docs - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-dotnet - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ inputs.python-version }} - - name: Generate API Reference Docs.. - shell: pwsh - run: | - ./scripts/generate-docs.ps1 -SkipPreClean - Compress-Archive ./docs/* -Destination docs.zip - - name: Upload Docs Artifact - uses: actions/upload-artifact@v4 - with: - name: docs - path: docs.zip - - name: Setup GitHub Pages - uses: actions/configure-pages@v4.0.0 - - name: Upload Github Pages - uses: actions/upload-pages-artifact@v3.0.1 - with: - path: './docs' - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4.0.5 + publish-docs: + environment: + name: docs + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ${{ inputs.runs-on-config }} + name: Publish docs + steps: + # Checkout the repository + - uses: actions/checkout@v4 + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + + # Setup Python environment + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + # Generate API Reference Docs + - name: Generate API Reference Docs + shell: pwsh + run: | + ./scripts/generate-docs.ps1 -SkipPreClean + Compress-Archive ./docs/* -Destination docs.zip + + # Upload Docs Artifact + - name: Upload Docs Artifact + uses: actions/upload-artifact@v4 + with: + name: docs + path: docs.zip + + # Setup GitHub Pages + - name: Setup GitHub Pages + uses: actions/configure-pages@v4.0.0 + + # Upload to GitHub Pages + - name: Upload Github Pages + uses: actions/upload-pages-artifact@v3.0.1 + with: + path: './docs' + + # Deploy to GitHub Pages + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4.0.5 diff --git a/.github/workflows/python-example-test.yml b/.github/workflows/python-example-test.yml new file mode 100644 index 00000000..82fc9423 --- /dev/null +++ b/.github/workflows/python-example-test.yml @@ -0,0 +1,77 @@ +name: Python Test + +on: + workflow_call: + +env: + MIG_SDK_PYTHON_BUILD: ${{ vars.MIG_SDK_PYTHON_BUILD }} + +defaults: + run: + working-directory: ./tests/Python.ExampleApplication.Tests + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(vars.BUILD_OS) }} + config: ${{ fromJSON(vars.BUILD_CONFIGURATIONS) }} + runs-on: ${{ matrix.os }} + name: Test on ${{ matrix.os }}, ${{ matrix.config }} + steps: + # Checkout the repository + - uses: actions/checkout@v4 + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + + # Restore Tools (i.e. report generator) + - name: Restore .NET Tools + run: dotnet tool restore + + # Setup Python environment + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ vars.PYTHON_TEST_VERSIONS }} + cache: 'pip' # caching pip dependencies + + # Install dependencies + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + + # Download the published test artifacts - this is needed for test infra + - uses: actions/download-artifact@v4 + with: + name: tests-published-${{ matrix.config }} + path: ./src/Python/src/tableau_migration/bin/ + + # Lint with ruff + - name: Lint with ruff + run: python -m hatch run lint:lint + + # Test with pytest + - name: Test with pytest + run: python -m hatch --data-dir=.hatch --cache-dir=.hatch_cache run test:testcov + + # Build code coverage reports + - name: Build Coverage Summary + run: dotnet reportgenerator -reports:"TestResults/coverage*.xml" -targetdir:"TestResults/coverage-reports" -reporttypes:"Html;MarkdownSummaryGithub" + + # Create job summary + - name: Set Job Summary + run: cat "TestResults/coverage-reports/SummaryGithub.md" >> $GITHUB_STEP_SUMMARY + shell: bash + + # Upload test results + - name: Upload Test Results + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: python-example-test-results-${{ matrix.os }}-${{ matrix.config }} + path: ./tests/Python.ExampleApplication.Tests/TestResults + if-no-files-found: error diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 58fc4cea..47cc9966 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -2,91 +2,109 @@ name: Python Publish Package Workflow on: - workflow_call: - secrets: - PYPI_PUBLISH_USER_PASS: - required: false - inputs: - runs-on-config: - required: true - type: string - build-config: - required: true - type: string - publish-environment: - required: true - type: string - beta-version: - required: true - type: string - publish-artifact: - required: false - type: boolean - default: false + workflow_call: + secrets: + PYPI_PUBLISH_USER_PASS: + required: false + inputs: + runs-on-config: + required: true + type: string + build-config: + required: true + type: string + publish-environment: + required: true + type: string + beta-version: + required: true + type: string + publish-artifact: + required: false + type: boolean + default: false env: - PUBLISH_PACKAGE_KEY: ${{ secrets.PYPI_PUBLISH_USER_PASS }} + PUBLISH_PACKAGE_KEY: ${{ secrets.PYPI_PUBLISH_USER_PASS }} defaults: - run: - working-directory: ./src/Python + run: + working-directory: ./src/Python jobs: - publish-package: - environment: - name: ${{ inputs.publish-environment }} - runs-on: ${{ inputs.runs-on-config }} - name: Publish Package with ${{ inputs.build-config }} configuration - steps: - - uses: actions/checkout@v4 - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - - uses: ./.github/actions/setup-dotnet - if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.runs-on-config != 'self-hosted' }} - - name: Set up Python - uses: actions/setup-python@v5 - if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.runs-on-config != 'self-hosted' }} - with: - python-version: ${{ vars.PYTHON_TEST_VERSIONS }} - cache: 'pip' # caching pip dependencies - - name: Install dependencies - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - shell: pwsh - run: | - python -m pip install --upgrade pip - python -m pip install hatch twine - - name: Lint with ruff - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - run: | - # default set of ruff rules with GitHub Annotations - python -m hatch run lint:lint - - uses: actions/download-artifact@v4 - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - with: - name: published-${{ inputs.build-config }} - path: ./src/Python/src/tableau_migration/bin/ - - name: Set Replaced Version - if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.beta-version != '' }} - shell: pwsh - run: hatch version '${{ inputs.beta-version }}' - - name: Build Python Package - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - shell: pwsh - run: python -m hatch build - - name: Publish Python Package Beta Version ${{ inputs.beta-version }} - if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} - shell: pwsh - env: - TWINE_USERNAME: ${{ vars.PYPI_PUBLISH_USER }} - TWINE_NON_INTERACTIVE: 1 - TWINE_PASSWORD: ${{ secrets.PYPI_PUBLISH_USER_PASS }} - run: | - python -m twine upload --repository-url ${{ vars.PYPI_PACKAGE_REPOSITORY_URL }} dist/* - Compress-Archive -Path .\dist\* -DestinationPath .\tableau_migration-pypi.zip - - name: Upload Pypi Artifact - uses: actions/upload-artifact@v4 - if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.publish-artifact }} - with: - name: pypi-package - path: src/Python/tableau_migration-pypi.zip - if-no-files-found: error - + publish-package: + environment: + name: ${{ inputs.publish-environment }} + runs-on: ${{ inputs.runs-on-config }} + name: Publish Package with ${{ inputs.build-config }} configuration + steps: + # Checkout the repository + - uses: actions/checkout@v4 + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.runs-on-config != 'self-hosted' }} + + # Setup Python environment + - name: Set up Python + uses: actions/setup-python@v5 + if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.runs-on-config != 'self-hosted' }} + with: + python-version: ${{ vars.PYTHON_TEST_VERSIONS }} + cache: 'pip' # caching pip dependencies + + # Install dependencies + - name: Install dependencies + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + shell: pwsh + run: | + python -m pip install --upgrade pip + python -m pip install hatch twine + + # Lint with ruff + - name: Lint with ruff + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + run: | + # default set of ruff rules with GitHub Annotations + python -m hatch run lint:lint + + # Download the published artifact + - uses: actions/download-artifact@v4 + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + with: + name: published-${{ inputs.build-config }} + path: ./src/Python/src/tableau_migration/bin/ + + # Set replaced version + - name: Set Replaced Version + if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.beta-version != '' }} + shell: pwsh + run: hatch version '${{ inputs.beta-version }}' + + # Build Python package + - name: Build Python Package + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + shell: pwsh + run: python -m hatch build + + # Publish Python package + - name: Publish Python Package Beta Version ${{ inputs.beta-version }} + if: ${{ env.PUBLISH_PACKAGE_KEY != '' }} + shell: pwsh + env: + TWINE_USERNAME: ${{ vars.PYPI_PUBLISH_USER }} + TWINE_NON_INTERACTIVE: 1 + TWINE_PASSWORD: ${{ secrets.PYPI_PUBLISH_USER_PASS }} + run: | + python -m twine upload --repository-url ${{ vars.PYPI_PACKAGE_REPOSITORY_URL }} dist/* + Compress-Archive -Path .\dist\* -DestinationPath .\tableau_migration-pypi.zip + + # Upload PyPI artifact + - name: Upload Pypi Artifact + uses: actions/upload-artifact@v4 + if: ${{ env.PUBLISH_PACKAGE_KEY != '' && inputs.publish-artifact }} + with: + name: pypi-package + path: src/Python/tableau_migration-pypi.zip + if-no-files-found: error diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 18ab3694..8302d8cd 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -1,42 +1,77 @@ name: Python Test on: - workflow_call: + workflow_call: env: - MIG_SDK_PYTHON_BUILD: ${{ vars.MIG_SDK_PYTHON_BUILD }} + MIG_SDK_PYTHON_BUILD: ${{ vars.MIG_SDK_PYTHON_BUILD }} defaults: - run: - working-directory: ./src/Python + run: + working-directory: ./src/Python + jobs: - test: - strategy: - fail-fast: false - matrix: - os: ${{ fromJSON(vars.BUILD_OS) }} - config: ${{ fromJSON(vars.BUILD_CONFIGURATIONS) }} - runs-on: ${{ matrix.os }} - name: Test on ${{ matrix.os }}, ${{ matrix.config }} - steps: - - uses: actions/checkout@v4 - - uses: ./.github/actions/setup-dotnet - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ vars.PYTHON_TEST_VERSIONS }} - cache: 'pip' # caching pip dependencies - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install hatch - - uses: actions/download-artifact@v4 - with: - name: tests-published-${{ matrix.config }} - path: ./src/Python/src/tableau_migration/bin/ - - name: Lint with ruff - run: python -m hatch run lint:lint - - - name: Test with pytest - run: | - python -m hatch --data-dir=.hatch --cache-dir=.hatch_cache run test:testcov + test: + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(vars.BUILD_OS) }} + config: ${{ fromJSON(vars.BUILD_CONFIGURATIONS) }} + runs-on: ${{ matrix.os }} + name: Test on ${{ matrix.os }}, ${{ matrix.config }} + steps: + # Checkout the repository + - uses: actions/checkout@v4 + + # Setup .NET environment + - uses: ./.github/actions/setup-dotnet + + # Restore Tools (i.e. report generator) + - name: Restore .NET Tools + run: dotnet tool restore + + # Setup Python environment + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ vars.PYTHON_TEST_VERSIONS }} + cache: 'pip' # caching pip dependencies + + # Install dependencies + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install hatch + + # Download the published test artifacts + - uses: actions/download-artifact@v4 + with: + name: tests-published-${{ matrix.config }} + path: ./src/Python/src/tableau_migration/bin/ + + # Lint with ruff + - name: Lint with ruff + run: python -m hatch run lint:lint + + # Test with pytest + - name: Test with pytest + run: python -m hatch --data-dir=.hatch --cache-dir=.hatch_cache run test:testcov + + # Build code coverage reports + - name: Build Coverage Summary + run: dotnet reportgenerator -reports:"TestResults/coverage*.xml" -targetdir:"TestResults/coverage-reports" -reporttypes:"Html;MarkdownSummaryGithub" + + # Create job summary + - name: Set Job Summary + run: cat "TestResults/coverage-reports/SummaryGithub.md" >> $GITHUB_STEP_SUMMARY + shell: bash + + # Upload test results + - name: Upload Test Results + # Use always() to always run this step to publish test results when there are test failures + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: python-test-results-${{ matrix.os }}-${{ matrix.config }} + path: ./src/Python/TestResults + if-no-files-found: error diff --git a/.github/workflows/sdk-workflow.yml b/.github/workflows/sdk-workflow.yml index a94aff9c..f00c7a64 100644 --- a/.github/workflows/sdk-workflow.yml +++ b/.github/workflows/sdk-workflow.yml @@ -3,236 +3,271 @@ name: SDK Workflow on: - push: - branches: - - main - - 'release/**' - - 'staging/**' - - 'feature/**' - paths-ignore: - - README.md - - '**/README.md' - - .gitignore - - '**/.gitignore' - - 'CODEOWNERS' - pull_request: - # The default trigger for Pull Requests are: - # - opened - # - synchronize - # - reopened - # Ref: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request - types: [opened, synchronize, reopened, ready_for_review] - paths-ignore: - - README.md - - '**/README.md' - - .gitignore - - '**/.gitignore' - - 'CODEOWNERS' - workflow_dispatch: - inputs: - publish-release: - description: 'Publish Release' - required: false - default: 'No Publishing' - type: choice - options: - - No Publishing - - Beta-Internal - - Beta - - Prod-Internal - - Prod - publish-docs: - description: 'Publish Documentation to Github Pages' - required: false - default: false - type: boolean + # On Push + push: + branches: + - main + - 'release/**' + - 'staging/**' + - 'feature/**' + paths-ignore: + - README.md + - '**/README.md' + - .gitignore + - '**/.gitignore' + - 'CODEOWNERS' + + # On Pull Request + pull_request: + # The default trigger for Pull Requests are: + # - opened + # - synchronize + # - reopened + # Ref: https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#pull_request + types: [opened, synchronize, reopened, ready_for_review] + paths-ignore: + - README.md + - '**/README.md' + - .gitignore + - '**/.gitignore' + - 'CODEOWNERS' + + # When started manually + workflow_dispatch: + inputs: + publish-release: + description: 'Publish Release' + required: false + default: 'No Publishing' + type: choice + options: + - No Publishing + - Beta-Internal + - Beta + - Prod-Internal + - Prod + publish-docs: + description: 'Publish Documentation to Github Pages' + required: false + default: false + type: boolean jobs: - define-version: - runs-on: ubuntu-latest - outputs: - beta-version: ${{ steps.set-beta-version.outputs.betaversion }} - code-version: ${{ steps.get-version.outputs.codeversion }} - if: github.event.pull_request.draft == false - steps: - - uses: actions/checkout@v4 - - name: Set Beta Version - id: set-beta-version - if: ${{ inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' }} - run: | - # Gets the current version from Directory.Build.props. Example: 0.2.0 - majorminorbuild=$(grep -oP '\K[^<]*' Directory.Build.props) - # Generate the beta sdk version using the format: currentversion-beta{workflow run number}.post{workflow run attempt} - # Examples: 0.2.0-beta1141.post1 - sdkversion="$majorminorbuild-beta${{ github.run_number }}.post${{ github.run_attempt }}" - echo "betaversion=$sdkversion" >> "$GITHUB_OUTPUT" - - name: Get Version - id: get-version - if: ${{ inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal' }} - run: | - # Gets the current version from Directory.Build.props. Example: 0.2.0 - majorminorbuild=$(grep -oP '\K[^<]*' Directory.Build.props) - echo "codeversion=$majorminorbuild" >> "$GITHUB_OUTPUT" - dotnet-build: - needs: [ define-version ] - uses: ./.github/workflows/dotnet-build.yml - with: - beta-version: ${{ needs.define-version.outputs.beta-version }} - dotnet-test: - needs: [ define-version ] - uses: ./.github/workflows/dotnet-test.yml - python-test: - needs: [ dotnet-build ] - uses: ./.github/workflows/python-test.yml - dotnet-publish-package-internal-dryrun: - needs: [ define-version, dotnet-build ] - uses: ./.github/workflows/dotnet-package.yml - secrets: inherit - with: - published-os: ${{ vars.PUBLISH_OS }} - runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: internal-dryrun - package-version: ${{ needs.define-version.outputs.beta-version }} - dotnet-publish-package-public-dryrun: - needs: [ define-version, dotnet-build ] - uses: ./.github/workflows/dotnet-package.yml - secrets: inherit - with: - published-os: ${{ vars.PUBLISH_OS }} - runs-on-config: ${{ vars.PUBLISH_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: public-dryrun - package-version: ${{ needs.define-version.outputs.beta-version }} - python-publish-package-internal-dryrun: - needs: [ define-version, dotnet-build ] - uses: ./.github/workflows/python-package.yml - secrets: inherit - with: - runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: internal-dryrun - beta-version: ${{ needs.define-version.outputs.beta-version }} - python-publish-package-public-dryrun: - needs: [ define-version, dotnet-build ] - uses: ./.github/workflows/python-package.yml - secrets: inherit - with: - runs-on-config: ${{ vars.PUBLISH_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: public-dryrun - beta-version: ${{ needs.define-version.outputs.beta-version }} - publish-artifact: true - publish-docs-dry-run: - needs: [ dotnet-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/publishdocs-dryrun.yml - if: ${{ (inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' && inputs.publish-docs == false) || (github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') && !startsWith(github.ref, 'refs/tags/release/')) }} - with: - runs-on-config: ${{ vars.DOCS_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - python-version: ${{ vars.PYTHON_PUBLISH_DOCS_VERSION }} - publish-docs: - needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/publishdocs.yml - if: ${{ (inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal' || inputs.publish-docs == true) && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} - with: - runs-on-config: ${{ vars.DOCS_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - python-version: ${{ vars.PYTHON_PUBLISH_DOCS_VERSION }} - create-release-from-dry-run: - needs: [ define-version, publish-docs-dry-run, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/create-release.yml - if: ${{ inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal' || inputs.publish-release == 'Beta' }} - with: - runs-on-config: ${{ vars.PUBLISH_OS }} - release-version: ${{ needs.define-version.outputs.beta-version != '' && needs.define-version.outputs.beta-version || needs.define-version.outputs.code-version }} - is-pre-release: ${{ inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' }} - create-release: - needs: [ define-version, publish-docs, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/create-release.yml - if: ${{ inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal' || inputs.publish-release == 'Beta' || inputs.publish-release == 'Beta-Internal' }} - with: - runs-on-config: ${{ vars.PUBLISH_OS }} - release-version: ${{ needs.define-version.outputs.beta-version != '' && needs.define-version.outputs.beta-version || needs.define-version.outputs.code-version }} - is-pre-release: ${{ inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' }} - dotnet-publish-package-internal-beta: - needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/dotnet-package.yml - secrets: inherit - if: ${{ inputs.publish-release == 'Beta' || inputs.publish-release == 'Beta-Internal' }} - with: - published-os: ${{ vars.PUBLISH_OS }} - runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: internal-beta - dotnet-publish-package-public-beta: - needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/dotnet-package.yml - secrets: inherit - if: ${{ inputs.publish-release == 'Beta' }} - with: - published-os: ${{ vars.PUBLISH_OS }} - runs-on-config: ${{ vars.PUBLISH_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: public-beta - dotnet-publish-package-internal-prod: - needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/dotnet-package.yml - secrets: inherit - if: ${{ (inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} - with: - published-os: ${{ vars.PUBLISH_OS }} - runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: internal-prod - dotnet-publish-package-public-prod: - needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/dotnet-package.yml - secrets: inherit - if: ${{ inputs.publish-release == 'Prod' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} - with: - published-os: ${{ vars.PUBLISH_OS }} - runs-on-config: ${{ vars.PUBLISH_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: public-prod - python-publish-package-internal-beta: - needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/python-package.yml - secrets: inherit - if: ${{ inputs.publish-release == 'Beta' || inputs.publish-release == 'Beta-Internal' }} - with: - runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: internal-beta - beta-version: ${{ needs.define-version.outputs.beta-version }} - python-publish-package-public-beta: - needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/python-package.yml - secrets: inherit - if: ${{ inputs.publish-release == 'Beta' }} - with: - runs-on-config: ${{ vars.PUBLISH_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: public-beta - beta-version: ${{ needs.define-version.outputs.beta-version }} - python-publish-package-internal-prod: - needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/python-package.yml - secrets: inherit - if: ${{ (inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} - with: - runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: internal-prod - beta-version: ${{ needs.define-version.outputs.beta-version }} - python-publish-package-public-prod: - needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] - uses: ./.github/workflows/python-package.yml - secrets: inherit - if: ${{ inputs.publish-release == 'Prod' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} - with: - runs-on-config: ${{ vars.PUBLISH_OS }} - build-config: ${{ vars.PUBLISH_CONFIGURATION }} - publish-environment: public-prod - beta-version: ${{ needs.define-version.outputs.beta-version }} - \ No newline at end of file + # Define version job + define-version: + runs-on: ubuntu-latest + outputs: + beta-version: ${{ steps.set-beta-version.outputs.betaversion }} + code-version: ${{ steps.get-version.outputs.codeversion }} + if: github.event.pull_request.draft == false + steps: + - uses: actions/checkout@v4 + - name: Set Beta Version + id: set-beta-version + if: ${{ inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' }} + run: | + # Gets the current version from Directory.Build.props. Example: 0.2.0 + majorminorbuild=$(grep -oP '\K[^<]*' Directory.Build.props) + # Generate the beta sdk version using the format: currentversion-beta{workflow run number}.post{workflow run attempt} + # Examples: 0.2.0-beta1141.post1 + sdkversion="$majorminorbuild-beta${{ github.run_number }}.post${{ github.run_attempt }}" + echo "betaversion=$sdkversion" >> "$GITHUB_OUTPUT" + - name: Get Version + id: get-version + if: ${{ inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal' }} + run: | + # Gets the current version from Directory.Build.props. Example: 0.2.0 + majorminorbuild=$(grep -oP '\K[^<]*' Directory.Build.props) + echo "codeversion=$majorminorbuild" >> "$GITHUB_OUTPUT" + + # Build and test .NET project + dotnet-build: + needs: [ define-version ] + uses: ./.github/workflows/dotnet-build.yml + with: + beta-version: ${{ needs.define-version.outputs.beta-version }} + + dotnet-test: + needs: [ define-version ] + uses: ./.github/workflows/dotnet-test.yml + + python-test: + needs: [ dotnet-build ] + uses: ./.github/workflows/python-test.yml + + python-example-test: + needs: [ dotnet-build ] + uses: ./.github/workflows/python-example-test.yml + + # Publish .NET packages + dotnet-publish-package-internal-dryrun: + needs: [ define-version, dotnet-build ] + uses: ./.github/workflows/dotnet-package.yml + secrets: inherit + with: + published-os: ${{ vars.PUBLISH_OS }} + runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: internal-dryrun + package-version: ${{ needs.define-version.outputs.beta-version }} + + dotnet-publish-package-public-dryrun: + needs: [ define-version, dotnet-build ] + uses: ./.github/workflows/dotnet-package.yml + secrets: inherit + with: + published-os: ${{ vars.PUBLISH_OS }} + runs-on-config: ${{ vars.PUBLISH_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: public-dryrun + package-version: ${{ needs.define-version.outputs.beta-version }} + + # Publish Python packages + python-publish-package-internal-dryrun: + needs: [ define-version, dotnet-build ] + uses: ./.github/workflows/python-package.yml + secrets: inherit + with: + runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: internal-dryrun + beta-version: ${{ needs.define-version.outputs.beta-version }} + + python-publish-package-public-dryrun: + needs: [ define-version, dotnet-build ] + uses: ./.github/workflows/python-package.yml + secrets: inherit + with: + runs-on-config: ${{ vars.PUBLISH_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: public-dryrun + beta-version: ${{ needs.define-version.outputs.beta-version }} + publish-artifact: true + + # Publish documentation + publish-docs-dry-run: + needs: [ dotnet-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/publishdocs-dryrun.yml + if: ${{ (inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' && inputs.publish-docs == false) || (github.ref != 'refs/heads/main' && !startsWith(github.ref, 'refs/heads/release/') && !startsWith(github.ref, 'refs/tags/release/')) }} + with: + runs-on-config: ${{ vars.DOCS_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + python-version: ${{ vars.PYTHON_PUBLISH_DOCS_VERSION }} + + publish-docs: + needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/publishdocs.yml + if: ${{ (inputs.publish-release == 'Prod' || inputs.publish-docs == true) && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} + with: + runs-on-config: ${{ vars.DOCS_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + python-version: ${{ vars.PYTHON_PUBLISH_DOCS_VERSION }} + + # Create release + create-release-from-dry-run: + needs: [ define-version, publish-docs-dry-run, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/create-release.yml + if: ${{ inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal' || inputs.publish-release == 'Beta' }} + with: + runs-on-config: ${{ vars.PUBLISH_OS }} + release-version: ${{ needs.define-version.outputs.beta-version != '' && needs.define-version.outputs.beta-version || needs.define-version.outputs.code-version }} + is-pre-release: ${{ inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' }} + + create-release: + needs: [ define-version, publish-docs, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/create-release.yml + if: ${{ inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal' || inputs.publish-release == 'Beta' || inputs.publish-release == 'Beta-Internal' }} + with: + runs-on-config: ${{ vars.PUBLISH_OS }} + release-version: ${{ needs.define-version.outputs.beta-version != '' && needs.define-version.outputs.beta-version || needs.define-version.outputs.code-version }} + is-pre-release: ${{ inputs.publish-release != 'Prod' && inputs.publish-release != 'Prod-Internal' }} + + # Publish .NET packages (Beta and Prod) + dotnet-publish-package-internal-beta: + needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/dotnet-package.yml + secrets: inherit + if: ${{ inputs.publish-release == 'Beta' || inputs.publish-release == 'Beta-Internal' }} + with: + published-os: ${{ vars.PUBLISH_OS }} + runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: internal-beta + + dotnet-publish-package-public-beta: + needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/dotnet-package.yml + secrets: inherit + if: ${{ inputs.publish-release == 'Beta' }} + with: + published-os: ${{ vars.PUBLISH_OS }} + runs-on-config: ${{ vars.PUBLISH_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: public-beta + + dotnet-publish-package-internal-prod: + needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/dotnet-package.yml + secrets: inherit + if: ${{ (inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} + with: + published-os: ${{ vars.PUBLISH_OS }} + runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: internal-prod + + dotnet-publish-package-public-prod: + needs: [ dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/dotnet-package.yml + secrets: inherit + if: ${{ inputs.publish-release == 'Prod' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} + with: + published-os: ${{ vars.PUBLISH_OS }} + runs-on-config: ${{ vars.PUBLISH_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: public-prod + + # Publish Python packages (Beta and Prod) + python-publish-package-internal-beta: + needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/python-package.yml + secrets: inherit + if: ${{ inputs.publish-release == 'Beta' || inputs.publish-release == 'Beta-Internal' }} + with: + runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: internal-beta + beta-version: ${{ needs.define-version.outputs.beta-version }} + + python-publish-package-public-beta: + needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/python-package.yml + secrets: inherit + if: ${{ inputs.publish-release == 'Beta' }} + with: + runs-on-config: ${{ vars.PUBLISH_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: public-beta + beta-version: ${{ needs.define-version.outputs.beta-version }} + + python-publish-package-internal-prod: + needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/python-package.yml + secrets: inherit + if: ${{ (inputs.publish-release == 'Prod' || inputs.publish-release == 'Prod-Internal') && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} + with: + runs-on-config: ${{ vars.PUBLISH_PACKAGE_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: internal-prod + beta-version: ${{ needs.define-version.outputs.beta-version }} + + python-publish-package-public-prod: + needs: [ define-version, dotnet-test, python-test, dotnet-publish-package-internal-dryrun, dotnet-publish-package-public-dryrun, python-publish-package-internal-dryrun, python-publish-package-public-dryrun ] + uses: ./.github/workflows/python-package.yml + secrets: inherit + if: ${{ inputs.publish-release == 'Prod' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release/') || startsWith(github.ref, 'refs/tags/release/')) }} + with: + runs-on-config: ${{ vars.PUBLISH_OS }} + build-config: ${{ vars.PUBLISH_CONFIGURATION }} + publish-environment: public-prod + beta-version: ${{ needs.define-version.outputs.beta-version }} diff --git a/.gitignore b/.gitignore index 18c32dbb..d6eeb312 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ [bB]in [oO]bj -/.vs +**/.vs /docs/** *.csproj.user .idea/ diff --git a/Directory.Build.props b/Directory.Build.props index 45000c9c..08d11cb8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,13 +1,14 @@ - 12.0 + preview enable true true - 5.0.1 + 5.1.1 Salesforce, Inc. Salesforce, Inc. Copyright (c) 2025, Salesforce, Inc. and its licensors Apache-2.0 + 11.1.0 \ No newline at end of file diff --git a/Migration SDK.sln b/Migration SDK.sln index f2d424c4..e01027aa 100644 --- a/Migration SDK.sln +++ b/Migration SDK.sln @@ -70,6 +70,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "workflows", "workflows", "{ .github\workflows\dotnet-test.yml = .github\workflows\dotnet-test.yml .github\workflows\publishdocs-dryrun.yml = .github\workflows\publishdocs-dryrun.yml .github\workflows\publishdocs.yml = .github\workflows\publishdocs.yml + .github\workflows\python-example-test.yml = .github\workflows\python-example-test.yml .github\workflows\python-package.yml = .github\workflows\python-package.yml .github\workflows\python-test.yml = .github\workflows\python-test.yml .github\workflows\README.md = .github\workflows\README.md @@ -85,6 +86,16 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Tableau.Migration.PythonGen EndProject Project("{888888A0-9F3D-457C-B088-3A5042F75D52}") = "Python.ExampleApplication.Tests", "tests\Python.ExampleApplication.Tests\Python.ExampleApplication.Tests.pyproj", "{B1884017-8E25-4A26-8C89-D9D880CFA392}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{52787DA5-4208-4B9D-8092-31F6C84ECC34}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tableau.Migration.ManifestAnalyzer", "tools\Tableau.Migration.ManifestAnalyzer\Tableau.Migration.ManifestAnalyzer.csproj", "{10EB18EB-3302-4883-9C3B-083D65134ED6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tableau.Migration.CleanSite", "tools\Tableau.Migration.CleanServer\Tableau.Migration.CleanSite.csproj", "{1CC08475-C472-424D-BEEE-E686FAE9F490}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tableau.Migration.ManifestExplorer", "tools\Tableau.Migration.ManifestExplorer\Tableau.Migration.ManifestExplorer.csproj", "{D5207985-6E7C-4BD0-B7DF-B3720F605C86}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tableau.Migration.ManifestExplorer.Desktop", "tools\Tableau.Migration.ManifestExplorer.Desktop\Tableau.Migration.ManifestExplorer.Desktop.csproj", "{7104A6BF-44E1-4ED9-9233-C3DC29132A91}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -125,6 +136,22 @@ Global {F20029C7-4514-4668-8941-B2C3BC245CCB}.Release|Any CPU.Build.0 = Release|Any CPU {B1884017-8E25-4A26-8C89-D9D880CFA392}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B1884017-8E25-4A26-8C89-D9D880CFA392}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10EB18EB-3302-4883-9C3B-083D65134ED6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {10EB18EB-3302-4883-9C3B-083D65134ED6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {10EB18EB-3302-4883-9C3B-083D65134ED6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {10EB18EB-3302-4883-9C3B-083D65134ED6}.Release|Any CPU.Build.0 = Release|Any CPU + {1CC08475-C472-424D-BEEE-E686FAE9F490}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CC08475-C472-424D-BEEE-E686FAE9F490}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CC08475-C472-424D-BEEE-E686FAE9F490}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CC08475-C472-424D-BEEE-E686FAE9F490}.Release|Any CPU.Build.0 = Release|Any CPU + {D5207985-6E7C-4BD0-B7DF-B3720F605C86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5207985-6E7C-4BD0-B7DF-B3720F605C86}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5207985-6E7C-4BD0-B7DF-B3720F605C86}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5207985-6E7C-4BD0-B7DF-B3720F605C86}.Release|Any CPU.Build.0 = Release|Any CPU + {7104A6BF-44E1-4ED9-9233-C3DC29132A91}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7104A6BF-44E1-4ED9-9233-C3DC29132A91}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7104A6BF-44E1-4ED9-9233-C3DC29132A91}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7104A6BF-44E1-4ED9-9233-C3DC29132A91}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -143,6 +170,10 @@ Global {EDD52CC0-7289-4167-A01E-E88B015FC67F} = {90102C4B-EC3B-4279-A6C6-A6CFDFCD4DB4} {454EF272-D967-4668-A20D-AD6B3EE96C1A} = {6B735E6E-1FFB-4C37-8CF6-BD979B4F8D9B} {B1884017-8E25-4A26-8C89-D9D880CFA392} = {9BF466A2-E00F-4F2C-AD23-591E9159AD11} + {10EB18EB-3302-4883-9C3B-083D65134ED6} = {52787DA5-4208-4B9D-8092-31F6C84ECC34} + {1CC08475-C472-424D-BEEE-E686FAE9F490} = {52787DA5-4208-4B9D-8092-31F6C84ECC34} + {D5207985-6E7C-4BD0-B7DF-B3720F605C86} = {52787DA5-4208-4B9D-8092-31F6C84ECC34} + {7104A6BF-44E1-4ED9-9233-C3DC29132A91} = {52787DA5-4208-4B9D-8092-31F6C84ECC34} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2C9E9FF4-E825-47A4-90BE-5499D5EDF3CC} diff --git a/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj b/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj index ef8c3a91..3029a5d1 100644 --- a/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj +++ b/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj @@ -1,7 +1,7 @@  Exe - net8.0 + net8.0;net9.0 CA2007,IDE0073 8368baab-103b-45f6-bfb1-f89a537f4f3c @@ -10,7 +10,7 @@ - + diff --git a/examples/Csharp.ExampleApplication/Hooks/Transformers/ActionUrlXmlTransformer.cs b/examples/Csharp.ExampleApplication/Hooks/Transformers/ActionUrlXmlTransformer.cs new file mode 100644 index 00000000..175f5076 --- /dev/null +++ b/examples/Csharp.ExampleApplication/Hooks/Transformers/ActionUrlXmlTransformer.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; +using System.Xml.XPath; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Hooks.Transformers; + +namespace Csharp.ExampleApplication.Hooks.Transformers +{ + #region class + + public class ActionUrlXmlTransformer : XmlContentTransformerBase + { + protected override bool NeedsXmlTransforming(IPublishableWorkbook ctx) + { + /* + * Returning false prevents TransformAsync from running. + * Implementing this method potentially allows workbooks to migrate without + * loading the file into memory, improving migration speed. + */ + return true; + } + + public override Task TransformAsync(IPublishableWorkbook ctx, XDocument xml, CancellationToken cancel) + { + // Changes to the XML are saved back to the workbook file before publishing. + foreach (var actionLink in xml.XPathSelectElements("//actions/*/link")) + { + actionLink.SetAttributeValue("expression", actionLink.Attribute("expression")?.Value?.Replace("127.0.0.1", "testserver")); + } + + return Task.CompletedTask; + } + } + + #endregion +} diff --git a/examples/Csharp.ExampleApplication/MyMigrationApplication.cs b/examples/Csharp.ExampleApplication/MyMigrationApplication.cs index 0c8346cf..7d38dcff 100644 --- a/examples/Csharp.ExampleApplication/MyMigrationApplication.cs +++ b/examples/Csharp.ExampleApplication/MyMigrationApplication.cs @@ -114,7 +114,7 @@ public async Task StartAsync(CancellationToken cancel) #region UnlicensedUsersFilter-Registration _planBuilder.Filters.Add(); #endregion - + #region SharedCustomViewFilter-Registration _planBuilder.Filters.Add(); #endregion @@ -143,11 +143,15 @@ public async Task StartAsync(CancellationToken cancel) #region StartAtTransformer-Registration _planBuilder.Transformers.Add, ICloudExtractRefreshTask>(); #endregion - + #region CustomViewDefaultUsersTransformer-Registration _planBuilder.Transformers.Add(); #endregion + #region ActionUrlXmlTransformer-Registration + _planBuilder.Transformers.Add(); + #endregion + // Add initialize migration hooks #region SetCustomContext-Registration _planBuilder.Hooks.Add(); @@ -213,7 +217,7 @@ private void PrintResult(MigrationResult result) } } - foreach (var type in ServerToCloudMigrationPipeline.ContentTypes) + foreach (var type in MigrationPipelineContentType.GetMigrationPipelineContentTypes(result.Manifest.PipelineProfile)) { var contentType = type.ContentType; diff --git a/examples/Csharp.ExampleApplication/Program.cs b/examples/Csharp.ExampleApplication/Program.cs index f322db8b..09038e9e 100644 --- a/examples/Csharp.ExampleApplication/Program.cs +++ b/examples/Csharp.ExampleApplication/Program.cs @@ -108,6 +108,10 @@ public static IServiceCollection AddCustomizations(this IServiceCollection servi services.AddScoped(); #endregion + #region ActionUrlXmlTransformer-DI + services.AddScoped(); + #endregion + #region LogMigrationActionsHook-DI services.AddScoped(); #endregion diff --git a/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj b/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj index 38aa6c02..d784d0dd 100644 --- a/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj +++ b/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj @@ -2,13 +2,13 @@ Exe - net8.0 + net8.0;net9.0 CA2007,IDE0073 - + diff --git a/examples/Python.ExampleApplication/Hooks/transformers/action_url_xml_transformer.py b/examples/Python.ExampleApplication/Hooks/transformers/action_url_xml_transformer.py new file mode 100644 index 00000000..771eb1c8 --- /dev/null +++ b/examples/Python.ExampleApplication/Hooks/transformers/action_url_xml_transformer.py @@ -0,0 +1,18 @@ +from xml.etree import ElementTree +from tableau_migration import ( + IPublishableWorkbook, + XmlContentTransformerBase +) + +class ActionUrlXmlTransformer(XmlContentTransformerBase[IPublishableWorkbook]): + + def needs_xml_transforming(self, ctx: IPublishableWorkbook) -> bool: + # Returning false prevents the transform method from running. + # Implementing this method potentially allows workbooks to migrate + # without loading the file into memory, improving migration speed. + return True + + def transform(self, ctx: IPublishableWorkbook, xml: ElementTree.Element) -> None: + # Changes to the XML are saved back to the workbook file before publishing. + for action_link in xml.findall("actions/*/link"): + action_link.set("expression", action_link.get("expression").replace("127.0.0.1", "testserver")) diff --git a/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj b/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj index 6f49b4e6..6dd5b1c6 100644 --- a/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj +++ b/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj @@ -31,6 +31,7 @@ + diff --git a/examples/Python.ExampleApplication/print_result.py b/examples/Python.ExampleApplication/print_result.py index b2608827..696f8b3a 100644 --- a/examples/Python.ExampleApplication/print_result.py +++ b/examples/Python.ExampleApplication/print_result.py @@ -9,10 +9,10 @@ def print_result(result: MigrationResult): """Prints the result of a migration.""" print(f'Result: {result.status}') - for pipeline_content_type in ServerToCloudMigrationPipeline.content_types(): + for pipeline_content_type in ServerToCloudMigrationPipeline.get_content_types(): content_type = pipeline_content_type.content_type - type_entries = [IMigrationManifestEntry(x) for x in result.manifest.entries.for_content_type(content_type)] + type_entries = [IMigrationManifestEntry(x) for x in result.manifest.entries.ForContentType(content_type)] count_total = len(type_entries) diff --git a/examples/Python.ExampleApplication/requirements.txt b/examples/Python.ExampleApplication/requirements.txt index d8f075b1..57444450 100644 --- a/examples/Python.ExampleApplication/requirements.txt +++ b/examples/Python.ExampleApplication/requirements.txt @@ -1,3 +1,3 @@ -configparser==7.0.0 +configparser==7.1.0 tableau_migration python-dotenv==1.0.1 diff --git a/global.json b/global.json index 7227be09..d1c554a8 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.403", + "version": "9.0.102", "rollForward": "latestMajor" } } \ No newline at end of file diff --git a/scripts/Clean-Server.dib b/scripts/Clean-Server.dib deleted file mode 100644 index c9384513..00000000 --- a/scripts/Clean-Server.dib +++ /dev/null @@ -1,205 +0,0 @@ -#!meta - -{"kernelInfo":{"defaultKernelName":"csharp","items":[{"aliases":[],"languageName":"csharp","name":"csharp"}]}} - -#!markdown - -# Clean Server -This notebook will delete all projects, groups, users. - -#!markdown - -## Readme - - - - -This is [polyglot notebook](https://code.visualstudio.com/docs/languages/polyglot). It's the VS Code version of a jupyter notebook. - -The [Polyglot Notebooks extension](https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.dotnet-interactive-vscode) VS Code extension needs to be installed. - -Once that's done. Just open in VS Code, and it turns into a wiki with executable cells. If you run them all in order, then all the projects, groups, users on the configured server will be deleted to get it ready for the next E2E tests. - -If you only want to delete certain content types, then run all the cells through "Sign in" manually, then you can run the cells after that in any order. - -#!markdown - -## Setup - -Because of how C# Interactive and notebooks work, the best way to get the current directory is by calling C# from pwsh. - -Then we `dotnet pack` the solution which creates the nuget package that is loaded in the next cell. - -The version needs to be ever increasing, else a cached nuget package will be used. - -**Note:** -* For me there is some red text about missing python targets. This can be ignored. -* For some reason it doesn't like the version of major.minor.build.revision. It still work, so ignore warnings - -#!pwsh - -#Register-PackageSource -provider NuGet -Location "C:\Users\sfroehlich\Code\migration-sdk\src\Tableau.Migration\bin\Release\" -Name local -Install-Package "Tableau.Migration" -Scope CurrentUser -ProviderName local - -#!pwsh - -# Setup well known directory paths -$currentDir = [System.IO.Directory]::GetCurrentDirectory() -$baseDir = (Get-Item $currentDir).Parent -$releaseDir = Join-Path $baseDir "src/Tableau.Migration/bin/Release" -$nugetDir = "C:\temp\migration-sdk" - -# Create nuget package version -[xml]$buildProps = Get-Content -Path (Join-Path $baseDir "Directory.Build.props") -$packageSuffix = Get-Date -UFormat %s -$packageVersion = $buildProps.Project.PropertyGroup.Version + ".${packageSuffix}" - - -if (-not (Test-Path -Path $nugetDir)) { - New-Item -Path $nugetDir -ItemType Directory -} - - -# Delete previous packages -Remove-Item $nugetDir -Include *.nupkg,*.snupkg - -# Build new packages -cd $baseDir -#dotnet build -c Release -dotnet pack -c Release -p:Version=$packageVersion --version-suffix $packageSuffix --output "C:\temp\migration-sdk" - -#!markdown - -Loads the Tableau.Migration nuget package that was build in the previous step. This also installs dependent nuget packages only used in this notebook. - -#!csharp - -// This will load the required nuget packages. -// If you already ran this once, you must reload the kernel of the notebook, else it will use the already loaded version. - -#i "nuget:C:\Temp\migration-sdk" -#r "nuget:Tableau.Migration," -#r "nuget:Microsoft.Extensions.Logging.Console" -#r "nuget:Microsoft.Extensions.Configuration.Json" - -#!markdown - -## Delete projects, groups, users - -#!markdown - -### Setup and configuration - -This section defines all the namespace the main script needs and sets up the configuration values. - -You must copy the `clean-server-settings.json` to `clean-server-settings.dev.json` and fill it in. - -#!csharp - -using System.Collections.Concurrent; -using System.Threading; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Tableau.Migration; -using Tableau.Migration.Api; -using Tableau.Migration.Content; - -CancellationToken cancel = default; - -var config = new ConfigurationBuilder() - .AddInMemoryCollection(new Dictionary - { - { "Files:RootPath", @"C:\Temp\filestore"}, - { "Network:HeadersLoggingEnabled", "true" }, - { "Network:ContentLoggingEnabled", "true" }, - { "Network:BinaryContentLoggingEnabled", "true" } - }) - // Copy the clean-server-settings.json to clean-server-settings.dev.json and fill in. - .AddJsonFile(System.IO.Path.Combine(System.IO.Directory.GetCurrentDirectory(), "clean-server-settings.dev.json")) - .Build(); - -#!markdown - -### Sign into server - -This must be run before any of the following cells. - -Once signed in, any of the cells below can be run in any order. -Note that the order project, groups, users is the fastest. - -#!csharp - -var serviceCollection = new ServiceCollection() - //.AddLogging(b => b.AddConsole()) - .AddTableauMigrationSdk(config); - -var services = serviceCollection.BuildServiceProvider(); - -var connectionConfig = config.GetSection("ConnectionConfig").Get(); - -var apiClient = services.GetRequiredService() - .Initialize(connectionConfig); - -var signIn = await apiClient.SignInAsync(cancel); - -if(!signIn.Success) -{ - foreach(var e in signIn.Errors) - Console.WriteLine(e); - - // If the above error isn't enough, uncomment the .AddLogging on line 2 -} - - var siteClient = signIn.Value!; - -#!markdown - -Delete all projects - -#!csharp - -var projects = await siteClient.Projects.GetAllAsync(100, cancel); - -foreach(var proj in projects.Value) -{ - Console.WriteLine($"About to delete project: {proj.Name}"); - await siteClient.Projects.DeleteProjectAsync(proj.Id, cancel); -} - -#!markdown - -Delete all groups - -#!csharp - -var groups = await siteClient.Groups.GetAllAsync(100, cancel); - -foreach(var group in groups.Value) -{ - Console.WriteLine($"About to delete group: {group.Name}"); - await siteClient.Groups.DeleteGroupAsync(group.Id, cancel); -} - -#!markdown - -Delete all non-admin Users - -#!csharp - -var users = await siteClient.Users.GetAllAsync(1000, cancel); - -var parallelUsers = new ConcurrentBag(users.Value); - -ParallelOptions parallelOptions = new() -{ - MaxDegreeOfParallelism = 10 -}; - -await Parallel.ForEachAsync(parallelUsers, parallelOptions, async (user, cancel) => { - if(user.AdministratorLevel.Contains("None")) - { - Console.WriteLine($"About to delete user: {user.Name}"); - await siteClient.Users.DeleteUserAsync(user.Id, cancel); - } -}); diff --git a/src/Documentation/Documentation.csproj b/src/Documentation/Documentation.csproj index 37b9c9db..a77064b8 100644 --- a/src/Documentation/Documentation.csproj +++ b/src/Documentation/Documentation.csproj @@ -1,6 +1,6 @@ - net8.0 + net9.0 enable enable False diff --git a/src/Documentation/api-python/index.md b/src/Documentation/api-python/index.md index 55371fa7..d0b12cd6 100644 --- a/src/Documentation/api-python/index.md +++ b/src/Documentation/api-python/index.md @@ -10,7 +10,7 @@ With the Python API, you can: - Provide basic [configuration](~/articles/configuration.md) values to the Migration SDK via the PlanBuilder. - Set configuration options as described in [Configuration](~/articles/configuration.md) with environment variables. -- Configure Python [logging](~/articles/logging.md#python-support). +- Configure Python [logging](~/articles/logging.md?tabs=Python). - Run a migration using python. - Write Python hooks. See [Custom Hooks](~/articles/hooks/index.md) for an overview. diff --git a/src/Documentation/articles/configuration.md b/src/Documentation/articles/configuration.md index 5f7c6f08..6474fae4 100644 --- a/src/Documentation/articles/configuration.md +++ b/src/Documentation/articles/configuration.md @@ -1,86 +1,107 @@ # Configuration -The Migration SDK uses two sources of configuration in two blocks: the [Migration Plan](#migration-plan) that contains configuration for a specific migration run, and [Migration SDK Options](#migration-sdk-options) for configuration that is unlikely to change between migration runs. +The Migration SDK uses two sources of configuration + +1. [Basic Configuration](#basic-configuration) that uses the [Migration Plan](~/articles/terms.md#migration-plan). It contains configuration for a specific migration run. +2. [Advanced Configuration](#advanced-configuration). This configuration that is unlikely to change between migration runs. ![Configuration Blocks](../images/configuration.svg){width=65%} -## Migration Plan +## Basic Configuration -The migration plan is a required input in the migration process. It will define the Source and Destination servers and the hooks executed during the migration. Consider the Migration Plan as the steps the Migration SDK will follow to migrate the information from a given Source Server to a given Destination Server. +The bare minimum Migration SDK configuration is done using a [Migration Plan](~/articles/terms.md#migration-plan). It defines the source, destination, and hooks executed during migration. The easiest way to generate a new Migration Plan is using [`MigrationPlanBuilder`](xref:Tableau.Migration.Engine.MigrationPlanBuilder)([`IMigrationPlanBuilder`](xref:Tableau.Migration.IMigrationPlanBuilder) implementation). Before you [build](#build) a new plan, you need to: -The [`IMigrationPlan`](xref:Tableau.Migration.IMigrationPlan) interface defines the Migration Plan structure. And the easiest path to generate a new Migration Plan is the [`IMigrationPlanBuilder`](xref:Tableau.Migration.IMigrationPlanBuilder) implementation [**`MigrationPlanBuilder`**](xref:Tableau.Migration.Engine.MigrationPlanBuilder). For that, it is needed a few steps before [building](#build) a new plan: +- Define [Source](#source). +- Define [Destination](#destination). +- Define the [Migration Type](#migration-type). +- Customize with [hooks](#add-hooks-optional) (optional). -- [Define the required Source server](#source). -- [Define the required Destination server](#destination). -- [Define the required Migration Type](#migration-type). -- [Add supplementary hooks](#add-hooks). +> [!IMPORTANT] +> Personal access tokens (PATs) are long-lived authentication tokens that allow you to sign in to the Tableau REST API without requiring hard-coded credentials or interactive sign-in. +> Best practices +> +> - Revoke and generate a new PAT every day to keep your server secure. +> - Access tokens should not be stored in plain text in application configuration files. Instead, use secure alternatives, such as encryption or a secrets management system. +> - If the source and destination sites are on the same server, use separate PATs. ### Source -*Optional/Required:* **Required**. - -*Description:* The method [`MigrationPlanBuilder.FromSourceTableauServer`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_FromSourceTableauServer_System_Uri_System_String_System_String_System_String_System_Boolean_) will define the source server by instantiating a new [`TableauSiteConnectionConfiguration`](xref:Tableau.Migration.Api.TableauSiteConnectionConfiguration) with the following parameters: - -- **serverUrl:** Required. -- **siteContentUrl:** Optional. -- **accessTokenName:** Required. -- **accessToken:** Required. +The method [`MigrationPlanBuilder.FromSourceTableauServer`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_FromSourceTableauServer_System_Uri_System_String_System_String_System_String_System_Boolean_) defines the source server by instantiating a new [`TableauSiteConnectionConfiguration`](xref:Tableau.Migration.Api.TableauSiteConnectionConfiguration) with the following parameters: -> [!Important] -> Personal access tokens (PATs) are long-lived authentication tokens that allow you to sign in to the Tableau REST API without requiring hard-coded credentials or interactive sign-in. Revoke and generate a new PAT every day to keep your server secure. Access tokens should not be stored in plain text in application configuration files, and should instead use secure alternatives such as encryption or a secrets management system. If the source and destination sites are on the same server, separate PATs should be used. +- serverUrl +- siteContentUrl (optional) +- accessTokenName +- accessToken ### Destination -*Optional/Required:* **Required**. +The method [`MigrationPlanBuilder.ToDestinationTableauCloud`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_ToDestinationTableauCloud_System_Uri_System_String_System_String_System_String_System_Boolean_) defines the destination server by instantiating a new [`TableauSiteConnectionConfiguration`](xref:Tableau.Migration.Api.TableauSiteConnectionConfiguration) with the following parameters: -*Description:* The method [`MigrationPlanBuilder.ToDestinationTableauCloud`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_ToDestinationTableauCloud_System_Uri_System_String_System_String_System_String_System_Boolean_) will define the destination server by instantiating a new [`TableauSiteConnectionConfiguration`](xref:Tableau.Migration.Api.TableauSiteConnectionConfiguration) with the following parameters: - -- **podUrl:** Required. -- **siteContentUrl:** Required. This is the site name on Tableau Cloud. -- **accessTokenName:** Required. -- **accessToken:** Required. - -> [!Important] -> Personal access tokens (PATs) are long-lived authentication tokens that allow you to sign in to the Tableau REST API without requiring hard-coded credentials or interactive signin. Revoke and generate a new PAT every day to keep your server secure. Access tokens should not be stored in plain text in application configuration files, and should instead use secure alternatives such as encryption or a secrets management system. If the source and destination sites are on the same server, separate PATs should be used. +- podUrl +- siteContentUrl: This is the site name on Tableau Cloud. +- accessTokenName +- accessToken ### Migration Type -*Optional/Required:* **Required**. +The method [`MigrationPlanBuilder.ForServerToCloud`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_ForServerToCloud) defines the migration type and load all default hooks for a **Server to Cloud** migration. -*Description:* The method [`MigrationPlanBuilder.ForServerToCloud`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_ForServerToCloud) will define the migration type and load all default hooks for a **Server to Cloud** migration. +### Add Hooks (optional) -### Add Hooks +The Plan Builder exposes the properties [`MigrationPlanBuilder.Hooks`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Hooks), [`MigrationPlanBuilder.Filters`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Filters), [`MigrationPlanBuilder.Mappings`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Mappings), and [`MigrationPlanBuilder.Transformers`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Transformers). With these properties, you can customize your migration plan. See [Custom Hooks article](hooks/custom_hooks.md) for more details. -*Optional/Required:* **Optional**. +### Build -*Description:* The Plan Builder exposes the properties [`MigrationPlanBuilder.Hooks`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Hooks), [`MigrationPlanBuilder.Filters`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Filters), [`MigrationPlanBuilder.Mappings`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Mappings), and [`MigrationPlanBuilder.Transformers`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Transformers). With these properties, it is possible to adjust a given migration plan for specific scenarios. For more details, see the [Custom Hooks article](hooks/custom_hooks.md). + The method [`MigrationPlanBuilder.Build`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Build) generates a Migration Plan ready to be used as an input to a migration process. -### Build +## Advanced configuration -*Optional/Required:* **Required**. +[`MigrationSdkOptions`](xref:Tableau.Migration.Config.MigrationSdkOptions) is the configuration class the Migration SDK uses internally to process a migration. It contains adjustable properties that change some engine behaviors. These properties are useful tools to troubleshoot and tune a migration process. -*Description:* The method [`MigrationPlanBuilder.Build`](xref:Tableau.Migration.Engine.MigrationPlanBuilder#Tableau_Migration_Engine_MigrationPlanBuilder_Build) will generate a Migration Plan ready to be used as an input to a migration process. +> [!NOTE] +> Unless specified otherwise, all configuration options are dynamically applied during the migration. -## Migration SDK Options +### [Python](#tab/Python) -[`MigrationSdkOptions`](xref:Tableau.Migration.Config.MigrationSdkOptions) is the configuration class the Migration SDK uses internally to process a migration. It contains adjustable properties that change some engine behaviors. These properties are useful tools to troubleshoot and tune a migration process. Start with this class and others in the [Config](xref:Tableau.Migration.Config) section for more details. +Configuration values are set via environment variables. The `:` delimiter doesn't work with environment variable hierarchical keys on all platforms. For example, the `:` delimiter is not supported by Bash. The double underscore (`__`), which is supported on all platforms, automatically replaces any `:` delimiters in environment variables. All configuration environment variables start with `MigrationSDK__`. -When writing a C# application, we recommend using a [.NET Generic Host](https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host?tabs=appbuilder) to initialize the application. This will enable setting configuration values via `appsettings.json` which can be passed into `userOptions` in [`.AddTableauMigrationSdk`](xref:Tableau.Migration.IServiceCollectionExtensions#Tableau_Migration_IServiceCollectionExtensions_AddTableauMigrationSdk_Microsoft_Extensions_DependencyInjection_IServiceCollection_Microsoft_Extensions_Configuration_IConfiguration_). See [.NET getting started examples](~/api-csharp/index.md) for more info. +### [C#](#tab/CSharp) -When writing a python application, configuration values are set via environment variables. The `:` delimiter doesn't work with environment variable hierarchical keys on all platforms. For example, the `:` delimiter is not supported by Bash. The double underscore (`__`), which is supported on all platforms, automatically replaces any `:` delimiters in environment variables. All configuration environment variables start with `MigrationSDK__`. +We recommend using a [.NET Generic Host](https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host?tabs=appbuilder) to initialize the application. This will enable setting configuration values via `appsettings.json` which can be passed into `userOptions` in [`.AddTableauMigrationSdk`](xref:Tableau.Migration.IServiceCollectionExtensions#Tableau_Migration_IServiceCollectionExtensions_AddTableauMigrationSdk_Microsoft_Extensions_DependencyInjection_IServiceCollection_Microsoft_Extensions_Configuration_IConfiguration_). See [.NET getting started examples](~/api-csharp/index.md) for more info. -### ContentTypes +--- -*Reference:* [`MigrationSdkOptions.ContentTypesOptions`](xref:Tableau.Migration.Config.ContentTypesOptions). +### [ContentTypes](xref:Tableau.Migration.Config.ContentTypesOptions) This is an array of [`MigrationSdkOptions.ContentTypesOptions`](xref:Tableau.Migration.Config.ContentTypesOptions). Each array object corresponds to settings for a single content type. > [!IMPORTANT] -> The [type](xref:Tableau.Migration.Config.ContentTypesOptions.Type) values are case-insensitive. -> Duplicate [type](xref:Tableau.Migration.Config.ContentTypesOptions.Type) key values will result in an exception. +> The [type](xref:Tableau.Migration.Config.ContentTypesOptions.Type) values are case-insensitive. Duplicate [type](xref:Tableau.Migration.Config.ContentTypesOptions.Type) key values will result in an exception. + +### [Python](#tab/Python) + +#### Python Environment Variables + +- `MigrationSDK__ContentTypes______Type`. +- `MigrationSDK__ContentTypes______BatchSize`. -In the following `json` example config file, -- A `BatchSize` of `201` is applied to the content type `User`. +**Example:** To set the `User` `BatchSize` to `201` and `Project` BatchSize to `203`, you would set environment variables as follows. Note the array indexes. They tie the setting values together in the Migration SDK. + +```bash +# User BatchSize is 201 +MigrationSDK__ContentTypes__0__Type = User +MigrationSDK__ContentTypes__0__BatchSize = 201 + +# Project BatchSize is 203 +MigrationSDK__ContentTypes__1__Type = Project +MigrationSDK__ContentTypes__1__BatchSize = 203 +``` + +### [C#](#tab/CSharp) + +In the following `json` example config file, + +- A `BatchSize` of `201` is applied to the content type `User`. - A `BatchSize` of `203` for `Project`. - A `BatchSize` of `200` for `ServerExtractRefreshTask`. @@ -106,396 +127,54 @@ In the following `json` example config file, ``` -*Python Environment Variables:* - -- `MigrationSDK__ContentTypes______Type`. -- `MigrationSDK__ContentTypes______BatchSize`. - -Here is an example of environment variables you would set. This is equivalent to the previous `json` example. Note the array indexes. They tie the setting values together in the Migration SDK. - -```bash -MigrationSDK__ContentTypes__0__Type = User -MigrationSDK__ContentTypes__0__BatchSize = 201 -MigrationSDK__ContentTypes__1__Type = Project -MigrationSDK__ContentTypes__1__BatchSize = 203 -``` - -The following sections describe each setting. They should always be set per content type as described previously. If a setting below is not set for a content type, the Migration SDK falls back to the default value. - -#### ContentTypes.Type - -*Reference:* [`MigrationSdkOptions.ContentTypes.Type`](xref:Tableau.Migration.Config.ContentTypesOptions.Type). - -*Default:* blank string. - -*Reload on Edit?:* **Yes**. The update will apply next time the Migration SDK requests a list of objects. - -*Description:* For each array object, the [type](xref:Tableau.Migration.Config.ContentTypesOptions.Type) key determines which content type the settings apply to. Only supported content types will be considered and all others will be ignored. This key comes from the interface for the content type. This is determined by [MigrationPipelineContentType.GetConfigKey()](xref:Tableau.Migration.Engine.Pipelines.MigrationPipelineContentType.GetConfigKey). For example, the key for [IUser](xref:Tableau.Migration.Content.IUser) is `User`. Content type [type](xref:Tableau.Migration.Config.ContentTypesOptions.Type) values are case insensitive. - -#### ContentTypes.BatchSize - -*Reference:* [`MigrationSdkOptions.ContentTypes.BatchSize`](xref:Tableau.Migration.Config.ContentTypesOptions.BatchSize). - -*Default:* [`MigrationSdkOptions.ContentTypes.Defaults.BATCH_SIZE`](xref:Tableau.Migration.Config.ContentTypesOptions.Defaults.BATCH_SIZE). - -*Reload on Edit?:* **Yes**. The update will apply next time the Migration SDK requests a list of objects. - -*Description:* The Migration SDK uses the **BatchSize** property to define the page size of each List Request. For more details, see the [Tableau REST API Paginating Results documentation](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_paging.htm). - -#### ContentTypes.BatchPublishingEnabled - -*Reference:* [`MigrationSdkOptions.ContentTypes.BatchPublishingEnabled`](xref:Tableau.Migration.Config.ContentTypesOptions.BatchPublishingEnabled). - -*Default:* [`MigrationSdkOptions.ContentTypes.Defaults.BATCH_PUBLISHING_ENABLED`](xref:Tableau.Migration.Config.ContentTypesOptions.Defaults.BATCH_PUBLISHING_ENABLED). - -*Reload on Edit?:* **Yes**. The update will apply next time the Migration SDK starts migrating a given content type. - -*Description:* The Migration SDK uses the **BatchPublishingEnabled** property to select the mode it will publish a given content type. Disabled by default, with this configuration, the SDK will publish the content by using individual REST API calls for each item. When this option is enabled, it is possible to publish content in a batch of items (just for some supported content types). +--- -Supported Content Types: +The following table describes each setting. They should always be set per content type as described previously. If a setting below is not set for a content type, the Migration SDK falls back to the default value. -- [User](xref:Tableau.Migration.Content.IUser) by using the method [Import Users to Site from CSV](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#import_users_to_site_from_csv); +[!include[](~/includes/configuration/sdk_opts_content_types.html)] -### MigrationParallelism +### [MigrationParallelism](xref:Tableau.Migration.Config.MigrationSdkOptions#Tableau_Migration_Config_MigrationSdkOptions_MigrationParallelism) -*Reference:* [`MigrationSdkOptions.MigrationParallelism`](xref:Tableau.Migration.Config.MigrationSdkOptions#Tableau_Migration_Config_MigrationSdkOptions_MigrationParallelism). - -*Default:* [`MigrationSdkOptions.Defaults.MIGRATION_PARALLELISM`](xref:Tableau.Migration.Config.MigrationSdkOptions.Defaults#Tableau_Migration_Config_MigrationSdkOptions_Defaults_MIGRATION_PARALLELISM). +This setting defines the number of parallel tasks migrating the same type of content simultaneously. You can tune the Migration SDK processing time with this configuration. +*Default:* 10 *Python Environment Variable:* `MigrationSDK__MigrationParallelism` -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK publishes a new batch. - -*Description:* The Migration SDK uses [two methods](hooks/index.md#hook-execution-flow) to publish the content to a destination server: the **bulk process**, where a single call to the API will push multiple items to the server, and the **individual process**, where it publishes a single item with a single call to the API. This configuration only applies to the **individual process**. The SDK uses the **MigrationParallelism** property to define the number of parallel tasks migrating the same type of content simultaneously. It is possible to tune the Migration SDK processing time with this configuration. > [!WARNING] > There are [concurrency limits in REST APIs on Tableau Cloud](https://kb.tableau.com/articles/issue/concurrency-limits-in-rest-apis-on-tableau-cloud). The current default configuration is the balance between performance without blocking too many resources to the migration process. -### Files.DisableFileEncryption - -*Reference:* [`FileOptions.DisableFileEncryption`](xref:Tableau.Migration.Config.FileOptions#Tableau_Migration_Config_FileOptions_DisableFileEncryption). - -*Default:* [`FileOptions.Defaults.DISABLE_FILE_ENCRYPTION`](xref:Tableau.Migration.Config.FileOptions.Defaults#Tableau_Migration_Config_FileOptions_Defaults_DISABLE_FILE_ENCRYPTION). - -*Python Environment Variable:* `MigrationSDK__Files__DisableFileEncryption` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK executes a migration plan. - -*Description:* As part of the migration process, the Migration SDK has to adjust existing references for file-based content types like Workbooks and Data Sources. The SDK has to download and temporarily store the content in the migration machine to be able to read and edit these files. The Migration SDK uses the **DisableFileEncryption** property to define whether it will encrypt the temporary file. -> [!CAUTION] -> Do not disable file encryption when migrating production content. - -### Files.RootPath - -*Reference:* [`FileOptions.RootPath`](xref:Tableau.Migration.Config.FileOptions#Tableau_Migration_Config_FileOptions_RootPath). - -*Default:* [`FileOptions.Defaults.ROOT_PATH`](xref:Tableau.Migration.Config.FileOptions.Defaults#Tableau_Migration_Config_FileOptions_Defaults_ROOT_PATH). - -*Python Environment Variable:* `MigrationSDK__Files__RootPath` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK executes a migration plan. - -*Description:* As part of the migration process, the Migration SDK has to adjust existing references for file-based content types like Workbooks and Data Sources. The SDK has to download and temporarily store the content in the migration machine to be able to read and edit these files. The Migration SDK uses the **RootPath** property to define the location where it will store the temporary files. - -### Network.FileChunkSizeKB - -*Reference:* [`NetworkOptions.FileChunkSizeKB`](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_FileChunkSizeKB). - -*Default:* [`NetworkOptions.Defaults.FILE_CHUNK_SIZE_KB`](xref:Tableau.Migration.Config.NetworkOptions.Defaults#Tableau_Migration_Config_NetworkOptions_Defaults_FILE_CHUNK_SIZE_KB). - -*Python Environment Variable:* `MigrationSDK__Network__FileChunkSizeKB` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK publishes a new file. - -*Description:* As part of the migration process, the Migration SDK has to publish file-based content types like Workbooks and Data Sources. Some of these files are very large. The Migration SDK uses the **FileChunkSizeKB** property to split these files into smaller pieces, making the publishing process more reliable. For more details, see the [Tableau REST API Publishing Resources documentation](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_publish.htm). - -### Network.HeadersLoggingEnabled - -*Reference:* [`NetworkOptions.HeadersLoggingEnabled`](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_HeadersLoggingEnabled). - -*Default:* [`NetworkOptions.Defaults.LOG_HEADERS_ENABLED`](xref:Tableau.Migration.Config.NetworkOptions.Defaults#Tableau_Migration_Config_NetworkOptions_Defaults_LOG_HEADERS_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__HeadersLoggingEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK logs a new HTTP request. - -*Description:* See the [logging article](logging.md) for more details. - -### Network.ContentLoggingEnabled - -*Reference:* [`NetworkOptions.ContentLoggingEnabled`](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_ContentLoggingEnabled). - -*Default:* [`NetworkOptions.Defaults.LOG_CONTENT_ENABLED`](xref:Tableau.Migration.Config.NetworkOptions.Defaults#Tableau_Migration_Config_NetworkOptions_Defaults_LOG_CONTENT_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__ContentLoggingEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK logs a new HTTP request. - -*Description:* See the [logging article](logging.md) for more details. - -### Network.BinaryContentLoggingEnabled - -*Reference:* [`NetworkOptions.BinaryContentLoggingEnabled`](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_BinaryContentLoggingEnabled). - -*Default:* [`NetworkOptions.Defaults.LOG_BINARY_CONTENT_ENABLED`](xref:Tableau.Migration.Config.NetworkOptions.Defaults#Tableau_Migration_Config_NetworkOptions_Defaults_LOG_BINARY_CONTENT_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__BinaryContentLoggingEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK logs a new HTTP request. - -*Description:* See the [logging article](logging.md) for more details. - -### Network.ExceptionsLoggingEnabled - -*Reference:* [`NetworkOptions.ExceptionsLoggingEnabled`](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_ExceptionsLoggingEnabled). - -*Default:* [`NetworkOptions.Defaults.LOG_EXCEPTIONS_ENABLED`](xref:Tableau.Migration.Config.NetworkOptions.Defaults#Tableau_Migration_Config_NetworkOptions_Defaults_LOG_EXCEPTIONS_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__ExceptionsLoggingEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK logs a new HTTP request. - -*Description:* See the [logging article](logging.md) for more details. - -### Network.Resilience.RetryEnabled - -*Reference:* [`ResilienceOptions.RetryEnabled`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_RetryEnabled). - -*Default:* [`ResilienceOptions.Defaults.RETRY_ENABLED`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_RETRY_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__RetryEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **RetryEnabled** property to define whether it will retry failed requests. - -### Network.Resilience.RetryIntervals +### [File](xref:Tableau.Migration.Config.FileOptions) -*Reference:* [`ResilienceOptions.RetryIntervals`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_RetryIntervals). +This section contains options related to file storage. -*Default:* [`ResilienceOptions.Defaults.RETRY_INTERVALS`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_RETRY_INTERVALS). +[!include[](~/includes/configuration/sdk_opts_files.html)] -*Python Environment Variable:* **Not Supported** +### [Network](xref:Tableau.Migration.Config.NetworkOptions) -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. +This configuration section contains network-related options. -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **RetryIntervals** property to define the number of retries and the interval between each retry. - -### Network.Resilience.RetryOverrideResponseCodes - -*Reference:* [`ResilienceOptions.RetryOverrideResponseCodes`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_RetryOverrideResponseCodes). - -*Default:* [`ResilienceOptions.Defaults.RETRY_OVERRIDE_RESPONSE_CODES`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_RETRY_OVERRIDE_RESPONSE_CODES). - -*Python Environment Variable:* **Not Supported** - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **RetryOverrideResponseCodes** property to override the default list of error status codes for retries with a specific list of status codes. - -### Network.Resilience.ConcurrentRequestsLimitEnabled - -*Reference:* [`ResilienceOptions.ConcurrentRequestsLimitEnabled`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_ConcurrentRequestsLimitEnabled). - -*Default:* [`ResilienceOptions.Defaults.CONCURRENT_REQUESTS_LIMIT_ENABLED`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_CONCURRENT_REQUESTS_LIMIT_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__ConcurrentRequestsLimitEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **ConcurrentRequestsLimitEnabled** property to define whether it will limit concurrent requests. - -### Network.Resilience.MaxConcurrentRequests - -*Reference:* [`ResilienceOptions.MaxConcurrentRequests`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_MaxConcurrentRequests). - -*Default:* [`ResilienceOptions.Defaults.MAX_CONCURRENT_REQUESTS`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_MAX_CONCURRENT_REQUESTS). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__MaxConcurrentRequests` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **MaxConcurrentRequests** property to define the maximum quantity of concurrent API requests. - -### Network.Resilience.ConcurrentWaitingRequestsOnQueue - -*Reference:* [`ResilienceOptions.ConcurrentWaitingRequestsOnQueue`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_ConcurrentWaitingRequestsOnQueue). - -*Default:* [`ResilienceOptions.Defaults.CONCURRENT_WAITING_REQUESTS_QUEUE`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_CONCURRENT_WAITING_REQUESTS_QUEUE). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__ConcurrentWaitingRequestsOnQueue` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **ConcurrentWaitingRequestsOnQueue** property to define the quantity of concurrent API requests waiting on queue. - -### Network.Resilience.ClientThrottleEnabled - -*Reference:* [`ResilienceOptions.ClientThrottleEnabled`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_ClientThrottleEnabled). - -*Default:* [`ResilienceOptions.Defaults.CLIENT_THROTTLE_ENABLED`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_CLIENT_THROTTLE_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__ClientThrottleEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **ClientThrottleEnabled** property to define whether it will limit requests to a given endpoint on the client side. - -### Network.Resilience.MaxReadRequests - -*Reference:* [`ResilienceOptions.MaxReadRequests`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_MaxReadRequests). - -*Default:* [`ResilienceOptions.Defaults.MAX_READ_REQUESTS`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_MAX_READ_REQUESTS). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__MaxReadRequests` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **MaxReadRequests** property to define the maximum quantity of GET requests on the client side. - -### Network.Resilience.MaxReadRequestsInterval - -*Reference:* [`ResilienceOptions.MaxReadRequestsInterval`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_MaxReadRequestsInterval). - -*Default:* [`ResilienceOptions.Defaults.MAX_READ_REQUESTS_INTERVAL`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_MAX_READ_REQUESTS_INTERVAL). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__MaxReadRequestsInterval` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **MaxReadRequestsInterval** property to define the interval for the limit of GET requests on the client side. - -### Network.Resilience.MaxPublishRequests - -*Reference:* [`ResilienceOptions.MaxPublishRequests`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_MaxPublishRequests). - -*Default:* [`ResilienceOptions.Defaults.MAX_PUBLISH_REQUESTS`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_MAX_PUBLISH_REQUESTS). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__MaxPublishRequests` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **MaxPublishRequests** property to define the maximum quantity of non-GET requests on the client side. - -### Network.Resilience.MaxPublishRequestsInterval - -*Reference:* [`ResilienceOptions.MaxPublishRequestsInterval`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_MaxPublishRequestsInterval). - -*Default:* [`ResilienceOptions.Defaults.MAX_PUBLISH_REQUESTS_INTERVAL`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_MAX_PUBLISH_REQUESTS_INTERVAL). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__MaxPublishRequestsInterval` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **MaxPublishRequestsInterval** property to define the interval for the limit of non-GET requests on the client side. - -### Network.Resilience.ServerThrottleEnabled - -*Reference:* [`ResilienceOptions.ServerThrottleEnabled`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_ServerThrottleEnabled). - -*Default:* [`ResilienceOptions.Defaults.SERVER_THROTTLE_ENABLED`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_SERVER_THROTTLE_ENABLED). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__ServerThrottleEnabled` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **ServerThrottleEnabled** property to define whether it will retry requests throttled on the server. - -### Network.Resilience.ServerThrottleLimitRetries - -*Reference:* [`ResilienceOptions.ServerThrottleLimitRetries`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_ServerThrottleLimitRetries). - -*Default:* [`ResilienceOptions.Defaults.SERVER_THROTTLE_LIMIT_RETRIES`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_SERVER_THROTTLE_LIMIT_RETRIES). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__ServerThrottleLimitRetries` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **ServerThrottleLimitRetries** property to define whether it will have a limit of retries to a throttled request. - -### Network.Resilience.ServerThrottleRetryIntervals - -*Reference:* [`ResilienceOptions.ServerThrottleRetryIntervals`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_ServerThrottleRetryIntervals). - -*Default:* [`ResilienceOptions.Defaults.SERVER_THROTTLE_RETRY_INTERVALS`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_SERVER_THROTTLE_RETRY_INTERVALS). - -*Python Environment Variable:* **Not Supported** - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **ServerThrottleRetryIntervals** property to define the interval between each retry for throttled requests without the 'Retry-After' header. If `ServerThrottleLimitRetries` is enabled, this configuration defines the maximum number of retries. Otherwise, the subsequent retries use the last interval value. - -### Network.Resilience.PerRequestTimeout - -*Reference:* [`ResilienceOptions.PerRequestTimeout`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_PerRequestTimeout). - -*Default:* [`ResilienceOptions.Defaults.REQUEST_TIMEOUT`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_REQUEST_TIMEOUT). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__PerRequestTimeout` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **PerRequestTimeout** property to define the maximum duration of non-FileTransfer requests. - -### Network.Resilience.PerFileTransferRequestTimeout - -*Reference:* [`ResilienceOptions.PerFileTransferRequestTimeout`](xref:Tableau.Migration.Config.ResilienceOptions#Tableau_Migration_Config_ResilienceOptions_PerFileTransferRequestTimeout). - -*Default:* [`ResilienceOptions.Defaults.FILE_TRANSFER_REQUEST_TIMEOUT`](xref:Tableau.Migration.Config.ResilienceOptions.Defaults#Tableau_Migration_Config_ResilienceOptions_Defaults_FILE_TRANSFER_REQUEST_TIMEOUT). - -*Python Environment Variable:* `MigrationSDK__Network__Resilience__PerFileTransferRequestTimeout` - -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK makes a new HTTP request. - -*Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **PerFileTransferRequestTimeout** property to define the maximum duration of FileTransfer requests. - - -### Network.UserAgentComment - -*Reference:* [`NetworkOptions.UserAgentComment`](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_UserAgentComment). - -*Default:* [`NetworkOptions.Defaults.USER_AGENT_COMMENT`](xref:Tableau.Migration.Config.NetworkOptions.Defaults#Tableau_Migration_Config_NetworkOptions_Defaults_USER_AGENT_COMMENT). - -*Python Environment Variable:* `MigrationSDK__Network__UserAgentComment` - -*Reload on Edit?:* **No**. Any changes to this configuration will reflect on the next time the application starts. - -*Description:* The Migration SDK appends the **UserAgentComment** property to the User-Agent header in all HTTP requests. This property is only used to assist in server-side debugging and it not typically set. - - -### DefaultPermissionsContentTypes.UrlSegments - -*Reference:* [`DefaultPermissionsContentTypeOptions.UrlSegments`](xref:Tableau.Migration.Config.DefaultPermissionsContentTypeOptions#Tableau_Migration_Config_DefaultPermissionsContentTypeOptions_UrlSegments). - -*Default:* [`DefaultPermissionsContentTypeUrlSegments`](xref:Tableau.Migration.Content.Permissions.DefaultPermissionsContentTypeUrlSegments). - -*Python Environment Variable:* **Not Supported** - -*Reload on Edit?:* **No**. Any changes to this configuration will reflect on the next time the application starts. - -*Description:* The SDK uses the **UrlSegments** property as a list of types of default permissions of given project. For more details, see the [Query Default Permissions documentation](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_permissions.htm#query_default_permissions). - -### Jobs.JobPollRate +> [!IMPORTANT] +> `NetworkOptions.UserAgentComment` is not dynamically applied. It takes effect when you restart your application. -*Reference:* [`JobOptions.JobPollRate`](xref:Tableau.Migration.Config.JobOptions#Tableau_Migration_Config_JobOptions_JobPollRate). +[!include[](~/includes/configuration/sdk_opts_network.html)] -*Default:* [`JobOptions.Defaults.JOB_POLL_RATE`](xref:Tableau.Migration.Config.JobOptions.Defaults#Tableau_Migration_Config_JobOptions_Defaults_JOB_POLL_RATE). +#### [Resilience](xref:Tableau.Migration.Config.ResilienceOptions) -*Python Environment Variable:* `MigrationSDK__Jobs__JobPollRate` +The `Resilience` sub-section deals with the resilience and transient-fault layer. See [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) for more details. -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK delays the processing status recheck. +[!include[](~/includes/configuration/sdk_opts_network_res.html)] -*Description:* The Migration SDK uses [two methods](hooks/index.md#hook-execution-flow) to publish the content to a destination server: the **bulk process**, where a single call to the API will push multiple items to the server, and the **individual process**, where it publishes a single item with a single call to the API. This configuration only applies to the **bulk process**. After publishing a batch, the API will return a Job ID. With it, the SDK can call another API to see the job processing status. The SDK uses the **JobPollRate** property to define the interval it will wait to recheck processing status. For more details, see the [Tableau REST API Query Job documentation](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job). -> [!WARNING] -> There is [a limit for querying job status on Tableau Cloud](https://help.tableau.com/current/online/en-us/to_site_capacity.htm#jobs-initiated-by-command-line-and-api-calls). The current default configuration is the balance between performance without blocking too many resources to the migration process. +### [DefaultPermissionsContentType](xref:Tableau.Migration.Config.DefaultPermissionsContentTypeOptions) -### Jobs.JobTimeout +[!include[](~/includes/configuration/sdk_opts_def_perm.html)] -*Reference:* [`JobOptions.JobTimeout`](xref:Tableau.Migration.Config.JobOptions#Tableau_Migration_Config_JobOptions_JobTimeout). +### [Job](xref:Tableau.Migration.Config.JobOptions) -*Default:* [`JobOptions.Defaults.JOB_TIMEOUT`](xref:Tableau.Migration.Config.JobOptions.Defaults#Tableau_Migration_Config_JobOptions_Defaults_JOB_TIMEOUT). +The Migration SDK uses [two methods](hooks/index.md#hook-execution-flow) to publish the content to a destination server: -*Python Environment Variable:* `MigrationSDK__Jobs__JobTimeout` +1. 'Bulk process': A single REST API call for multiple items. +2. 'Individual process': One REST API call per item. -*Reload on Edit?:* **Yes**. The update will apply the next time the Migration SDK validates the total time it has waited for the job to complete. +This configuration only applies to the 'Bulk process'. Each batch publish REST API call returns a Job ID (see the [Tableau REST API Query Job](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job) for details). The SDK uses this ID to determine job status. The following table describes the related settings. -*Description:* The Migration SDK uses [two methods](hooks/index.md#hook-execution-flow) to publish the content to a destination server: the **bulk process**, where a single call to the API will push multiple items to the server, and the **individual process**, where it publishes a single item with a single call to the API. This configuration only applies to the **bulk process**. The SDK uses the **JobTimeout** property to define the maximum interval it will wait for a job to complete. For more details, see the [Tableau REST API Query Job documentation](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#query_job). +[!include[](~/includes/configuration/sdk_opts_jobs.html)] diff --git a/src/Documentation/articles/hooks/index.md b/src/Documentation/articles/hooks/index.md index d3d6eeed..c8dfa121 100644 --- a/src/Documentation/articles/hooks/index.md +++ b/src/Documentation/articles/hooks/index.md @@ -6,12 +6,6 @@ The Migration SDK has [default hooks](#default-hooks) that run for every migrati > [!NOTE] > You can also write [custom hooks](custom_hooks.md) to fit your specific use cases. -## Terminology - -- Content Type: The type of Tableau content. Examples are user, project, workbook. -- Content Item: Items of a certain content type. -- Content Migration Action/Content Action: The action that migrates Content Items of a certain Content Type. - ## Types of Hooks The Migration SDK has the following types of hooks, categorized broadly based on when they run. diff --git a/src/Documentation/articles/logging.md b/src/Documentation/articles/logging.md index 123e7553..91e8c441 100644 --- a/src/Documentation/articles/logging.md +++ b/src/Documentation/articles/logging.md @@ -21,7 +21,35 @@ As part of the included tracings, it is possible to [configure](configuration.md - [Network.BinaryContentLoggingEnabled](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_BinaryContentLoggingEnabled): Indicates whether the SDK logs request/response binary (not textual) content. The default value is disabled. - [Network.ExceptionsLoggingEnabled](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_ExceptionsLoggingEnabled): Indicates whether the SDK logs network exceptions. The default value is disabled. -## C# Support +## [Python Support](#tab/Python) + +The Migration SDK supports logging with built-in providers like the one described in [Python Logging docs](https://docs.python.org/3/howto/logging.html). + +### SDK default handler + +The SDK adds a StreamHandler to the root logger by executing the following command: + +```Python +logging.basicConfig( + format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level = logging.INFO) +``` + +### Overriding default handler configuration + +To override the default configuration, set the `force` parameter to `True`. + +```Python +logging.basicConfig( + force = True, + format = '%(asctime)s|%(levelname)s|%(name)s -\t%(message)s', + level = logging.WARNING) +``` + +> [!Note] +> See [Logging Configuration](https://docs.python.org/3/library/logging.config.html) for advanced configuration guidance. + +## [C# Support](#tab/CSharp) The Migration SDK supports logging with built-in or third-party providers such as the ones described in [.NET Logging Providers](https://learn.microsoft.com/en-us/dotnet/core/extensions/logging-providers). Refer to that article for guidance in your use case. Some basic examples are below. @@ -53,30 +81,4 @@ services > [!Note] > See [LoggingServiceCollectionExtensions.AddLogging Method](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.loggingservicecollectionextensions.addlogging) for guidance on how to configure your logging provider. -## Python Support - -The Migration SDK supports logging with built-in providers like the one described in [Python Logging docs](https://docs.python.org/3/howto/logging.html). - -### SDK default handler - -The SDK adds a StreamHandler to the root logger by executing the following command: - -```Python -logging.basicConfig( - format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s', - level = logging.INFO) -``` - -### Overriding default handler configuration - -To override the default configuration, the parameter `force` must be set to `True`. - -```Python -logging.basicConfig( - force = True, - format = '%(asctime)s|%(levelname)s|%(name)s -\t%(message)s', - level = logging.WARNING) -``` - -> [!Note] -> See [Logging Configuration](https://docs.python.org/3/library/logging.config.html) for advanced configuration guidance. +--- diff --git a/src/Documentation/articles/terms.md b/src/Documentation/articles/terms.md new file mode 100644 index 00000000..c28ec958 --- /dev/null +++ b/src/Documentation/articles/terms.md @@ -0,0 +1,46 @@ + +# SDK Terminology + +## Content Type + +Content types are the various types of content that reside on Tableau Server or Tableau Cloud. For details about which content types the Migration SDK supports, see [Supported Content Types](https://help.tableau.com/current/api/migration_sdk/en-us/docs/supported_content_types.html). For unsupported content types, see [Data not supported by the Migration SDK](https://help.tableau.com/current/api/migration_sdk/en-us/docs/planning.html#data-not-supported-by-the-migration-sdk). + +## Content Item + + Item of a certain content type. + +## Content Migration Action or Content Action + +The action that migrates Content Items of a certain Content Type. + +## Migration Plan + +A migration plan describes how a migration should be done and what customizations must be done inflight. The [`IMigrationPlan`](xref:Tableau.Migration.IMigrationPlan) interface defines the Migration Plan structure. See [Basic Configuration](~/articles/configuration.md#basic-configuration) for more guidance on the Migration Plan and how to use it. + +## Plan Builder + +This is the best way to build a migration plan. Calling the [`Build()`](xref:Tableau.Migration.Engine.MigrationPlanBuilder.Build) method on the [`IMigrationPlanBuilder`](xref:Tableau.Migration.IMigrationPlanBuilder) gives you a [`MigrationPlan`](xref:Tableau.Migration.Engine.MigrationPlan). + +## Manifest + +The migration manifest describes the various Tableau data items found to migrate and their migration results. See [`IMigrationManifest`](xref:Tableau.Migration.IMigrationManifest) for details. + +## Manifest Serializer + +The Migration SDK ships with a helpful serializer in both [C#](xref:Tableau.Migration.Engine.Manifest.MigrationManifestSerializer) and [Python](~/api-python/reference/tableau_migration.migration_engine_manifest.PyMigrationManifestSerializer.md). It serializes and deserializes migration manifests in JSON format. + +## Migration Status + +This is simply the status of the migration. See [`MigrationCompletionStatus`](xref:Tableau.Migration.MigrationCompletionStatus) for a list of statuses. + +## Migration Result + +This is the [result](xref:Tableau.Migration.MigrationResult) generated after the migration has finished. It has two properties + + 1. [`Manifest`](#manifest) + 2. [`Migration Status`](#migration-status) + +## Hook + +A [hook](~/articles/hooks/index.md) is a means of modifying the standard functionality of the Migration SDK. These modifications include filtering, mapping, transforming migration content and reacting to other SDK events. + \ No newline at end of file diff --git a/src/Documentation/articles/toc.yml b/src/Documentation/articles/toc.yml index 1e818aa7..c3973f7c 100644 --- a/src/Documentation/articles/toc.yml +++ b/src/Documentation/articles/toc.yml @@ -1,3 +1,5 @@ +- name: SDK Terminology + href: terms.md - name: Configuration href: configuration.md - name: Plan Validation diff --git a/src/Documentation/articles/troubleshooting.md b/src/Documentation/articles/troubleshooting.md index 5aa0a6af..69d7bb0c 100644 --- a/src/Documentation/articles/troubleshooting.md +++ b/src/Documentation/articles/troubleshooting.md @@ -65,7 +65,7 @@ Python Environment variables must be set in the system the Python application runs in. This can be done through the OS itself, or by 3rd party libraries. The SDK will load the environment configuration on its **\_\_init\_\_** process. -For the case of the library [dotenv](https://pypi.org/project/python-dotenv/), it is required to execute the command **load_dotenv()** before referring to any **tableau_migration** code. +For the case of the library [dotenv](https://pypi.org/project/python-dotenv/), it is required to execute the command **load_dotenv()** before referring to any **tableau_migration** code. ``` # Used to load environment variables @@ -93,3 +93,20 @@ This warning message indicates that the `GroupUsersTransformer` was unable to ad This situation can occur if a user was excluded by a custom filter, but was not mapped to another user. If a custom filter was implemented based on the `ContentFilterBase` class, then debug logging is already available. To resolve this issue, enable debug logging to identify which filter is excluding the user. Then, add a mapping to an existing user using the `ContentMappingBase` class. + +### Error (manifest) migrating `Guest` users + +`Guest` users are not supported on Tableau Cloud. They are only on Servers with the legacy Core based licensing. So, the Migration SDK cannot migrate them. To mitigate the problem, you can do one of these things + +1. If the users have no associated permissions on content items, you can write a User [filter](~/articles/hooks/custom_hooks.md) based on their SiteRole ([PySiteRoles](~/api-python/reference/tableau_migration.migration_api_rest_models.PySiteRoles.md)/[SiteRoles](xref:Tableau.Migration.Api.Rest.Models.SiteRoles)). +2. If the users do have associate permissions on content items, you can write a [mapping](~/articles/hooks/custom_hooks.md) for each of them to a different user. + +### Warning: `Embedded Managed OAuth Credentials migration is not supported. They will be converted to saved credentials for[workbook/data source] [name] at [location]. The connection IDs are [list of connection IDs].` + +This warning message indicates that the Migration SDK did not migrate a workbook/data source's [Managed OAuth Embedded Credentials](https://help.tableau.com/current/server/en-us/protected_auth.htm#defaultmanaged-keychain-connectors). +They will be automatically converted to saved credentials at the destination. Users will need to re-enter credentials the first time they use the workbook/ data source. +All other types of embedded credentials are migrated as they are. + +### Error `Content migration data could not be found for site '[Site ID]'.` + +This error message indicates that you need to authorize credential migration before migrating content with embedded credentials. See the [Pre-Migration Checklist](https://help.tableau.com/current/api/migration_sdk/en-us/docs/how_to_migrate.html) for more details. diff --git a/src/Documentation/articles/user_authentication.md b/src/Documentation/articles/user_authentication.md index 9a4b951a..02c975b8 100644 --- a/src/Documentation/articles/user_authentication.md +++ b/src/Documentation/articles/user_authentication.md @@ -1,28 +1,34 @@ # User and Group authentication -[Tableau Server](https://help.tableau.com/current/server/en-us/security_auth.htm) and [Tableau Cloud](https://help.tableau.com/current/online/en-us/security_auth.htm) support different authentication types. The Migration SDK supports authentication types listed in [AuthenticationTypes](xref:Tableau.Migration.Api.Rest.Models.Types.AuthenticationTypes) out of the box. +[Tableau Server](https://help.tableau.com/current/server/en-us/security_auth.htm) and [Tableau Cloud](https://help.tableau.com/current/online/en-us/security_auth.htm) support different authentication types. +The Migration SDK supports authentication types listed in [AuthenticationTypes](xref:Tableau.Migration.Api.Rest.Models.Types.AuthenticationTypes) out of the box. ## Defaults -The default authentication type for users is [`ServerDefault`](xref:Tableau.Migration.Api.Rest.Models.Types.AuthenticationTypes). It is set by the [`UserAuthenticationTypeTransformer`](xref:Tableau.Migration.Engine.Hooks.Transformers.Default.UserAuthenticationTypeTransformer). +The default authentication type for users is [`ServerDefault`](xref:Tableau.Migration.Api.Rest.Models.Types.AuthenticationTypes). +It is set by the automatically registered [`UserAuthenticationTypeTransformer`](xref:Tableau.Migration.Engine.Hooks.Transformers.Default.UserAuthenticationTypeTransformer). ## Server to Cloud -It is possible to set the authentication type on users and groups. The [`ServerToCloudMigrationPlanBuilder`](xref:Tableau.Migration.Engine.ServerToCloudMigrationPlanBuilder) contains methods to support mapping Server users and groups to Cloud authentication types. +It is possible to set the authentication type on users and groups. +The [`ServerToCloudMigrationPlanBuilder`](xref:Tableau.Migration.Engine.ServerToCloudMigrationPlanBuilder) contains methods to support mapping Server users and groups to Cloud authentication types. ### SAML and Tableau ID specific -1. `WithSamlAuthenticationType(string domain)` : Adds mappings for user and group domains based on the SAML authentication type with a domain supplied. -2. `WithTableauIdAuthenticationType(bool mfa = true)`: Adds mappings for user and group domains based on the Tableau ID authentication type with or without multi-factor authentication. +1. `WithSamlAuthenticationType(string domain, string? idpConfigurationName = null)` : Adds mappings for user and group domains based on the SAML authentication type with a domain supplied. When a site has [multiple SAML authentication types](https://help.tableau.com/current/online/en-us/security_auth.htm#multiple_idp) enabled, the IdP configuration name should be supplied. +2. `WithTableauIdAuthenticationType(bool mfa = true, string? idpConfigurationName = null)`: Adds mappings for user and group domains based on the Tableau ID authentication type with or without multi-factor authentication. ### Tableau Cloud Usernames -The `WithTableauCloudUsernames()` and its overloads allow you to supply an email domain or your own implementation of [`ITableauCloudUsernameMapping`](xref:Tableau.Migration.Engine.Hooks.Mappings.Default.ITableauCloudUsernameMapping) for Tableau Cloud user names. +The `WithTableauCloudUsernames()` method and its overloads allow you to supply an email domain or your own implementation of [`ITableauCloudUsernameMapping`](xref:Tableau.Migration.Engine.Hooks.Mappings.Default.ITableauCloudUsernameMapping) for Tableau Cloud user names. ### General methods -The `WithAuthenticationType()` and its overloads allow you to supply your chosen authentication type with your implementation of [`IAuthenticationTypeDomainMapping`](xref:Tableau.Migration.Engine.Hooks.Mappings.Default.IAuthenticationTypeDomainMapping). +The `WithAuthenticationType()` method and its overloads allow you to supply your chosen authentication type with your implementation of [`IAuthenticationTypeDomainMapping`](xref:Tableau.Migration.Engine.Hooks.Mappings.Default.IAuthenticationTypeDomainMapping). +When a site has [multiple authentication types](https://help.tableau.com/current/online/en-us/security_auth.htm#multiple_idp) enabled the IdP configuration name should be used as the authentication type. +Otherwise a [`AuthenticationTypes`](xref:Tableau.Migration.Api.Rest.Models.Types.AuthenticationTypes) value should be used. ## Custom Mapping -You can also build your own mapping to supply to the appropriate `WithAuthenticationType()` overload. See [Sample EmailDomainMapping](~/samples/mappings/username_email_mapping.md) for example code. +You can also build your own mapping to supply to the appropriate `WithAuthenticationType()` overload. +See [Sample EmailDomainMapping](~/samples/mappings/username_email_mapping.md) for example code. diff --git a/src/Documentation/cspell.json b/src/Documentation/cspell.json index 69c2eb4e..944aa8b8 100644 --- a/src/Documentation/cspell.json +++ b/src/Documentation/cspell.json @@ -3,7 +3,9 @@ "ignorePaths": [], "dictionaryDefinitions": [], "dictionaries": [], - "words": [], + "words": [ + "appsettings" + ], "ignoreWords": [ "configparser", "docfx", diff --git a/src/Documentation/includes/configuration/sdk_opts_content_types.html b/src/Documentation/includes/configuration/sdk_opts_content_types.html new file mode 100644 index 00000000..2c902502 --- /dev/null +++ b/src/Documentation/includes/configuration/sdk_opts_content_types.html @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDescriptionDefaultPython Environment Variable
ContentTypes.Type + Determines which content type the settings apply to. + Only supported content types will be considered and all others will be ignored. + This key comes from the interface for the content type. + For example, the key for IUser is 'User'. Content + type values are case insensitive. + Not applicable. + MigrationSDK__ContentTypes__<array-index>__<type-key>__Type +
ContentTypes.BatchSizeDefines the page size of each list request. See Tableau + REST API Paginating Results for details.100 + MigrationSDK__ContentTypes__<array-index>__<type-key>__BatchSize +
ContentTypes.BatchPublishingEnabledSelects the mode to publish a given content type.
Important: This option is available only + for Users.
false + MigrationSDK__ContentTypes__<array-index>__<type-key>__BatchPublishingEnabled +
ContentTypes.IncludeExtractEnabledDetermines whether to include extracts. Default: enabled.
Important: This option is + available only for Workbooks.
false + MigrationSDK__ContentTypes__<array-index>__<type-key>__IncludeExtractEnabled +
\ No newline at end of file diff --git a/src/Documentation/includes/configuration/sdk_opts_def_perm.html b/src/Documentation/includes/configuration/sdk_opts_def_perm.html new file mode 100644 index 00000000..55c6f583 --- /dev/null +++ b/src/Documentation/includes/configuration/sdk_opts_def_perm.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + +
KeyDescriptionDefaultPython Environment Variable
DefaultPermissionsContentTypes.UrlSegmentsList of types of default permissions for a given project. See Query + Default Permissions for details. Important: This configuration is not dynamically + applied. It takes effect when you restart your application. Listed in DefaultPermissionsContentTypeUrlSegments + Not Supported
\ No newline at end of file diff --git a/src/Documentation/includes/configuration/sdk_opts_files.html b/src/Documentation/includes/configuration/sdk_opts_files.html new file mode 100644 index 00000000..49b8aa65 --- /dev/null +++ b/src/Documentation/includes/configuration/sdk_opts_files.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + +
KeyDescriptionDefaultPython Environment Variable
Files.DisableFileEncryptionDefines whether to encrypt temporary files downloaded during the migration. This applies to file-based + content types, such as Workbooks and Data Sources.falseMigrationSDK__Files__DisableFileEncryption
Files.RootPathDefines the location to store temporary files. The default temporary path for the OS.MigrationSDK__Files__RootPath
\ No newline at end of file diff --git a/src/Documentation/includes/configuration/sdk_opts_jobs.html b/src/Documentation/includes/configuration/sdk_opts_jobs.html new file mode 100644 index 00000000..13f39fd9 --- /dev/null +++ b/src/Documentation/includes/configuration/sdk_opts_jobs.html @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + +
KeyDescriptionDefaultPython Environment Variable
Jobs.JobPollRateDefines the interval to wait to recheck processing status for bulk processes.3 sMigrationSDK__Jobs__JobPollRate
Jobs.JobTimeoutDefines the maximum interval to wait for a job to complete for bulk processes.30 mMigrationSDK__Jobs__JobTimeout
\ No newline at end of file diff --git a/src/Documentation/includes/configuration/sdk_opts_network.html b/src/Documentation/includes/configuration/sdk_opts_network.html new file mode 100644 index 00000000..f5367c3a --- /dev/null +++ b/src/Documentation/includes/configuration/sdk_opts_network.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDescriptionDefaultPython Environment Variable
Network.FileChunkSizeKBThe chunk size (KB) for publishing large files. This applies to file-based content types like Workbooks + and Data Sources. See the Tableau + REST API Publishing Resources documentation for more details.65536MigrationSDK__Network__FileChunkSizeKB
Network.HeadersLoggingEnabledEnables logging of HTTP request headers.falseMigrationSDK__Network__HeadersLoggingEnabled
Network.ContentLoggingEnabledEnables logging of HTTP request content.falseMigrationSDK__Network__ContentLoggingEnabled
Network.BinaryContentLoggingEnabledEnables logging of binary content in HTTP requests.falseMigrationSDK__Network__BinaryContentLoggingEnabled
Network.ExceptionsLoggingEnabledEnables logging of HTTP request exceptions.falseMigrationSDK__Network__ExceptionsLoggingEnabled
Network.UserAgentCommentDefines a comment to append to the User-Agent header in all HTTP requests. This property is only used to + assist in server-side debugging and it not typically set. + MigrationSDK__Network__UserAgentComment
\ No newline at end of file diff --git a/src/Documentation/includes/configuration/sdk_opts_network_res.html b/src/Documentation/includes/configuration/sdk_opts_network_res.html new file mode 100644 index 00000000..8f9e329f --- /dev/null +++ b/src/Documentation/includes/configuration/sdk_opts_network_res.html @@ -0,0 +1,134 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDescriptionDefaultPython Environment Variable
Network.Resilience.RetryEnabledDefines whether to retry failed requests.trueMigrationSDK__Network__Resilience__RetryEnabled
Network.Resilience.RetryIntervalsDefines the number of retries and interval between retries. + [
+  500 ms,
+  500 ms,
+  500 ms,
+  1 s,
+  2 s
+ ] +
Not supported
Network.Resilience.RetryOverrideResponseCodesOverrides the default error status codes for retries.Not supported
Network.Resilience.ConcurrentRequestsLimitEnabledDefines whether to limit concurrent requests.falseMigrationSDK__Network__Resilience__ConcurrentRequestsLimitEnabled
Network.Resilience.MaxConcurrentRequests + Defines the maximum quantity of concurrent API requests.
+ This is based on the + number of logical processors + (or processor count). +
+ (processor count)/2 + MigrationSDK__Network__Resilience__MaxConcurrentRequests
Network.Resilience.ConcurrentWaitingRequestsOnQueueDefines the quantity of concurrent API requests waiting on queue. This is also based on processor count. + + (processor count)/4 + MigrationSDK__Network__Resilience__ConcurrentWaitingRequestsOnQueue
Network.Resilience.ClientThrottleEnabledDefines whether to limit requests to a given endpoint.falseMigrationSDK__Network__Resilience__ClientThrottleEnabled
Network.Resilience.MaxReadRequestsLimits the amount of GET requests for the Client Throttle.40000MigrationSDK__Network__Resilience__MaxReadRequests
Network.Resilience.MaxReadRequestsIntervalDefines the interval for the Read Request Throttle.1 hMigrationSDK__Network__Resilience__MaxReadRequestsInterval
Network.Resilience.MaxPublishRequestsDefines the maximum quantity of non-GET requests on the client side.5500MigrationSDK__Network__Resilience__MaxPublishRequests
Network.Resilience.MaxPublishRequestsIntervalDefines the interval for the limit of non-GET requests on the client side.1 dayMigrationSDK__Network__Resilience__MaxPublishRequestsInterval
Network.Resilience.ServerThrottleEnabledDefines whether to retry requests throttled on the server.trueMigrationSDK__Network__Resilience__ServerThrottleEnabled
Network.Resilience.ServerThrottleLimitRetriesDefines whether there is a limit of retries for a throttled request.falseMigrationSDK__Network__Resilience__ServerThrottleLimitRetries
Network.Resilience.ServerThrottleRetryIntervalsDefines the interval between each retry for throttled requests without the 'Retry-After' header. + [
+  1 s,
+  3 s,
+  10 s,
+  30 s,
+  1 m
+ ] +
Not supported
Network.Resilience.PerRequestTimeoutDefines the maximum duration of non-FileTransfer requests.30 mMigrationSDK__Network__Resilience__PerRequestTimeout
Network.Resilience.PerFileTransferRequestTimeoutDefines the maximum duration of FileTransfer requests.12 hMigrationSDK__Network__Resilience__PerFileTransferRequestTimeout
\ No newline at end of file diff --git a/src/Documentation/includes/python-getting-started.md b/src/Documentation/includes/python-getting-started.md index eae84a79..579f98fc 100644 --- a/src/Documentation/includes/python-getting-started.md +++ b/src/Documentation/includes/python-getting-started.md @@ -1,7 +1,13 @@ -### [Startup Script](#tab/startup) +### [Startup Scripts](#tab/startup) + +Main module: [!code-python[](../../../examples/Python.ExampleApplication/Python.ExampleApplication.py)] +`print_result` helper module: + +[!code-python[](../../../examples/Python.ExampleApplication/print_result.py)] + ### [config.ini](#tab/config) > [!Important] diff --git a/src/Documentation/samples/index.md b/src/Documentation/samples/index.md index 5de560fa..bf6c6f91 100644 --- a/src/Documentation/samples/index.md +++ b/src/Documentation/samples/index.md @@ -6,7 +6,7 @@ Once you have started building your migration using the example code in [C#](~/a ## Hook Registration -To use hooks, you need to register them with the [plan builder](~/articles/configuration.md#migration-plan). +To use hooks, you need to register them with the [plan builder](~/articles/configuration.md#basic-configuration). The process of registering hooks differs slightly between C# and Python, as described below. diff --git a/src/Documentation/samples/transformers/action_url_xml_transformer.md b/src/Documentation/samples/transformers/action_url_xml_transformer.md new file mode 100644 index 00000000..4e33f3d6 --- /dev/null +++ b/src/Documentation/samples/transformers/action_url_xml_transformer.md @@ -0,0 +1,53 @@ +# Sample: Action URL XML Transformer + +This sample demonstrates how to read and write XML to update the workbook files. +XML transformers should use the `XmlContentTransformerBase` base class that handles parsing the file XML and saving the modified XML back to the file to be published. + +XML transformers require additional resource overhead to execute, so care should be taken when developing them. + +- Due to encryption, XML transformers require the file to be loaded into memory to modify. For large files, such as those with large extracts, this can require significant memory. +- Python XML transformers each require extra processing as the parsed XML is converted from a .NET representation to a Python representation. For files with large XML content this can require significant time and memory. Combining multiple Python XML transformers can minimize this impact. + +In general, resource overhead can be mimized by implementing the `NeedsXmlTransforming`/`needs_xml_transforming` method, which allows the transformer to use the metadata of the content item to determine whether the XML file needs to be loaded. + +XML transformers are provided with "raw" XML and no file format validation is performed by the SDK. +Care should be taken when modifying workbook or other files that the changes do not result in content that is valid XML but invalid by the file format. +File format errors can lead to migration errors during publishing, and can also cause errors that are only apparent after the migration is complete and reported success. + +## [Python](#tab/Python) + +### Transformer Class + +To update action URLs in Python, you can use the following transformer class: + +[!code-python[](../../../../examples/Python.ExampleApplication/hooks/transformers/action_url_xml_transformer.py)] + +### Registration + +[//]: <> (Adding this as code as regions are not supported in python snippets) + +```Python +plan_builder.transformers.add(ActionUrlXmlTransformer) +``` + +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + +## [C#](#tab/CSharp) + +### Transformer Class + +In C#, the transformer class for adjusting action URLs is implemented as follows: + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Hooks/Transformers/ActionUrlXmlTransformer.cs#class)] + +### Registration + +To register the transformer in C#, follow the guidance provided in the [documentation](~/samples/index.md?tabs=CSharp#hook-registration). + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/MyMigrationApplication.cs#ActionUrlXmlTransformer-Registration)] + +### Dependency Injection + +Learn more about dependency injection [here](~/articles/dependency_injection.md). + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Program.cs#ActionUrlXmlTransformer-DI)] \ No newline at end of file diff --git a/src/Documentation/samples/transformers/index.md b/src/Documentation/samples/transformers/index.md index d290ab60..4960a569 100644 --- a/src/Documentation/samples/transformers/index.md +++ b/src/Documentation/samples/transformers/index.md @@ -7,4 +7,5 @@ The following samples cover some common scenarios: - [Sample: Add tags to content](~/samples/transformers/migrated_tag_transformer.md) - [Sample: Encrypt Extracts](~/samples/transformers/encrypt_extracts_transformer.md) - [Sample: Adjust 'Start At' to Scheduled Tasks](~/samples/transformers/start_at_transformer.md) -- [Sample: Change default users for Custom Views](~/samples/transformers/custom_view_default_users_transformer.md) \ No newline at end of file +- [Sample: Change default users for Custom Views](~/samples/transformers/custom_view_default_users_transformer.md) +- [Sample: Action URL XML transformer](~/samples/transformers/action_url_xml_transformer.md) \ No newline at end of file diff --git a/src/Documentation/samples/transformers/toc.yml b/src/Documentation/samples/transformers/toc.yml index 248d9582..31e005b1 100644 --- a/src/Documentation/samples/transformers/toc.yml +++ b/src/Documentation/samples/transformers/toc.yml @@ -5,4 +5,6 @@ - name: Adjust 'Start At' to Scheduled Tasks href: start_at_transformer.md - name: Change default users for Custom Views - href: custom_view_default_users_transformer.md \ No newline at end of file + href: custom_view_default_users_transformer.md +- name: Action URL XML Transformer + href: action_url_xml_transformer.md \ No newline at end of file diff --git a/src/Python/Documentation/conf.py b/src/Python/Documentation/conf.py index 580cd02d..c0eb7380 100644 --- a/src/Python/Documentation/conf.py +++ b/src/Python/Documentation/conf.py @@ -28,4 +28,7 @@ markdown_anchor_sections=True markdown_anchor_signatures=True add_module_names=False +autodoc_default_options = { + 'member-order': 'bysource' +} print("..done.") \ No newline at end of file diff --git a/src/Python/pyproject.toml b/src/Python/pyproject.toml index 91873a3a..74eae7ea 100644 --- a/src/Python/pyproject.toml +++ b/src/Python/pyproject.toml @@ -21,9 +21,9 @@ license = "Apache-2.0" dependencies = [ "typing_extensions==4.12.2", - "cffi==1.16.0", + "cffi==1.17.1", "pycparser==2.22", - "pythonnet==3.0.3" + "pythonnet==3.0.4" ] # Get the version from Directory.Build.props file, which is where the nuget package version comes from. @@ -35,7 +35,7 @@ validate-bump = false [tool.hatch.envs.docs] dependencies = [ - "sphinx-markdown-builder==0.6.6" + "sphinx-markdown-builder==0.6.7" ] [tool.hatch.envs.docs.scripts] @@ -45,7 +45,7 @@ docs = "sphinx-build -M markdown .\\Documentation\\ ..\\Documentation\\python\\ detached = true dependencies = [ - "ruff==0.5.0" + "ruff==0.7.3" ] [tool.hatch.envs.lint.scripts] @@ -54,14 +54,14 @@ lint = "ruff check ." [tool.hatch.envs.test] dev-mode = false dependencies = [ - "pytest>=8.2.2", - "pytest-cov>=5.0.0", - "pytest-env>=1.1.3" + "pytest>=8.3.3", + "pytest-cov>=6.0.0", + "pytest-env>=1.1.5" ] [tool.hatch.envs.test.scripts] test = "pytest -vv" -testcov = "test --cov-config=pyproject.toml --cov=tableau_migration" +testcov = "test --cov-config=pyproject.toml --cov=src/tableau_migration --cov-report term --cov-report xml:TestResults/coverage-{matrix:python}.xml" [[tool.hatch.envs.test.matrix]] python = ["3.9", "3.10", "3.11", "3.12"] diff --git a/src/Python/requirements.txt b/src/Python/requirements.txt index 9c484041..42b2f86e 100644 --- a/src/Python/requirements.txt +++ b/src/Python/requirements.txt @@ -1,6 +1,6 @@ -cffi==1.16.0 +cffi==1.17.1 pycparser==2.22 -pythonnet==3.0.3 +pythonnet==3.0.4 typing_extensions==4.12.2 -pytest-env==1.1.3 -build==1.2.1 \ No newline at end of file +pytest-env==1.1.5 +build==1.2.2 \ No newline at end of file diff --git a/src/Python/src/tableau_migration/__init__.py b/src/Python/src/tableau_migration/__init__.py index 70ce060a..1f88248a 100644 --- a/src/Python/src/tableau_migration/__init__.py +++ b/src/Python/src/tableau_migration/__init__.py @@ -86,6 +86,7 @@ from tableau_migration.migration import PyContentLocation as ContentLocation # noqa: E402, F401 from tableau_migration.migration import PyContentReference as IContentReference # noqa: E402, F401 from tableau_migration.migration import PyMigrationCompletionStatus as MigrationCompletionStatus # noqa: E402, F401 +from tableau_migration.migration import PyPipelineProfile as PipelineProfile # noqa: E402, F401 from tableau_migration.migration import PyResult as IResult # noqa: E402, F401 from tableau_migration.migration_api_rest import PyRestIdentifiable as IRestIdentifiable # noqa: E402, F401 from tableau_migration.migration_api_rest_models import PyAdministratorLevels as AdministratorLevels # noqa: E402, F401 @@ -99,6 +100,7 @@ from tableau_migration.migration_api_rest_models_types import PyAuthenticationTypes as AuthenticationTypes # noqa: E402, F401 from tableau_migration.migration_api_rest_models_types import PyDataSourceFileTypes as DataSourceFileTypes # noqa: E402, F401 from tableau_migration.migration_api_rest_models_types import PyWorkbookFileTypes as WorkbookFileTypes # noqa: E402, F401 +from tableau_migration.migration_content import PyCloudSubscription as ICloudSubscription # noqa: E402, F401 from tableau_migration.migration_content import PyConnection as IConnection # noqa: E402, F401 from tableau_migration.migration_content import PyConnectionsContent as IConnectionsContent # noqa: E402, F401 from tableau_migration.migration_content import PyContainerContent as IContainerContent # noqa: E402, F401 @@ -116,8 +118,12 @@ from tableau_migration.migration_content import PyPublishableGroup as IPublishableGroup # noqa: E402, F401 from tableau_migration.migration_content import PyPublishableWorkbook as IPublishableWorkbook # noqa: E402, F401 from tableau_migration.migration_content import PyPublishedContent as IPublishedContent # noqa: E402, F401 +from tableau_migration.migration_content import PyServerSubscription as IServerSubscription # noqa: E402, F401 +from tableau_migration.migration_content import PySubscription as ISubscription # noqa: E402, F401 +from tableau_migration.migration_content import PySubscriptionContent as ISubscriptionContent # noqa: E402, F401 from tableau_migration.migration_content import PyTag as ITag # noqa: E402, F401 from tableau_migration.migration_content import PyUser as IUser # noqa: E402, F401 +from tableau_migration.migration_content import PyUserAuthenticationType as UserAuthenticationType # noqa: E402, F401 from tableau_migration.migration_content import PyUsernameContent as IUsernameContent # noqa: E402, F401 from tableau_migration.migration_content import PyView as IView # noqa: E402, F401 from tableau_migration.migration_content import PyWithDomain as IWithDomain # noqa: E402, F401 diff --git a/src/Python/src/tableau_migration/migration.py b/src/Python/src/tableau_migration/migration.py index 7669dd77..c3d88089 100644 --- a/src/Python/src/tableau_migration/migration.py +++ b/src/Python/src/tableau_migration/migration.py @@ -21,7 +21,6 @@ import sys from typing import Type, TypeVar, List -from typing_extensions import Self from uuid import UUID # region Generic Wrapper Helpers @@ -142,69 +141,6 @@ def _get_new_python_logger_delegate(name: str) -> MigrationLogger: # endregion -# region objects - -class PyMigrationManifest(): - """Interface for an object that describes the various Tableau data items found to migrate and their migration results.""" - - _dotnet_base = IMigrationManifestEditor - - def __init__(self, migration_manifest: IMigrationManifestEditor) -> None: - """Default init. - - Args: - migration_manifest: IMigrationManifest that can be edited - - Returns: None. - """ - self._migration_manifest = migration_manifest - - @property - def plan_id(self) -> UUID: - """Gets the unique identifier of the IMigrationPlan that was executed to produce this manifest.""" - return UUID(self._migration_manifest.PlanId.ToString()) - - @property - def migration_id(self) -> UUID: - """Gets the unique identifier of the migration run that produced this manifest.""" - return UUID(self._migration_manifest.MigrationId.ToString()) - - @property - def manifest_version(self) -> int: - """Gets the version of this manifest. Used for serialization.""" - return self._migration_manifest.ManifestVersion - - @property - def errors(self): - """Gets top-level errors that are not related to any Tableau content item but occurred during the migration.""" - return self._migration_manifest.Errors - - @property - def entries(self): - """Gets the collection of manifest entries.""" - return self._migration_manifest.Entries - - def add_errors(self, errors) -> Self: - """Adds top-level errors that are not related to any Tableau content item. - - Args: - errors: The errors to add. Either a List[System.Exception] or System.Exception - - Returns: This manifest editor, for fluent API usage. - """ - # If a list is passed in, marshal it to a dotnet list and pass it on - if(isinstance(errors, List)): - marshalled_error = System.Collections.Generic.List[System.Exception]() - [marshalled_error.Add(error) for error in errors] - self._migration_manifest.AddErrors(marshalled_error) - return - - # If something else is passed in, let dotnet handle it. - # It's either valid and it works - # or an exception will be thrown - self._migration_manifest.AddErrors(errors) - -# endregion # region _generated @@ -359,6 +295,21 @@ class PyMigrationCompletionStatus(IntEnum): """The migration had a fatal error that interrupted completion.""" FATAL_ERROR = 2 +class PyPipelineProfile(IntEnum): + """Enumeration of the various supported migration pipeline profiles.""" + + """A custom pipeline supplied by the migration plan is used.""" + CUSTOM = 1 + + """The pipeline to bulk migrate content from a Tableau Server site to a Tableau Cloud site.""" + SERVER_TO_CLOUD = 2 + + """The pipeline to bulk migrate content from a Tableau Server site to a Tableau Server site.""" + SERVER_TO_SERVER = 3 + + """The pipeline to bulk migrate content from a Tableau Cloud site to a Tableau Cloud site.""" + CLOUD_TO_CLOUD = 4 + class PyResult(): """Interface representing the result of an operation.""" @@ -387,6 +338,71 @@ def errors(self) -> Sequence[System.Exception]: # endregion +class PyMigrationManifest(): + """Interface for an object that describes the various Tableau data items found to migrate and their migration results.""" + + _dotnet_base = IMigrationManifestEditor + + def __init__(self, migration_manifest: IMigrationManifestEditor) -> None: + """Default init. + + Args: + migration_manifest: IMigrationManifest that can be edited + + Returns: None. + """ + self._migration_manifest = migration_manifest + + @property + def plan_id(self) -> UUID: + """Gets the unique identifier of the IMigrationPlan that was executed to produce this manifest.""" + return UUID(self._migration_manifest.PlanId.ToString()) + + @property + def migration_id(self) -> UUID: + """Gets the unique identifier of the migration run that produced this manifest.""" + return UUID(self._migration_manifest.MigrationId.ToString()) + + @property + def manifest_version(self) -> int: + """Gets the version of this manifest. Used for serialization.""" + return self._migration_manifest.ManifestVersion + + @property + def pipeline_profile(self) -> PyPipelineProfile: + """Gets the profile of the migration pipeline that produced this manifest.""" + return self._migration_manifest.PipelineProfile + + @property + def errors(self): + """Gets top-level errors that are not related to any Tableau content item but occurred during the migration.""" + return self._migration_manifest.Errors + + @property + def entries(self): + """Gets the collection of manifest entries.""" + return self._migration_manifest.Entries + + def add_errors(self, errors) -> Self: + """Adds top-level errors that are not related to any Tableau content item. + + Args: + errors: The errors to add. Either a List[System.Exception] or System.Exception + + Returns: This manifest editor, for fluent API usage. + """ + # If a list is passed in, marshal it to a dotnet list and pass it on + if(isinstance(errors, List)): + marshalled_error = System.Collections.Generic.List[System.Exception]() + [marshalled_error.Add(error) for error in errors] + self._migration_manifest.AddErrors(marshalled_error) + return + + # If something else is passed in, let dotnet handle it. + # It's either valid and it works + # or an exception will be thrown + self._migration_manifest.AddErrors(errors) + class PyMigrationResult(): """Interface for a result of a migration.""" diff --git a/src/Python/src/tableau_migration/migration_api_rest_models.py b/src/Python/src/tableau_migration/migration_api_rest_models.py index c349a206..b5e09a29 100644 --- a/src/Python/src/tableau_migration/migration_api_rest_models.py +++ b/src/Python/src/tableau_migration/migration_api_rest_models.py @@ -89,75 +89,78 @@ class PyPermissionsCapabilityModes(StrEnum): DENY = "Deny" class PyPermissionsCapabilityNames(StrEnum): - """The Capability names used in the test API.""" + """Enumeration class for the various capability names used in REST API permissions.""" - """Gets the name of capability name for no capabilities.""" + """Gets the name of the None capability.""" NONE = "None" - """Gets the name of capability name for AddComment.""" + """Gets the name of the "Add Comment" capability.""" ADD_COMMENT = "AddComment" - """Gets the name of capability name for ChangeHierarchy.""" + """Gets the name of the "Change Hierarchy" capability.""" CHANGE_HIERARCHY = "ChangeHierarchy" - """Gets the name of capability name for ChangePermissions.""" + """Gets the name of the "Change Permissions" capability.""" CHANGE_PERMISSIONS = "ChangePermissions" - """Gets the name of capability name for Connect.""" + """Gets the name of the "Connect" capability.""" CONNECT = "Connect" - """Gets the name of capability name for CreateRefreshMetrics.""" + """Gets the name of the "Create Refresh Metrics" capability.""" CREATE_REFRESH_METRICS = "CreateRefreshMetrics" - """Gets the name of capability name for Delete.""" + """Gets the name of the "Delete" capability.""" DELETE = "Delete" - """Gets the name of capability name for Execute.""" + """Gets the name of the "Execute" capability.""" EXECUTE = "Execute" - """Gets the name of capability name for ExportData.""" + """Gets the name of the "Export Data" capability.""" EXPORT_DATA = "ExportData" - """Gets the name of capability name for ExportImage.""" + """Gets the name of the "Export Image" capability.""" EXPORT_IMAGE = "ExportImage" - """Gets the name of capability name for ExportXml.""" + """Gets the name of the "Export XML" capability.""" EXPORT_XML = "ExportXml" - """Gets the name of capability name for Filter.""" + """Gets the name of the "Extract Refresh" capability.""" + EXTRACT_REFRESH = "ExtractRefresh" + + """Gets the name of the "Filter" capability.""" FILTER = "Filter" - """Gets the name of capability name for InheritedProjectLeader.""" + """Gets the name of the "Inherited Project Leader" capability.""" INHERITED_PROJECT_LEADER = "InheritedProjectLeader" - """Gets the name of capability name for ProjectLeader.""" + """Gets the name of the "Project Leader" capability.""" PROJECT_LEADER = "ProjectLeader" - """Gets the name of capability name for Read.""" + """Gets the name of the "Read" capability.""" READ = "Read" - """Gets the name of capability name for RunExplainData.""" + """Gets the name of the "Run Explain Data" capability.""" RUN_EXPLAIN_DATA = "RunExplainData" - """Gets the name of capability name for SaveAs.""" + """Gets the name of the "Save As" capability.""" SAVE_AS = "SaveAs" - """Gets the name of capability name for ShareView.""" + """Gets the name of the "Share View" capability.""" SHARE_VIEW = "ShareView" - """Gets the name of capability name for ViewComments.""" + """Gets the name of the "View Comments" capability.""" VIEW_COMMENTS = "ViewComments" - """Gets the name of capability name for ViewUnderlyingData.""" + """Gets the name of the "View Underlying Data" capability.""" VIEW_UNDERLYING_DATA = "ViewUnderlyingData" - """Gets the name of capability name for WebAuthoring.""" + """Gets the name of the "Web Authoring" capability.""" WEB_AUTHORING = "WebAuthoring" - """Gets the name of capability name for WebAuthoringForFlows.""" + """Gets the name of the "Web Authoring" capability for flows.""" WEB_AUTHORING_FOR_FLOWS = "WebAuthoringForFlows" - """Gets the name of capability name for Write.""" + """Gets the name of the "Write" capability.""" WRITE = "Write" class PySiteRoles(StrEnum): diff --git a/src/Python/src/tableau_migration/migration_content.py b/src/Python/src/tableau_migration/migration_content.py index 7a655180..05864a05 100644 --- a/src/Python/src/tableau_migration/migration_content.py +++ b/src/Python/src/tableau_migration/migration_content.py @@ -19,12 +19,19 @@ # region _generated -from tableau_migration.migration import PyContentReference # noqa: E402, F401 +from tableau_migration.migration import ( # noqa: E402, F401 + PyContentReference, + _generic_wrapper +) from tableau_migration.migration_api_rest import PyRestIdentifiable # noqa: E402, F401 +from tableau_migration.migration_content_schedules import PyWithSchedule # noqa: E402, F401 from typing import ( # noqa: E402, F401 Sequence, - List + List, + Generic, + TypeVar ) +from typing_extensions import Self # noqa: E402, F401 from uuid import UUID # noqa: E402, F401 from System import ( # noqa: E402, F401 @@ -36,6 +43,7 @@ HashSet as DotnetHashSet ) from Tableau.Migration.Content import ( # noqa: E402, F401 + ICloudSubscription, IConnection, IConnectionsContent, IContainerContent, @@ -53,6 +61,9 @@ IPublishableGroup, IPublishableWorkbook, IPublishedContent, + IServerSubscription, + ISubscription, + ISubscriptionContent, ITag, IUser, IUsernameContent, @@ -62,9 +73,52 @@ IWithTags, IWithWorkbook, IWorkbook, - IWorkbookDetails + IWorkbookDetails, + UserAuthenticationType ) +TSchedule = TypeVar("TSchedule") + +class PyWithOwner(PyContentReference): + """Interface to be inherited by content items with owner.""" + + _dotnet_base = IWithOwner + + def __init__(self, with_owner: IWithOwner) -> None: + """Creates a new PyWithOwner object. + + Args: + with_owner: A IWithOwner object. + + Returns: None. + """ + self._dotnet = with_owner + + @property + def owner(self) -> PyContentReference: + """Gets or sets the owner for the content item.""" + return None if self._dotnet.Owner is None else PyContentReference(self._dotnet.Owner) + + @owner.setter + def owner(self, value: PyContentReference) -> None: + """Gets or sets the owner for the content item.""" + self._dotnet.Owner = None if value is None else value._dotnet + +class PyCloudSubscription(PyWithOwner): + """The interface for a cloud subscription.""" + + _dotnet_base = ICloudSubscription + + def __init__(self, cloud_subscription: ICloudSubscription) -> None: + """Creates a new PyCloudSubscription object. + + Args: + cloud_subscription: A ICloudSubscription object. + + Returns: None. + """ + self._dotnet = cloud_subscription + class PyConnection(): """Interface for a content item's embedded connection.""" @@ -110,6 +164,21 @@ def query_tagging_enabled(self) -> bool: """Gets the query tagging enabled flag for the response. This is returned only for administrator users.""" return self._dotnet.QueryTaggingEnabled + @property + def authentication_type(self) -> str: + """Gets the authentication type for the response.""" + return self._dotnet.AuthenticationType + + @property + def use_o_auth_managed_keychain(self) -> bool: + """Gets whether to use OAuth managed keychain.""" + return self._dotnet.UseOAuthManagedKeychain + + @property + def embed_password(self) -> bool: + """Gets whether to embed the password.""" + return self._dotnet.EmbedPassword + class PyConnectionsContent(): """Interface for content that has connection metadata.""" @@ -130,6 +199,21 @@ def connections(self) -> Sequence[PyConnection]: """Gets the connection metadata. Connection metadata is read only because connection metadata should not be transformed directly. Instead, connections should be modified by either: 1) manipulating XML before publishing, or 2) updating connection metadata in a post-publish hook.""" return None if self._dotnet.Connections is None else list((None if x is None else PyConnection(x)) for x in self._dotnet.Connections) + @property + def has_embedded_password(self) -> bool: + """Gets whether any Connections have an embedded password.""" + return self._dotnet.HasEmbeddedPassword + + @property + def has_embedded_o_auth_managed_keychain(self) -> bool: + """Gets whether any Connections have an embedded password and uses OAuth managed keychain.""" + return self._dotnet.HasEmbeddedOAuthManagedKeychain + + @property + def has_embedded_o_auth_credentials(self) -> bool: + """Gets whether any Connections have an embedded password and an OAuth authentication type.""" + return self._dotnet.HasEmbeddedOAuthCredentials + class PyContainerContent(): """Interface for a content item that belongs to a container (e.g. project or personal space).""" @@ -150,31 +234,6 @@ def container(self) -> PyContentReference: """Gets the container for the content item. Relocating the content should be done through mapping.""" return None if self._dotnet.Container is None else PyContentReference(self._dotnet.Container) -class PyWithOwner(PyContentReference): - """Interface to be inherited by content items with owner.""" - - _dotnet_base = IWithOwner - - def __init__(self, with_owner: IWithOwner) -> None: - """Creates a new PyWithOwner object. - - Args: - with_owner: A IWithOwner object. - - Returns: None. - """ - self._dotnet = with_owner - - @property - def owner(self) -> PyContentReference: - """Gets or sets the owner for the content item.""" - return None if self._dotnet.Owner is None else PyContentReference(self._dotnet.Owner) - - @owner.setter - def owner(self, value: PyContentReference) -> None: - """Gets or sets the owner for the content item.""" - self._dotnet.Owner = None if value is None else value._dotnet - class PyWithWorkbook(PyContentReference): """Interface to be inherited by content items with workbook.""" @@ -331,7 +390,7 @@ def encrypt_extracts(self, value: bool) -> None: self._dotnet.EncryptExtracts = value class PyTag(): - """Inteface for tags associated with content items.""" + """Interface for tags associated with content items.""" _dotnet_base = ITag @@ -763,6 +822,11 @@ def __init__(self, view: IView) -> None: """ self._dotnet = view + @property + def parent_workbook(self) -> PyContentReference: + """Gets the parent workbook of the view.""" + return None if self._dotnet.ParentWorkbook is None else PyContentReference(self._dotnet.ParentWorkbook) + class PyWorkbookDetails(PyWorkbook): """Interface for a workbook object with extended information, from a GET query for example.""" @@ -824,6 +888,215 @@ def hidden_view_names(self, value: Sequence[str]) -> None: dotnet_collection.Add(x) self._dotnet.HiddenViewNames = dotnet_collection +class PyServerSubscription(PyWithOwner): + """The interface for a server subscription.""" + + _dotnet_base = IServerSubscription + + def __init__(self, server_subscription: IServerSubscription) -> None: + """Creates a new PyServerSubscription object. + + Args: + server_subscription: A IServerSubscription object. + + Returns: None. + """ + self._dotnet = server_subscription + +class PySubscriptionContent(): + """The content of the subscription.""" + + _dotnet_base = ISubscriptionContent + + def __init__(self, subscription_content: ISubscriptionContent) -> None: + """Creates a new PySubscriptionContent object. + + Args: + subscription_content: A ISubscriptionContent object. + + Returns: None. + """ + self._dotnet = subscription_content + + @property + def id(self) -> UUID: + """The ID of the content item tied to the subscription.""" + return None if self._dotnet.Id is None else UUID(self._dotnet.Id.ToString()) + + @id.setter + def id(self, value: UUID) -> None: + """The ID of the content item tied to the subscription.""" + self._dotnet.Id = None if value is None else Guid.Parse(str(value)) + + @property + def type(self) -> str: + """The content type of the subscription.""" + return self._dotnet.Type + + @type.setter + def type(self, value: str) -> None: + """The content type of the subscription.""" + self._dotnet.Type = value + + @property + def send_if_view_empty(self) -> bool: + """Whether or not send the notification if the view is empty.""" + return self._dotnet.SendIfViewEmpty + + @send_if_view_empty.setter + def send_if_view_empty(self, value: bool) -> None: + """Whether or not send the notification if the view is empty.""" + self._dotnet.SendIfViewEmpty = value + +class PySubscription(Generic[TSchedule], PyWithSchedule[TSchedule], PyWithOwner): + """Interface for a subscription.""" + + _dotnet_base = ISubscription + + def __init__(self, subscription: ISubscription) -> None: + """Creates a new PySubscription object. + + Args: + subscription: A ISubscription object. + + Returns: None. + """ + self._dotnet = subscription + + @property + def subject(self) -> str: + """Gets or sets the subject of the subscription.""" + return self._dotnet.Subject + + @subject.setter + def subject(self, value: str) -> None: + """Gets or sets the subject of the subscription.""" + self._dotnet.Subject = value + + @property + def attach_image(self) -> bool: + """Gets or sets whether or not an image file should be attached to the notification.""" + return self._dotnet.AttachImage + + @attach_image.setter + def attach_image(self, value: bool) -> None: + """Gets or sets whether or not an image file should be attached to the notification.""" + self._dotnet.AttachImage = value + + @property + def attach_pdf(self) -> bool: + """Gets or sets whether or not a pdf file should be attached to the notification.""" + return self._dotnet.AttachPdf + + @attach_pdf.setter + def attach_pdf(self, value: bool) -> None: + """Gets or sets whether or not a pdf file should be attached to the notification.""" + self._dotnet.AttachPdf = value + + @property + def page_orientation(self) -> str: + """Gets or set the page orientation of the subscription.""" + return self._dotnet.PageOrientation + + @page_orientation.setter + def page_orientation(self, value: str) -> None: + """Gets or set the page orientation of the subscription.""" + self._dotnet.PageOrientation = value + + @property + def page_size_option(self) -> str: + """Gets or set the page page size option of the subscription.""" + return self._dotnet.PageSizeOption + + @page_size_option.setter + def page_size_option(self, value: str) -> None: + """Gets or set the page page size option of the subscription.""" + self._dotnet.PageSizeOption = value + + @property + def suspended(self) -> bool: + """Gets or sets whether or not the subscription is suspended.""" + return self._dotnet.Suspended + + @suspended.setter + def suspended(self, value: bool) -> None: + """Gets or sets whether or not the subscription is suspended.""" + self._dotnet.Suspended = value + + @property + def message(self) -> str: + """Gets or sets the message of the subscription.""" + return self._dotnet.Message + + @message.setter + def message(self, value: str) -> None: + """Gets or sets the message of the subscription.""" + self._dotnet.Message = value + + @property + def content(self) -> PySubscriptionContent: + """Gets or set the content reference of the subscription.""" + return None if self._dotnet.Content is None else PySubscriptionContent(self._dotnet.Content) + + @content.setter + def content(self, value: PySubscriptionContent) -> None: + """Gets or set the content reference of the subscription.""" + self._dotnet.Content = None if value is None else value._dotnet + +class PyUserAuthenticationType(): + """Structure representing the authentication type of a user.""" + + _dotnet_base = UserAuthenticationType + + def __init__(self, user_authentication_type: UserAuthenticationType) -> None: + """Creates a new PyUserAuthenticationType object. + + Args: + user_authentication_type: A UserAuthenticationType object. + + Returns: None. + """ + self._dotnet = user_authentication_type + + @classmethod + def get_default(cls) -> Self: + """Gets a value representing the site default authentication type.""" + return None if UserAuthenticationType.Default is None else PyUserAuthenticationType(UserAuthenticationType.Default) + + @property + def authentication_type(self) -> str: + """Gets the authentication type, or null if the site uses IdpConfigurationIds.""" + return self._dotnet.AuthenticationType + + @property + def idp_configuration_id(self) -> UUID: + """Gets the IdP configuration ID, or null if the site uses AuthenticationTypes.""" + return None if self._dotnet.IdpConfigurationId is None else UUID(self._dotnet.IdpConfigurationId.ToString()) + + @classmethod + def for_authentication_type(cls, authentication_type: str) -> Self: + """Creates a new UserAuthenticationType value. + + Args: + authentication_type: The authentication type. + + Returns: The created UserAuthenticationType value. + """ + result = UserAuthenticationType.ForAuthenticationType(authentication_type) + return None if result is None else PyUserAuthenticationType(result) + + @classmethod + def for_configuration_id(cls, idp_configuration_id: UUID) -> Self: + """Creates a new UserAuthenticationType value. + + Args: + idp_configuration_id: The IdP configuration ID. + + Returns: The created UserAuthenticationType value. + """ + result = UserAuthenticationType.ForConfigurationId(None if idp_configuration_id is None else Guid.Parse(str(idp_configuration_id))) + return None if result is None else PyUserAuthenticationType(result) + class PyUser(PyUsernameContent): """Interface for a user content item.""" @@ -879,6 +1152,16 @@ def authentication_type(self, value: str) -> None: """Gets or sets the authentication type of the user, or null to not send an explicit authentication type for the user during migration.""" self._dotnet.AuthenticationType = value + @property + def authentication(self) -> PyUserAuthenticationType: + """Gets or sets the authentication type of the user. Use Default to use either the default authentication type of the site.""" + return None if self._dotnet.Authentication is None else PyUserAuthenticationType(self._dotnet.Authentication) + + @authentication.setter + def authentication(self, value: PyUserAuthenticationType) -> None: + """Gets or sets the authentication type of the user. Use Default to use either the default authentication type of the site.""" + self._dotnet.Authentication = None if value is None else value._dotnet + @property def administrator_level(self) -> str: """Gets the user's administrator level derived from SiteRole.""" diff --git a/src/Python/src/tableau_migration/migration_engine.py b/src/Python/src/tableau_migration/migration_engine.py index 35510532..7ffff8d2 100644 --- a/src/Python/src/tableau_migration/migration_engine.py +++ b/src/Python/src/tableau_migration/migration_engine.py @@ -20,6 +20,7 @@ from uuid import UUID from tableau_migration.migration import ( + PyPipelineProfile, get_service_provider, get_service ) @@ -60,13 +61,11 @@ def __init__(self, migration_plan: IMigrationPlan) -> None: def plan_id(self) -> UUID: """Gets the per-plan options to supply.""" return UUID(self._migration_plan.PlanId.ToString()) - @property - def pipeline_profile(self): + def pipeline_profile(self) -> PyPipelineProfile: """Gets the profile of the pipeline that will be built and executed.""" - return self._migration_plan.PipelineProfile # TODO: PipelineProfile needs python wrapper - + return self._migration_plan.PipelineProfile @property def options(self): @@ -148,6 +147,11 @@ def mappings(self) -> PyContentMappingBuilder: """Gets the mappings to execute at various points during the migration.""" return self._mappings + @property + def pipeline_profile(self) -> PyPipelineProfile: + """Gets the profile of the pipeline that will be built and executed.""" + return self._plan_builder.PipelineProfile + def from_source_tableau_server(self, server_url: str, site_content_url: str, access_token_name: str, access_token: str, create_api_simulator: bool = False) -> Self: """Sets or overwrites the configuration for the source Tableau Server site to migrate content from. @@ -248,33 +252,35 @@ def append_default_server_to_cloud_extensions(self) -> Self: self._plan_builder.AppendDefaultServerToCloudExtensions() return self - def with_saml_authentication_type(self, domain: str) -> Self: + def with_saml_authentication_type(self, domain: str, idp_configuration_name: Union[str, None] = None) -> Self: """Adds an object to map user and group domains based on the SAML authentication type. Args: domain: The domain to map users and groups to. + idp_configuration_name: The IdP configuration name for the authentication type to assign to users. Should be null when the destination site is Tableau Server or has a single authentication configuration. Should be non-null when the destination site is Tableau Cloud and has multiple authentication configurations. Returns: The same plan builder object for fluent API calls. """ - self._plan_builder.WithSamlAuthenticationType(domain) + self._plan_builder.WithSamlAuthenticationType(domain, None if idp_configuration_name is None else idp_configuration_name) return self - def with_tableau_id_authentication_type(self, mfa: bool = True) -> Self: + def with_tableau_id_authentication_type(self, mfa: bool = True, idp_configuration_name: Union[str, None] = None) -> Self: """Adds an object to map user and group domains based on the Tableau ID authentication type. Args: mfa: Whether or not MFA is used, defaults to true. + idp_configuration_name: The IdP configuration name for the authentication type to assign to users. Should be null when the destination site is Tableau Server or has a single authentication configuration. Should be non-null when the destination site is Tableau Cloud and has multiple authentication configurations. Returns: The same plan builder object for fluent API calls. """ - self._plan_builder.WithTableauIdAuthenticationType(mfa) + self._plan_builder.WithTableauIdAuthenticationType(mfa, None if idp_configuration_name is None else idp_configuration_name) return self def with_authentication_type(self, authentication_type: str, input_1, group_domain) -> Self: """Adds an object to map user and group domains based on the destination authentication type. Args: - authentication_type: An authentication type to assign to users. + authentication_type: The authentication type to assign to users. For sites without multiple authentication types an authSetting value from https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_users_and_groups.htm#add_user_to_site should be used. If the site has multiple authentication types the IdP configuration name shown in the authentication configuration list should be used. input_1: Either 1) the domain to map users to, or 2) the mapping to execute. group_domain: The domain to map users to. Not used with input_1 in a mapping object. @@ -331,6 +337,7 @@ def __init__(self) -> None: self._transformers = PyContentTransformerBuilder(self._plan_builder.Transformers) self._options = PyMigrationPlanOptionsBuilder(self._plan_builder.Options) + @property def filters(self) -> PyContentFilterBuilder: """Gets the filters to execute at various points during the migration.""" @@ -356,6 +363,10 @@ def mappings(self) -> PyContentMappingBuilder: """Gets the mappings to execute at various points during the migration.""" return self._mappings + def pipeline_profile(self) -> PyPipelineProfile: + """Gets the pipeline profile to execute.""" + return self._plan_builder.PipelineProfile + def from_source_tableau_server(self, server_url: str, site_content_url: str, access_token_name: str, access_token: str, create_api_simulator: bool = False) -> Self: """Sets or overwrites the configuration for the source Tableau Server site to migrate content from. diff --git a/src/Python/src/tableau_migration/migration_engine_manifest.py b/src/Python/src/tableau_migration/migration_engine_manifest.py index 98b87fc4..9f858b03 100644 --- a/src/Python/src/tableau_migration/migration_engine_manifest.py +++ b/src/Python/src/tableau_migration/migration_engine_manifest.py @@ -151,6 +151,11 @@ def errors(self) -> Sequence[System.Exception]: """Gets errors that occurred while migrating the content item.""" return None if self._dotnet.Errors is None else list(self._dotnet.Errors) + @property + def skipped_reason(self) -> str: + """Gets the reason why the content item was skipped, if applicable.""" + return self._dotnet.SkippedReason + class PyMigrationManifestEntryEditor(PyMigrationManifestEntry): """Interface for a IMigrationManifestEntry that can be edited.""" @@ -196,12 +201,15 @@ def destination_found(self, destination_info: PyContentReference) -> Self: result = self._dotnet.DestinationFound(None if destination_info is None else destination_info._dotnet) return None if result is None else PyMigrationManifestEntryEditor(result) - def set_skipped(self) -> Self: + def set_skipped(self, skipped_reason: str) -> Self: """Sets the entry to skipped status. + Args: + skipped_reason: Reason this item was skipped. Generally the skipped filter name. + Returns: The current entry editor, for fluent API usage. """ - result = self._dotnet.SetSkipped() + result = self._dotnet.SetSkipped(skipped_reason) return None if result is None else PyMigrationManifestEntryEditor(result) def set_canceled(self) -> Self: diff --git a/src/Python/src/tableau_migration/migration_engine_pipelines.py b/src/Python/src/tableau_migration/migration_engine_pipelines.py index 5fe8f76b..e7f03dc8 100644 --- a/src/Python/src/tableau_migration/migration_engine_pipelines.py +++ b/src/Python/src/tableau_migration/migration_engine_pipelines.py @@ -17,6 +17,7 @@ # region _generated +from tableau_migration.migration import PyPipelineProfile # noqa: E402, F401 from typing import Sequence # noqa: E402, F401 from typing_extensions import Self # noqa: E402, F401 @@ -72,29 +73,59 @@ def get_views(cls) -> Self: """Gets the views MigrationPipelineContentType.""" return None if MigrationPipelineContentType.Views is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Views) + @classmethod + def get_server_to_server_extract_refresh_tasks(cls) -> Self: + """Gets the Server to Server extract refresh tasks MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.ServerToServerExtractRefreshTasks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToServerExtractRefreshTasks) + @classmethod def get_server_to_cloud_extract_refresh_tasks(cls) -> Self: """Gets the Server to Cloud extract refresh tasks MigrationPipelineContentType.""" return None if MigrationPipelineContentType.ServerToCloudExtractRefreshTasks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToCloudExtractRefreshTasks) + @classmethod + def get_cloud_to_cloud_extract_refresh_tasks(cls) -> Self: + """Gets the Cloud to Cloud extract refresh tasks MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.CloudToCloudExtractRefreshTasks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.CloudToCloudExtractRefreshTasks) + @classmethod def get_custom_views(cls) -> Self: """Gets the custom views MigrationPipelineContentType.""" return None if MigrationPipelineContentType.CustomViews is None else PyMigrationPipelineContentType(MigrationPipelineContentType.CustomViews) + @classmethod + def get_server_to_server_subscriptions(cls) -> Self: + """Gets the Server to Server subscriptions MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.ServerToServerSubscriptions is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToServerSubscriptions) + + @classmethod + def get_server_to_cloud_subscriptions(cls) -> Self: + """Gets the Server to Cloud subscriptions MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.ServerToCloudSubscriptions is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToCloudSubscriptions) + + @classmethod + def get_cloud_to_cloud_subscriptions(cls) -> Self: + """Gets the Cloud to Cloud subscriptions MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.CloudToCloudSubscriptions is None else PyMigrationPipelineContentType(MigrationPipelineContentType.CloudToCloudSubscriptions) + @property def content_type(self) -> System.Type: - """The content type.""" + """The content type. Content type is returned from list step, pre-pull.""" return self._dotnet.ContentType + @property + def prepare_type(self) -> System.Type: + """Gets the preparation type that is pulled and converted for publishing. The Prepare type is the post-pull, pre-conversion type.""" + return self._dotnet.PrepareType + @property def publish_type(self) -> System.Type: - """Gets the publish type.""" + """Gets the publish type. The publish type is post-conversion, ready to publish.""" return self._dotnet.PublishType @property def result_type(self) -> System.Type: - """Gets the result type.""" + """Gets the result type returned by publishing.""" return self._dotnet.ResultType @property @@ -122,6 +153,37 @@ def get_config_key_for_type(cls, content_type: System.Type) -> str: result = MigrationPipelineContentType.GetConfigKeyForType(content_type) return result + @classmethod + def get_display_name_for_type(cls, content_type: System.Type, plural: bool) -> str: + """Gets the friendly display name for a content type. + + Args: + content_type: The content type. + plural: Whether the display name should be in plural form. + + Returns: The display name string. + """ + result = MigrationPipelineContentType.GetDisplayNameForType(content_type, plural) + return result + + @classmethod + def get_migration_pipeline_content_types(cls, profile: PyPipelineProfile) -> Sequence[Self]: + """Gets the content types for a given profile. + + Args: + profile: Profile to get the types for. + + Returns: Array of content types supported by the given pipeline profile. + """ + result = MigrationPipelineContentType.GetMigrationPipelineContentTypes(profile) + return None if result is None else list((None if x is None else PyMigrationPipelineContentType(x)) for x in result) + + @classmethod + def get_all_migration_pipeline_content_types(cls) -> Sequence[Self]: + """Gets all static instances of MigrationPipelineContentType.""" + result = MigrationPipelineContentType.GetAllMigrationPipelineContentTypes() + return None if result is None else list((None if x is None else PyMigrationPipelineContentType(x)) for x in result) + class PyServerToCloudMigrationPipeline(): """IMigrationPipeline implementation to perform migrations from Tableau Server to Tableau Cloud.""" diff --git a/src/Python/tests/test_classes.py b/src/Python/tests/test_classes.py index 01443be4..85bc398c 100644 --- a/src/Python/tests/test_classes.py +++ b/src/Python/tests/test_classes.py @@ -224,6 +224,7 @@ def test_overloaded_missing(self): PyContentLocation, PyContentReference, PyMigrationCompletionStatus, + PyPipelineProfile, PyResult ) @@ -247,6 +248,7 @@ def test_overloaded_missing(self): ) from tableau_migration.migration_content import ( # noqa: E402, F401 + PyCloudSubscription, PyConnection, PyConnectionsContent, PyContainerContent, @@ -264,8 +266,12 @@ def test_overloaded_missing(self): PyPublishableGroup, PyPublishableWorkbook, PyPublishedContent, + PyServerSubscription, + PySubscription, + PySubscriptionContent, PyTag, PyUser, + PyUserAuthenticationType, PyUsernameContent, PyView, PyWithDomain, @@ -324,6 +330,7 @@ def test_overloaded_missing(self): from Tableau.Migration import MigrationCompletionStatus +from Tableau.Migration import PipelineProfile from Tableau.Migration.Api.Rest.Models import AdministratorLevels from Tableau.Migration.Api.Rest.Models import ContentPermissions from Tableau.Migration.Api.Rest.Models import ExtractEncryptionModes @@ -344,6 +351,7 @@ def test_overloaded_missing(self): (PyContentReference, None), (PyResult, [ "CastFailure" ]), (PyRestIdentifiable, None), + (PyCloudSubscription, [ "AttachImage", "AttachPdf", "Content", "Message", "PageOrientation", "PageSizeOption", "Schedule", "Subject", "Suspended" ]), (PyConnection, None), (PyConnectionsContent, None), (PyContainerContent, None), @@ -361,8 +369,12 @@ def test_overloaded_missing(self): (PyPublishableGroup, [ "SetLocation" ]), (PyPublishableWorkbook, [ "ChildPermissionContentItems", "ChildType", "DisposeAsync", "File", "SetLocation", "ShouldMigrateChildPermissions" ]), (PyPublishedContent, None), + (PyServerSubscription, [ "AttachImage", "AttachPdf", "Content", "Message", "PageOrientation", "PageSizeOption", "Schedule", "Subject", "Suspended" ]), + (PySubscription, None), + (PySubscriptionContent, None), (PyTag, None), (PyUser, [ "SetLocation" ]), + (PyUserAuthenticationType, None), (PyUsernameContent, [ "SetLocation" ]), (PyView, None), (PyWithDomain, None), @@ -390,12 +402,13 @@ def test_overloaded_missing(self): (PyMigrationManifestEntryEditor, [ "SetFailed" ]), (PyContentItemMigrationResult, [ "CastFailure" ]), (PyContentBatchMigrationResult, [ "CastFailure" ]), - (PyMigrationPipelineContentType, [ "GetContentTypeForInterface", "GetPostPublishTypesForInterface", "GetPublishTypeForInterface", "WithPublishType", "WithResultType" ]), - (PyServerToCloudMigrationPipeline, [ "BuildActions", "BuildPipeline", "CreateDestinationCache", "CreateSourceCache", "GetBatchMigrator", "GetDestinationLockedProjectCache", "GetItemPreparer", "GetMigrator" ]) + (PyMigrationPipelineContentType, [ "GetContentTypeForInterface", "GetPostPublishTypesForInterface", "GetPublishTypeForInterface", "WithPrepareType", "WithPublishType", "WithResultType" ]), + (PyServerToCloudMigrationPipeline, [ "BuildActions", "BuildPipeline", "CreateDestinationCache", "CreateSourceCache", "GetBatchMigrator", "GetDestinationLockedProjectCache", "GetItemConverter", "GetItemPreparer", "GetMigrator" ]) ] _generated_enum_data = [ (PyMigrationCompletionStatus, MigrationCompletionStatus), + (PyPipelineProfile, PipelineProfile), (PyAdministratorLevels, AdministratorLevels), (PyContentPermissions, ContentPermissions), (PyExtractEncryptionModes, ExtractEncryptionModes), diff --git a/src/Python/tests/test_migration.py b/src/Python/tests/test_migration.py index 53c3185b..62690ebc 100644 --- a/src/Python/tests/test_migration.py +++ b/src/Python/tests/test_migration.py @@ -164,11 +164,13 @@ def test_path_segments(self): PyContentLocation, PyContentReference, PyMigrationCompletionStatus, + PyPipelineProfile, PyResult ) from Tableau.Migration import MigrationCompletionStatus +from Tableau.Migration import PipelineProfile # Extra imports for tests. from tests.helpers.autofixture import AutoFixtureTestBase # noqa: E402, F401 diff --git a/src/Python/tests/test_migration_content.py b/src/Python/tests/test_migration_content.py index f1f364da..4acf5c4f 100644 --- a/src/Python/tests/test_migration_content.py +++ b/src/Python/tests/test_migration_content.py @@ -121,12 +121,19 @@ def test_setter_empty(self): assert len(py.tags) == 0 # region _generated -from tableau_migration.migration import PyContentReference # noqa: E402, F401 +from tableau_migration.migration import ( # noqa: E402, F401 + PyContentReference, + _generic_wrapper +) from tableau_migration.migration_api_rest import PyRestIdentifiable # noqa: E402, F401 +from tableau_migration.migration_content_schedules import PyWithSchedule # noqa: E402, F401 from typing import ( # noqa: E402, F401 Sequence, - List + List, + Generic, + TypeVar ) +from typing_extensions import Self # noqa: E402, F401 from uuid import UUID # noqa: E402, F401 from System import ( # noqa: E402, F401 @@ -138,6 +145,7 @@ def test_setter_empty(self): HashSet as DotnetHashSet ) from Tableau.Migration.Content import ( # noqa: E402, F401 + ICloudSubscription, IConnection, IConnectionsContent, IContainerContent, @@ -155,6 +163,9 @@ def test_setter_empty(self): IPublishableGroup, IPublishableWorkbook, IPublishedContent, + IServerSubscription, + ISubscription, + ISubscriptionContent, ITag, IUser, IUsernameContent, @@ -164,10 +175,12 @@ def test_setter_empty(self): IWithTags, IWithWorkbook, IWorkbook, - IWorkbookDetails + IWorkbookDetails, + UserAuthenticationType ) from tableau_migration.migration_content import ( # noqa: E402, F401 + PyCloudSubscription, PyConnection, PyConnectionsContent, PyContainerContent, @@ -185,8 +198,12 @@ def test_setter_empty(self): PyPublishableGroup, PyPublishableWorkbook, PyPublishedContent, + PyServerSubscription, + PySubscription, + PySubscriptionContent, PyTag, PyUser, + PyUserAuthenticationType, PyUsernameContent, PyView, PyWithDomain, @@ -204,6 +221,8 @@ def test_setter_empty(self): Boolean, Nullable ) +from Tableau.Migration.Content.Schedules import ISchedule # noqa: E402, F401 +from tableau_migration.migration_content_schedules import PySchedule # noqa: E402, F401 from tests.helpers.autofixture import AutoFixtureTestBase # noqa: E402, F401 class TestPyConnectionGenerated(AutoFixtureTestBase): @@ -243,6 +262,21 @@ def test_query_tagging_enabled_getter(self): py = PyConnection(dotnet) assert py.query_tagging_enabled == dotnet.QueryTaggingEnabled + def test_authentication_type_getter(self): + dotnet = self.create(IConnection) + py = PyConnection(dotnet) + assert py.authentication_type == dotnet.AuthenticationType + + def test_use_o_auth_managed_keychain_getter(self): + dotnet = self.create(IConnection) + py = PyConnection(dotnet) + assert py.use_o_auth_managed_keychain == dotnet.UseOAuthManagedKeychain + + def test_embed_password_getter(self): + dotnet = self.create(IConnection) + py = PyConnection(dotnet) + assert py.embed_password == dotnet.EmbedPassword + class TestPyConnectionsContentGenerated(AutoFixtureTestBase): def test_ctor(self): @@ -256,6 +290,21 @@ def test_connections_getter(self): assert len(dotnet.Connections) != 0 assert len(py.connections) == len(dotnet.Connections) + def test_has_embedded_password_getter(self): + dotnet = self.create(IConnectionsContent) + py = PyConnectionsContent(dotnet) + assert py.has_embedded_password == dotnet.HasEmbeddedPassword + + def test_has_embedded_o_auth_managed_keychain_getter(self): + dotnet = self.create(IConnectionsContent) + py = PyConnectionsContent(dotnet) + assert py.has_embedded_o_auth_managed_keychain == dotnet.HasEmbeddedOAuthManagedKeychain + + def test_has_embedded_o_auth_credentials_getter(self): + dotnet = self.create(IConnectionsContent) + py = PyConnectionsContent(dotnet) + assert py.has_embedded_o_auth_credentials == dotnet.HasEmbeddedOAuthCredentials + class TestPyContainerContentGenerated(AutoFixtureTestBase): def test_ctor(self): @@ -713,6 +762,218 @@ def test_webpage_url_getter(self): py = PyPublishedContent(dotnet) assert py.webpage_url == dotnet.WebpageUrl +class TestPySubscriptionGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py._dotnet == dotnet + + def test_subject_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.subject == dotnet.Subject + + def test_subject_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(String) + + # set property to new test value + py.subject = testValue + + # assert value + assert py.subject == testValue + + def test_attach_image_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.attach_image == dotnet.AttachImage + + def test_attach_image_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(Boolean) + + # set property to new test value + py.attach_image = testValue + + # assert value + assert py.attach_image == testValue + + def test_attach_pdf_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.attach_pdf == dotnet.AttachPdf + + def test_attach_pdf_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(Boolean) + + # set property to new test value + py.attach_pdf = testValue + + # assert value + assert py.attach_pdf == testValue + + def test_page_orientation_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.page_orientation == dotnet.PageOrientation + + def test_page_orientation_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(String) + + # set property to new test value + py.page_orientation = testValue + + # assert value + assert py.page_orientation == testValue + + def test_page_size_option_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.page_size_option == dotnet.PageSizeOption + + def test_page_size_option_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(String) + + # set property to new test value + py.page_size_option = testValue + + # assert value + assert py.page_size_option == testValue + + def test_suspended_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.suspended == dotnet.Suspended + + def test_suspended_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(Boolean) + + # set property to new test value + py.suspended = testValue + + # assert value + assert py.suspended == testValue + + def test_message_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.message == dotnet.Message + + def test_message_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(String) + + # set property to new test value + py.message = testValue + + # assert value + assert py.message == testValue + + def test_content_getter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + assert py.content == None if dotnet.Content is None else PySubscriptionContent(dotnet.Content) + + def test_content_setter(self): + dotnet = self.create(ISubscription[ISchedule]) + py = PySubscription[PySchedule](dotnet) + + # create test data + testValue = self.create(ISubscriptionContent) + + # set property to new test value + py.content = None if testValue is None else PySubscriptionContent(testValue) + + # assert value + assert py.content == None if testValue is None else PySubscriptionContent(testValue) + +class TestPySubscriptionContentGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(ISubscriptionContent) + py = PySubscriptionContent(dotnet) + assert py._dotnet == dotnet + + def test_id_getter(self): + dotnet = self.create(ISubscriptionContent) + py = PySubscriptionContent(dotnet) + assert py.id == None if dotnet.Id is None else UUID(dotnet.Id.ToString()) + + def test_id_setter(self): + dotnet = self.create(ISubscriptionContent) + py = PySubscriptionContent(dotnet) + + # create test data + testValue = self.create(Guid) + + # set property to new test value + py.id = None if testValue is None else UUID(testValue.ToString()) + + # assert value + assert py.id == None if testValue is None else UUID(testValue.ToString()) + + def test_type_getter(self): + dotnet = self.create(ISubscriptionContent) + py = PySubscriptionContent(dotnet) + assert py.type == dotnet.Type + + def test_type_setter(self): + dotnet = self.create(ISubscriptionContent) + py = PySubscriptionContent(dotnet) + + # create test data + testValue = self.create(String) + + # set property to new test value + py.type = testValue + + # assert value + assert py.type == testValue + + def test_send_if_view_empty_getter(self): + dotnet = self.create(ISubscriptionContent) + py = PySubscriptionContent(dotnet) + assert py.send_if_view_empty == dotnet.SendIfViewEmpty + + def test_send_if_view_empty_setter(self): + dotnet = self.create(ISubscriptionContent) + py = PySubscriptionContent(dotnet) + + # create test data + testValue = self.create(Boolean) + + # set property to new test value + py.send_if_view_empty = testValue + + # assert value + assert py.send_if_view_empty == testValue + class TestPyTagGenerated(AutoFixtureTestBase): def test_ctor(self): @@ -817,6 +1078,24 @@ def test_authentication_type_setter(self): # assert value assert py.authentication_type == testValue + def test_authentication_getter(self): + dotnet = self.create(IUser) + py = PyUser(dotnet) + assert py.authentication == None if dotnet.Authentication is None else PyUserAuthenticationType(dotnet.Authentication) + + def test_authentication_setter(self): + dotnet = self.create(IUser) + py = PyUser(dotnet) + + # create test data + testValue = self.create(UserAuthenticationType) + + # set property to new test value + py.authentication = None if testValue is None else PyUserAuthenticationType(testValue) + + # assert value + assert py.authentication == None if testValue is None else PyUserAuthenticationType(testValue) + def test_administrator_level_getter(self): dotnet = self.create(IUser) py = PyUser(dotnet) @@ -832,6 +1111,18 @@ def test_can_publish_getter(self): py = PyUser(dotnet) assert py.can_publish == dotnet.CanPublish +class TestPyViewGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(IView) + py = PyView(dotnet) + assert py._dotnet == dotnet + + def test_parent_workbook_getter(self): + dotnet = self.create(IView) + py = PyView(dotnet) + assert py.parent_workbook == None if dotnet.ParentWorkbook is None else PyContentReference(dotnet.ParentWorkbook) + class TestPyWithDomainGenerated(AutoFixtureTestBase): def test_ctor(self): @@ -968,6 +1259,28 @@ def test_views_getter(self): assert len(dotnet.Views) != 0 assert len(py.views) == len(dotnet.Views) +class TestPyUserAuthenticationTypeGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(UserAuthenticationType) + py = PyUserAuthenticationType(dotnet) + assert py._dotnet == dotnet + + def test_default_getter(self): + dotnet = self.create(UserAuthenticationType) + py = PyUserAuthenticationType(dotnet) + assert py.get_default() == None if UserAuthenticationType.Default is None else PyUserAuthenticationType(UserAuthenticationType.Default) + + def test_authentication_type_getter(self): + dotnet = self.create(UserAuthenticationType) + py = PyUserAuthenticationType(dotnet) + assert py.authentication_type == dotnet.AuthenticationType + + def test_idp_configuration_id_getter(self): + dotnet = self.create(UserAuthenticationType) + py = PyUserAuthenticationType(dotnet) + assert py.idp_configuration_id == None if dotnet.IdpConfigurationId is None else UUID(dotnet.IdpConfigurationId.ToString()) + # endregion diff --git a/src/Python/tests/test_migration_engine.py b/src/Python/tests/test_migration_engine.py index c439074c..55afad2b 100644 --- a/src/Python/tests/test_migration_engine.py +++ b/src/Python/tests/test_migration_engine.py @@ -16,16 +16,58 @@ # Make sure the test can find the module import pytest -from tableau_migration.migration_engine import ( - PyServerToCloudMigrationPlanBuilder) +from tableau_migration import ( + ContentMappingContext as PyContentMappingContext, + IUser as PyUser, + MigrationPlanBuilder, + TableauCloudUsernameMappingBase) -from Tableau.Migration import ( - IServerToCloudMigrationPlanBuilder) +from tableau_migration.migration_engine import PyServerToCloudMigrationPlanBuilder +from System import IServiceProvider +from System.Threading import CancellationToken +from Tableau.Migration import IServerToCloudMigrationPlanBuilder +from Tableau.Migration.Content import IUser +from Tableau.Migration.Engine.Hooks import IMigrationHook +from Tableau.Migration.Engine.Hooks.Mappings import ( + ContentMappingContext, + IContentMapping) + +from tests.helpers.autofixture import AutoFixtureTestBase import Moq -class TestPyServerToCloudMigrationPlanBuilder: - def test_with_authentication_type_string_arg(self): +class TestUsernameMapping(TableauCloudUsernameMappingBase): + """Mapping that takes a base email and appends the source item name to the email username.""" + + def map(self, ctx: PyContentMappingContext[PyUser]) -> PyContentMappingContext[PyUser]: # noqa: N802 + return ctx + +class TestPyServerToCloudMigrationPlanBuilder(AutoFixtureTestBase): + def test_with_saml_authentication_type_auth_type(self): + mockBuilder = Moq.Mock[IServerToCloudMigrationPlanBuilder]() + builder = PyServerToCloudMigrationPlanBuilder(mockBuilder.Object) + + builder.with_saml_authentication_type("userDomain") + + def test_with_saml_authentication_type_idp_name(self): + mockBuilder = Moq.Mock[IServerToCloudMigrationPlanBuilder]() + builder = PyServerToCloudMigrationPlanBuilder(mockBuilder.Object) + + builder.with_saml_authentication_type("userDomain", "idp name") + + def test_with_tableau_id_authentication_type_auth_type(self): + mockBuilder = Moq.Mock[IServerToCloudMigrationPlanBuilder]() + builder = PyServerToCloudMigrationPlanBuilder(mockBuilder.Object) + + builder.with_tableau_id_authentication_type() + + def test_with_tableau_id_authentication_type_idp_name(self): + mockBuilder = Moq.Mock[IServerToCloudMigrationPlanBuilder]() + builder = PyServerToCloudMigrationPlanBuilder(mockBuilder.Object) + + builder.with_tableau_id_authentication_type(True, "idp name") + + def test_with_authentication_type_string_arg_auth_type(self): mockBuilder = Moq.Mock[IServerToCloudMigrationPlanBuilder]() builder = PyServerToCloudMigrationPlanBuilder(mockBuilder.Object) @@ -36,3 +78,18 @@ def test_with_tableau_cloud_usernames_string_arg(self): builder = PyServerToCloudMigrationPlanBuilder(mockBuilder.Object) builder.with_tableau_cloud_usernames("test.com") + + def test_with_tableau_cloud_usernames_class(self): + builder = MigrationPlanBuilder() + + builder = builder.for_server_to_cloud().with_tableau_cloud_usernames(TestUsernameMapping) + + hook_builder = builder.mappings.build() + hook_factories = hook_builder.get_hooks(IContentMapping[IUser]) + + services = self.create(IServiceProvider) + hook = hook_factories[0].Create[IMigrationHook[ContentMappingContext[IUser]]](services) + + map_ctx = self.create(ContentMappingContext[IUser]) + hook_result = hook.ExecuteAsync(map_ctx, CancellationToken(False)).GetAwaiter().GetResult() + diff --git a/src/Python/tests/test_migration_engine_pipelines.py b/src/Python/tests/test_migration_engine_pipelines.py index 265e7a70..120ef7cb 100644 --- a/src/Python/tests/test_migration_engine_pipelines.py +++ b/src/Python/tests/test_migration_engine_pipelines.py @@ -1,5 +1,6 @@ # region _generated +from tableau_migration.migration import PyPipelineProfile # noqa: E402, F401 from typing import Sequence # noqa: E402, F401 from typing_extensions import Self # noqa: E402, F401 @@ -56,21 +57,51 @@ def test_views_getter(self): py = PyMigrationPipelineContentType(dotnet) assert py.get_views() == None if MigrationPipelineContentType.Views is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Views) + def test_server_to_server_extract_refresh_tasks_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_server_to_server_extract_refresh_tasks() == None if MigrationPipelineContentType.ServerToServerExtractRefreshTasks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToServerExtractRefreshTasks) + def test_server_to_cloud_extract_refresh_tasks_getter(self): dotnet = self.create(MigrationPipelineContentType) py = PyMigrationPipelineContentType(dotnet) assert py.get_server_to_cloud_extract_refresh_tasks() == None if MigrationPipelineContentType.ServerToCloudExtractRefreshTasks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToCloudExtractRefreshTasks) + def test_cloud_to_cloud_extract_refresh_tasks_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_cloud_to_cloud_extract_refresh_tasks() == None if MigrationPipelineContentType.CloudToCloudExtractRefreshTasks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.CloudToCloudExtractRefreshTasks) + def test_custom_views_getter(self): dotnet = self.create(MigrationPipelineContentType) py = PyMigrationPipelineContentType(dotnet) assert py.get_custom_views() == None if MigrationPipelineContentType.CustomViews is None else PyMigrationPipelineContentType(MigrationPipelineContentType.CustomViews) + def test_server_to_server_subscriptions_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_server_to_server_subscriptions() == None if MigrationPipelineContentType.ServerToServerSubscriptions is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToServerSubscriptions) + + def test_server_to_cloud_subscriptions_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_server_to_cloud_subscriptions() == None if MigrationPipelineContentType.ServerToCloudSubscriptions is None else PyMigrationPipelineContentType(MigrationPipelineContentType.ServerToCloudSubscriptions) + + def test_cloud_to_cloud_subscriptions_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_cloud_to_cloud_subscriptions() == None if MigrationPipelineContentType.CloudToCloudSubscriptions is None else PyMigrationPipelineContentType(MigrationPipelineContentType.CloudToCloudSubscriptions) + def test_content_type_getter(self): dotnet = self.create(MigrationPipelineContentType) py = PyMigrationPipelineContentType(dotnet) assert py.content_type == dotnet.ContentType + def test_prepare_type_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.prepare_type == dotnet.PrepareType + def test_publish_type_getter(self): dotnet = self.create(MigrationPipelineContentType) py = PyMigrationPipelineContentType(dotnet) diff --git a/src/Tableau.Migration.PythonGenerator/GenerationListHelper.cs b/src/Tableau.Migration.PythonGenerator/GenerationListHelper.cs index 63632a56..a9d723cf 100644 --- a/src/Tableau.Migration.PythonGenerator/GenerationListHelper.cs +++ b/src/Tableau.Migration.PythonGenerator/GenerationListHelper.cs @@ -15,15 +15,16 @@ // limitations under the License. // +using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using System; namespace Tableau.Migration.PythonGenerator { internal static class GenerationListHelper { - internal static ImmutableHashSet ToTypeNameHash(params Type[] types) + internal static ImmutableHashSet ToTypeNameHash(params IEnumerable types) => types.Select(t => t.FullName!).ToImmutableHashSet(); } } \ No newline at end of file diff --git a/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs b/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs index 2c7b6b01..7dc1c4f8 100644 --- a/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs +++ b/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs @@ -62,7 +62,7 @@ internal abstract class PythonMemberGenerator Dotnet.Namespaces.SYSTEM_EXCEPTION, Dotnet.Namespaces.SYSTEM, ConversionMode.Direct); - + private static readonly PythonTypeReference TIME_ONLY = new( Py.Types.TIME, ImportModule: Py.Modules.DATETIME, @@ -72,7 +72,7 @@ internal abstract class PythonMemberGenerator new PythonTypeReference(Dotnet.Types.TIME_ONLY, ImportModule: Dotnet.Namespaces.SYSTEM, ConversionMode.Direct))); private static readonly PythonTypeReference TYPE = new(Dotnet.Namespaces.SYSTEM_TYPE, Dotnet.Namespaces.SYSTEM, ConversionMode.Direct); - + private readonly PythonGeneratorOptions _options; protected PythonMemberGenerator(IOptions options) diff --git a/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs b/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs index 10ccd724..a28c5656 100644 --- a/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs +++ b/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs @@ -41,7 +41,7 @@ public PythonPropertyGenerator(IPythonDocstringGenerator docGenerator, public ImmutableArray GenerateProperties(INamedTypeSymbol dotNetType) { // Enums don't generate any properties, but "enum values" through IPythonEnumValueGenerator. - if(dotNetType.IsAnyEnum()) + if (dotNetType.IsAnyEnum()) { return ImmutableArray.Empty; } @@ -51,7 +51,7 @@ public ImmutableArray GenerateProperties(INamedTypeSymbol dotNet // Generate both C# fields and properties as Python properties. foreach (var dotNetMember in dotNetType.GetMembers()) { - if(IgnoreMember(dotNetType, dotNetMember) || IGNORED_PROPERTIES.Contains(dotNetMember.Name)) + if (IgnoreMember(dotNetType, dotNetMember) || IGNORED_PROPERTIES.Contains(dotNetMember.Name)) { continue; } @@ -65,7 +65,7 @@ public ImmutableArray GenerateProperties(INamedTypeSymbol dotNet hasSetter = !(dotNetProperty.IsReadOnly || (dotNetProperty.SetMethod is not null && dotNetProperty.SetMethod.IsInitOnly)); isStatic = dotNetProperty.IsStatic; } - else if(dotNetMember is IFieldSymbol dotNetField) + else if (dotNetMember is IFieldSymbol dotNetField) { dotNetMemberType = dotNetField.Type; hasGetter = true; diff --git a/src/Tableau.Migration.PythonGenerator/ITypeSymbolExtensions.cs b/src/Tableau.Migration.PythonGenerator/ITypeSymbolExtensions.cs index 2d6114cf..c4ac7339 100644 --- a/src/Tableau.Migration.PythonGenerator/ITypeSymbolExtensions.cs +++ b/src/Tableau.Migration.PythonGenerator/ITypeSymbolExtensions.cs @@ -15,6 +15,7 @@ // limitations under the License. // +using System; using Microsoft.CodeAnalysis; namespace Tableau.Migration.PythonGenerator @@ -29,7 +30,23 @@ public static string ToPythonModuleName(this ITypeSymbol ts) return ts.Name; } - return "tableau_migration." + containingNs.ToDisplayString() + return GetPythonModuleName(containingNs.ToDisplayString()); + } + + public static string ToPythonModuleName(Type ts) + { + var containingNs = ts.Namespace; + if (containingNs == null) + { + return ts.Name; + } + + return GetPythonModuleName(containingNs); + } + + private static string GetPythonModuleName(string dotNetNamespace) + { + return "tableau_migration." + dotNetNamespace .Replace("Tableau.", "") .Replace(".", "_") .ToLower(); diff --git a/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs b/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs index 462bc18b..d2f7034a 100644 --- a/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs +++ b/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs @@ -47,6 +47,7 @@ internal static class PythonGenerationList typeof(IContentReference), typeof(IResult), typeof(MigrationCompletionStatus), + typeof(PipelineProfile), #endregion @@ -87,6 +88,11 @@ internal static class PythonGenerationList typeof(IWithWorkbook), typeof(ICustomView), typeof(IPublishableCustomView), + typeof(ISubscriptionContent), + typeof(IServerSubscription), + typeof(ICloudSubscription), + typeof(ISubscription<>), + typeof(UserAuthenticationType), #endregion @@ -103,7 +109,7 @@ internal static class PythonGenerationList #endregion #region - Tableau.Migration.Engine.Hooks.PostPublish - - + typeof(BulkPostPublishContext<>), typeof(ContentItemPostPublishContext<,>), diff --git a/src/Tableau.Migration.PythonGenerator/PythonTestGenerationList.cs b/src/Tableau.Migration.PythonGenerator/PythonTestGenerationList.cs index b9cfa29c..030f6591 100644 --- a/src/Tableau.Migration.PythonGenerator/PythonTestGenerationList.cs +++ b/src/Tableau.Migration.PythonGenerator/PythonTestGenerationList.cs @@ -15,6 +15,7 @@ // limitations under the License. // +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -117,14 +118,18 @@ internal class PythonTestGenerationList #endregion ); - private static readonly ImportedModule IContentReferenceImport = new( - Dotnet.Namespaces.TABLEAU_MIGRATION, - new ImportedType(nameof(IContentReference))); + private static readonly ImportedModule IContentReferenceImport = new(Dotnet.Namespaces.TABLEAU_MIGRATION, nameof(IContentReference)); private static readonly ImportedModule DotnetListImport = new( Dotnet.Namespaces.SYSTEM_COLLECTIONS_GENERIC, new ImportedType(Dotnet.Types.LIST, Dotnet.TypeAliases.LIST)); + private static ImportedModule GetPythonImportedModule(Type dotnetType) + => new(ITypeSymbolExtensions.ToPythonModuleName(dotnetType), PythonTypeReference.ToPythonTypeName(dotnetType)); + + private static ImportedModule GetDotnetImportedModule(Type dotnetType) + => new(dotnetType.Namespace ?? string.Empty, new ImportedType(dotnetType.Name)); + private static readonly Dictionary> NAMESPACE_IMPORTS = new() { { @@ -132,7 +137,9 @@ internal class PythonTestGenerationList new List() { IContentReferenceImport, - new(Dotnet.Namespaces.SYSTEM, [new ImportedType(Dotnet.Types.BOOLEAN), new ImportedType(Dotnet.Types.NULLABLE)]) + new(Dotnet.Namespaces.SYSTEM, [Dotnet.Types.BOOLEAN, Dotnet.Types.NULLABLE]), + GetDotnetImportedModule(typeof(ISchedule)), + GetPythonImportedModule(typeof(ISchedule)) } }, { @@ -140,7 +147,7 @@ internal class PythonTestGenerationList new List() { IContentReferenceImport, - new(Dotnet.Namespaces.SYSTEM,new ImportedType(Dotnet.Types.NULLABLE)), + new(Dotnet.Namespaces.SYSTEM,Dotnet.Types.NULLABLE), DotnetListImport } }, @@ -149,9 +156,9 @@ internal class PythonTestGenerationList new List() { IContentReferenceImport, - new(Dotnet.Namespaces.SYSTEM, [new ImportedType(Dotnet.Types.NULLABLE), new ImportedType(Dotnet.Types.TIME_ONLY), new ImportedType(Dotnet.Types.STRING)]), + new(Dotnet.Namespaces.SYSTEM, [Dotnet.Types.NULLABLE, Dotnet.Types.TIME_ONLY, Dotnet.Types.STRING]), DotnetListImport, - new($"{typeof(ExtractRefreshContentType).Namespace}",new ImportedType(nameof(ExtractRefreshContentType))) + GetDotnetImportedModule(typeof(ExtractRefreshContentType)) } } }; diff --git a/src/Tableau.Migration.PythonGenerator/PythonTypeReference.cs b/src/Tableau.Migration.PythonGenerator/PythonTypeReference.cs index 03d475e4..179437bc 100644 --- a/src/Tableau.Migration.PythonGenerator/PythonTypeReference.cs +++ b/src/Tableau.Migration.PythonGenerator/PythonTypeReference.cs @@ -15,6 +15,7 @@ // limitations under the License. // +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -33,16 +34,14 @@ internal sealed record PythonTypeReference(string Name, string? ImportModule, public string GenericDefinitionName => GenericTypes is null ? Name : $"{Name}[{string.Join(", ", GenericTypes.Value.Select(g => g.GenericDefinitionName))}]"; - public bool IsExplicitReference => Name.Contains("."); + public bool IsExplicitReference => Name.Contains('.'); - public static string ToPythonTypeName(ITypeSymbol dotNetType) - { - var typeName = dotNetType.Name; - if (typeName.StartsWith("I")) - typeName = typeName.Substring(1); + public static string ToPythonTypeName(ITypeSymbol dotNetType) => GetPythonTypeName(dotNetType.Name); - return "Py" + typeName; - } + public static string ToPythonTypeName(Type dotNetType) => GetPythonTypeName(dotNetType.Name); + + public static string GetPythonTypeName(string typeName) + => typeName.StartsWith('I') ? "Py" + typeName[1..] : "Py" + typeName; public IEnumerable UnwrapGenerics() { @@ -58,7 +57,7 @@ public IEnumerable UnwrapGenerics() } public static PythonTypeReference ForGenericType(ITypeSymbol genericType) - => new PythonTypeReference(genericType.Name, null, ConversionMode.WrapGeneric); + => new(genericType.Name, null, ConversionMode.WrapGeneric); public static PythonTypeReference ForDotNetType(ITypeSymbol dotNetType) { diff --git a/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj b/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj index 08d04a31..2626a60b 100644 --- a/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj +++ b/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj @@ -2,7 +2,7 @@ Tableau Migration SDK Python Wrapper Generator Exe - net8.0 + net9.0 CA2007 @@ -12,7 +12,7 @@ - + diff --git a/src/Tableau.Migration.PythonGenerator/Writers/Imports/ImportedModule.cs b/src/Tableau.Migration.PythonGenerator/Writers/Imports/ImportedModule.cs index f337d8d3..13e4acb0 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/Imports/ImportedModule.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/Imports/ImportedModule.cs @@ -16,6 +16,7 @@ // using System.Collections.Generic; +using System.Linq; namespace Tableau.Migration.PythonGenerator.Writers.Imports { @@ -26,11 +27,24 @@ public ImportedModule(string name, HashSet types) Name = name; Types = types; } + public ImportedModule(string name, HashSet typeNames) + { + Name = name; + Types = typeNames.Select(tn=> new ImportedType(tn)).ToHashSet(); + } + public ImportedModule(string name, ImportedType type) { Name = name; Types = [type]; } + + public ImportedModule(string name, string typeName) + { + Name = name; + Types = [new ImportedType(typeName)]; + } + public string Name { get; set; } public HashSet Types { get; set; } diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs index 515240c5..1a93a789 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs @@ -36,7 +36,7 @@ private static void BuildCtorTestBody(PythonType type, IndentingStringBuilder ct { ctorBuilder.AppendLine($"dotnet = self.create({DotNetTypeName(type.DotNetType)})"); ctorBuilder.AppendLine($"py = {PythonTypeName(type)}(dotnet)"); - + ctorBuilder.AppendLine($"assert py._dotnet == dotnet"); } } diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonMemberWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonMemberWriter.cs index d9a9c954..8644103c 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonMemberWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonMemberWriter.cs @@ -16,7 +16,6 @@ // using System; -using System.Collections.Immutable; using System.Linq; using Microsoft.CodeAnalysis; @@ -157,7 +156,7 @@ protected static string BuildDotnetGenericTypeConstraintsString(INamedTypeSymbol var typeConstraints = dotnetType.TypeParameters.First().ConstraintTypes; return string.Join(",", typeConstraints.Select(t => t.Name)); } - + protected static string BuildPythongGenericTypeConstraintsString(INamedTypeSymbol dotnetType) { var typeConstraints = dotnetType.TypeParameters.First().ConstraintTypes; diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs index e4379e64..3a147638 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs @@ -41,7 +41,7 @@ public void Write(IndentingStringBuilder builder, PythonType type, PythonPropert if (property.Getter) { string getterPrefix; - if(property.IsStatic) + if (property.IsStatic) { builder.AppendLine("@classmethod"); getterPrefix = "get_"; @@ -94,7 +94,7 @@ private void BuildGetterBody(PythonType type, PythonProperty property, Indenting } private static string DotNetPropertyReference(PythonType type, PythonProperty property) - => property.IsStatic? DotNetTypeName(type) : $"self.{PythonTypeWriter.DOTNET_OBJECT}"; + => property.IsStatic ? DotNetTypeName(type) : $"self.{PythonTypeWriter.DOTNET_OBJECT}"; private static string DotNetPropertyInvocation(PythonType type, PythonProperty property) => $"{DotNetPropertyReference(type, property)}.{property.DotNetProperty.Name}"; diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonTestWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonTestWriter.cs index 522a998b..92090eaa 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonTestWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonTestWriter.cs @@ -71,7 +71,7 @@ private bool TypeHasProperty(PythonTypeCache typeCache, PythonType startType, IP private void AddHierarchyExcludedMemberHints(List excludedMembers, PythonTypeCache typeCache, PythonType type) { var typeHints = _options.Hints.ForType(type.DotNetType); - if (typeHints is not null && typeHints.ExcludeMembers.Any()) + if (typeHints is not null && typeHints.ExcludeMembers.Length != 0) { excludedMembers.AddRange(typeHints.ExcludeMembers); } @@ -108,10 +108,7 @@ private string BuildExcludedMemberList(PythonTypeCache typeCache, PythonType t) return "None"; } - var memberNames = excludedMembers - .Distinct() - .Order() - .Select(m => $"\"{m}\""); + var memberNames = excludedMembers.Distinct().Order().Select(m => $"\"{m}\""); return $"[ {string.Join(", ", memberNames)} ]"; } @@ -210,7 +207,7 @@ private async ValueTask WriteClassMemberTests(PythonTypeCache pyTypeCache, Cance return; } - var namespaceGroups = typesToTest.GroupBy(x + var namespaceGroups = typesToTest.GroupBy(x => x.DotNetType?.ContainingNamespace.ToDisplayString() ?? string.Empty); foreach (var namespaceGroup in namespaceGroups) @@ -242,10 +239,7 @@ private async ValueTask WriteClassMemberTests(PythonTypeCache pyTypeCache, Cance } } - private static void WriteExtraTestImports( - IndentingStringBuilder builder, - string nameSpace, - PythonTypeCache pyTypeCache) + private static void WriteExtraTestImports(IndentingStringBuilder builder, string nameSpace, PythonTypeCache pyTypeCache) { builder.AppendLine("# Extra imports for tests."); diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonWriter.cs index 5508d02d..f26ec679 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonWriter.cs @@ -67,7 +67,7 @@ private void WriteTypeAndDependencies(IndentingStringBuilder builder, PythonType { if (cycleReferences.Contains(type)) { - throw new Exception("Type dependency cycle detected. Consider implementing ordering or stubbing."); + throw new Exception($"Type dependency cycle detected for {type.Name}. Consider implementing ordering or stubbing. Cycle references: {string.Join(',', cycleReferences)}"); } if (writtenTypes.Contains(type)) diff --git a/src/Tableau.Migration.PythonGenerator/appsettings.json b/src/Tableau.Migration.PythonGenerator/appsettings.json index d7c374a2..df2a6eae 100644 --- a/src/Tableau.Migration.PythonGenerator/appsettings.json +++ b/src/Tableau.Migration.PythonGenerator/appsettings.json @@ -51,6 +51,7 @@ { "type": "MigrationPipelineContentType", "excludeMembers": [ + "WithPrepareType", "WithPublishType", "WithResultType", "GetPublishTypeForInterface", @@ -60,7 +61,7 @@ }, { "type": "ServerToCloudMigrationPipeline", - "excludeMembers": [ "BuildPipeline", "GetBatchMigrator" ] + "excludeMembers": [ "BuildPipeline", "GetBatchMigrator", "GetItemConverter" ] } ] }, diff --git a/src/Tableau.Migration/Api/ApiClient.cs b/src/Tableau.Migration/Api/ApiClient.cs index 99fe4424..a2f4ccdb 100644 --- a/src/Tableau.Migration/Api/ApiClient.cs +++ b/src/Tableau.Migration/Api/ApiClient.cs @@ -36,7 +36,6 @@ internal sealed class ApiClient : ApiClientBase, IApiClient private readonly TableauSiteConnectionConfiguration _siteConnectionConfiguration; private readonly IServerSessionProvider _sessionProvider; private readonly IHttpContentSerializer _contentSerializer; - internal const string SITES_QUERY_NOT_SUPPORTED = "403069"; internal const string EXPERIMENTAL_API_VERSION = "exp"; /// @@ -139,7 +138,7 @@ internal async Task GetInstanceTypeAsync(CancellationToken return TableauInstanceType.Server; } - if (sitesResult.Errors.OfType().Any(e => e.Code == SITES_QUERY_NOT_SUPPORTED)) + if (sitesResult.Errors.OfType().Any(e => RestErrorCodes.Equals(e.Code, RestErrorCodes.SITES_QUERY_NOT_SUPPORTED))) { return TableauInstanceType.Cloud; } diff --git a/src/Tableau.Migration/Api/AuthenticationConfigurationsApiClient.cs b/src/Tableau.Migration/Api/AuthenticationConfigurationsApiClient.cs new file mode 100644 index 00000000..fd77b53e --- /dev/null +++ b/src/Tableau.Migration/Api/AuthenticationConfigurationsApiClient.cs @@ -0,0 +1,86 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Net.Rest; +using Tableau.Migration.Paging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Api +{ + internal sealed class AuthenticationConfigurationsApiClient : + ApiClientBase, IAuthenticationConfigurationsApiClient + { + /// + /// The currently maximum number of allowed authentication configurations. + /// + internal const int MAX_CONFIGURATIONS = 20; + + private readonly IServerSessionProvider _sessionProvider; + + public AuthenticationConfigurationsApiClient( + IRestRequestBuilderFactory restRequestBuilderFactory, + IServerSessionProvider sessionProvider, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer sharedResourcesLocalizer) + : base(restRequestBuilderFactory, loggerFactory, sharedResourcesLocalizer) + { + _sessionProvider = sessionProvider; + } + + #region - IAuthenticationConfigurationsApiClient Implementation - + + public async Task>> GetAuthenticationConfigurationsAsync(CancellationToken cancel) + { + if(!AssertInstanceType(TableauInstanceType.Cloud, _sessionProvider.InstanceType, throwOnFailure: false)) + { + return Result>.Succeeded(ImmutableArray.Empty); + } + + var result = await RestRequestBuilderFactory + .CreateUri("/site-auth-configurations") + .ForGetRequest() + .SendAsync(cancel) + .ToResultAsync(r => (IImmutableList)r.Items.Select(i => (IAuthenticationConfiguration)new AuthenticationConfiguration(i)).ToImmutableArray(), + SharedResourcesLocalizer) + .ConfigureAwait(false); + + return result; + } + + public IPager GetPager(int pageSize) + => new MemoryPager(async (c) => + { + var result = await GetAuthenticationConfigurationsAsync(c).ConfigureAwait(false); + if(!result.Success) + { + return result.CastFailure>(); + } + + return Result>.Succeeded(result.Value); + }, pageSize); + + #endregion + } +} diff --git a/src/Tableau.Migration/Api/ContentApiClientBase.cs b/src/Tableau.Migration/Api/ContentApiClientBase.cs index c43ba202..b2d1acdf 100644 --- a/src/Tableau.Migration/Api/ContentApiClientBase.cs +++ b/src/Tableau.Migration/Api/ContentApiClientBase.cs @@ -15,6 +15,7 @@ // limitations under the License. // +using System; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; @@ -46,11 +47,13 @@ public ContentApiClientBase( ContentFinderFactory = finderFactory; } + #region - Find Project Methods - + protected async Task FindProjectAsync( - [NotNull] T? response, + T response, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) - where T : IWithProjectType, IRestIdentifiable + where T : IWithProjectReferenceType, IRestIdentifiable => await ContentFinderFactory .FindProjectAsync( response, @@ -60,8 +63,25 @@ public ContentApiClientBase( cancel) .ConfigureAwait(false); + protected async Task FindProjectByIdAsync( + Guid id, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + => await ContentFinderFactory + .FindProjectByIdAsync( + id, + Logger, + SharedResourcesLocalizer, + throwIfNotFound, + cancel) + .ConfigureAwait(false); + + #endregion - Find Project Methods - + + #region - Find User Methods - + protected async Task FindUserAsync( - [NotNull] T? response, + T response, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) where T : IRestIdentifiable @@ -74,8 +94,12 @@ public ContentApiClientBase( cancel) .ConfigureAwait(false); + #endregion + + #region - Find Owner Methods - + protected async Task FindOwnerAsync( - [NotNull] T? response, + T response, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) where T : IWithOwnerType, IRestIdentifiable @@ -88,8 +112,12 @@ public ContentApiClientBase( cancel) .ConfigureAwait(false); + #endregion - Find Owner Methods - + + #region - Find Workbook Methods - + protected async Task FindWorkbookAsync( - [NotNull] T? response, + T response, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) where T : IWithWorkbookReferenceType, IRestIdentifiable @@ -101,5 +129,20 @@ public ContentApiClientBase( throwIfNotFound, cancel) .ConfigureAwait(false); + + protected async Task FindWorkbookByIdAsync( + Guid id, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + => await ContentFinderFactory + .FindWorkbookByIdAsync( + id, + Logger, + SharedResourcesLocalizer, + throwIfNotFound, + cancel) + .ConfigureAwait(false); + + #endregion - Find Workbook Methods - } } diff --git a/src/Tableau.Migration/Api/CustomViewsApiClient.cs b/src/Tableau.Migration/Api/CustomViewsApiClient.cs index baba601e..a6de4e70 100644 --- a/src/Tableau.Migration/Api/CustomViewsApiClient.cs +++ b/src/Tableau.Migration/Api/CustomViewsApiClient.cs @@ -34,6 +34,7 @@ using Tableau.Migration.Content.Search; using Tableau.Migration.Net; using Tableau.Migration.Net.Rest; +using Tableau.Migration.Net.Rest.Filtering; using Tableau.Migration.Paging; using Tableau.Migration.Resources; @@ -68,13 +69,21 @@ public CustomViewsApiClient( _fileStore = fileStore; _customViewPublisher = customViewPublisher; } - /// public async Task> GetAllCustomViewsAsync(int pageNumber, int pageSize, CancellationToken cancel) + => await GetAllCustomViewsAsync(pageNumber, pageSize, Enumerable.Empty(), cancel).ConfigureAwait(false); + + /// + public async Task> GetAllCustomViewsAsync( + int pageNumber, + int pageSize, + IEnumerable filters, + CancellationToken cancel) { var getAllCustomViewsResult = await RestRequestBuilderFactory .CreateUri($"{UrlPrefix}") .WithPage(pageNumber, pageSize) + .WithFilters(filters) .ForGetRequest() .SendAsync(cancel) .ToPagedResultAsync(async (r, c) => @@ -84,7 +93,7 @@ public async Task> GetAllCustomViewsAsync(int pageNumb foreach (var item in r.Items) { - var workbook = await FindWorkbookAsync(item, false, c).ConfigureAwait(false); + var workbook = await FindWorkbookAsync(Guard.AgainstNull(item, () => item), false, c).ConfigureAwait(false); var owner = await FindOwnerAsync(item, false, c).ConfigureAwait(false); if (workbook is null || owner is null) @@ -243,7 +252,7 @@ public async Task> DownloadCustomViewAsync( CancellationToken cancel) { var downloadResult = await RestRequestBuilderFactory - .CreateUri($"{UrlPrefix}/{customViewId.ToUrlSegment()}/{RestUrlPrefixes.Content}", true) + .CreateUri($"{UrlPrefix}/{customViewId.ToUrlSegment()}/{RestUrlPrefixes.Content}") .ForGetRequest() .DownloadAsync(cancel) .ConfigureAwait(false); @@ -285,7 +294,96 @@ public async Task> PullAsync(ICustomView content public async Task> PublishCustomViewAsync( IPublishCustomViewOptions options, CancellationToken cancel) - => await _customViewPublisher.PublishAsync(options, cancel).ConfigureAwait(false); + { + var publishResult = await _customViewPublisher.PublishAsync(options, cancel) + .ConfigureAwait(false); + + if (publishResult.Success || + !publishResult.Errors.OfType() + .Any(e => RestErrorCodes.Equals(e.Code, RestErrorCodes.CUSTOM_VIEW_ALREADY_EXISTS))) + { + return publishResult; + } + + var existingCustomViewResult = await GetExistingCustomViewAsync(options.WorkbookId, options.Name, cancel) + .ConfigureAwait(false); + + if (!existingCustomViewResult.Success) + { + return existingCustomViewResult.CastFailure(); + } + + var existingCustomView = existingCustomViewResult.Value; + + var deleteResult = await DeleteCustomViewAsync(existingCustomView.Id, cancel).ConfigureAwait(false); + + if (!deleteResult.Success) + { + return deleteResult.CastFailure(); + } + + publishResult = await _customViewPublisher.PublishAsync(options, cancel).ConfigureAwait(false); + + return publishResult; + } + + private async Task> GetExistingCustomViewAsync(Guid workbookId, string name, CancellationToken cancel) + { + int pageNum = 1; + + var existingCustomViewResult = await GetFilteredPage(workbookId, pageNum, cancel).ConfigureAwait(false); + + if (!existingCustomViewResult.Success) + { + return existingCustomViewResult.CastFailure(); + } + var existingCustomView = FindByName(name); + + if (existingCustomView != null) + { + return Result.Succeeded(existingCustomView); + } + + while (!existingCustomViewResult.FetchedAllPages) + { + pageNum++; + existingCustomViewResult = await GetFilteredPage(workbookId, pageNum, cancel).ConfigureAwait(false); + + if (!existingCustomViewResult.Success) + { + return existingCustomViewResult.CastFailure(); + } + + var existingCustomViews = existingCustomViewResult.Value; + existingCustomView = FindByName(name); + + if (existingCustomView != null) + { + return Result.Succeeded(existingCustomView); + } + } + + if (existingCustomView == null) + { + return Result.Failed(new Exception($"No custom view with name {name} was found")); + } + + return Result.Succeeded(existingCustomView); + + async Task> GetFilteredPage(Guid workbookId, int pageNum, CancellationToken cancel) + { + var filters = new List + { + new("workbookId", FilterOperator.Equal, workbookId.ToString()) + }; + + return await GetAllCustomViewsAsync(pageNum, _configReader.Get().BatchSize, filters, cancel) + .ConfigureAwait(false); + } + + ICustomView? FindByName(string name) + => existingCustomViewResult?.Value.FirstOrDefault(cv => cv.Name == name); + } #region - IPublishApiClient Implementation - /// @@ -322,7 +420,7 @@ public async Task> PublishAsync( /// public async Task> GetPageAsync(int pageNumber, int pageSize, CancellationToken cancel) - => await GetAllCustomViewsAsync(pageNumber, pageSize, cancel).ConfigureAwait(false); + => await GetAllCustomViewsAsync(pageNumber, pageSize, [], cancel).ConfigureAwait(false); #endregion diff --git a/src/Tableau.Migration/Api/DataSourcesApiClient.cs b/src/Tableau.Migration/Api/DataSourcesApiClient.cs index 04f7074b..be06b533 100644 --- a/src/Tableau.Migration/Api/DataSourcesApiClient.cs +++ b/src/Tableau.Migration/Api/DataSourcesApiClient.cs @@ -20,6 +20,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Labels; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Permissions; @@ -57,6 +58,7 @@ public DataSourcesApiClient( IContentFileStore fileStore, IDataSourcePublisher dataSourcePublisher, ITagsApiClientFactory tagsClientFactory, + IEmbeddedCredentialsApiClientFactory embeddedCredentialsApiClientFactory, IConnectionManager connectionManager, ILabelsApiClientFactory labelsCLientFactory, IConfigReader configReader) @@ -67,8 +69,10 @@ public DataSourcesApiClient( Labels = labelsCLientFactory.Create(); Permissions = permissionsClientFactory.Create(this); Tags = tagsClientFactory.Create(this); + EmbeddedCredentials = embeddedCredentialsApiClientFactory.Create(this); _connectionManager = connectionManager; _configReader = configReader; + } #region - ILabelsContentApiClient Implementation - @@ -92,11 +96,15 @@ public DataSourcesApiClient( #endregion + #region - IEmbeddedCredentialsContentApiClient Implementation + /// - public async Task> GetAllPublishedDataSourcesAsync( - int pageNumber, - int pageSize, - CancellationToken cancel) + public IEmbeddedCredentialsApiClient EmbeddedCredentials { get; } + + #endregion + + /// + public async Task> GetAllPublishedDataSourcesAsync(int pageNumber, int pageSize, CancellationToken cancel) { var getAllResult = await RestRequestBuilderFactory .CreateUri(UrlPrefix) @@ -113,26 +121,27 @@ public async Task> GetAllPublishedDataSourcesAsync( foreach (var item in response.Items) { // Convert them all to type DataSource. - if (item.Project is not null) // Project is null if item is in a personal space + if (item.Project is null) // Project is null if item is in a personal space + { + continue; + } + var project = await FindProjectAsync(item, false, cancel).ConfigureAwait(false); + var owner = await FindOwnerAsync(item, false, cancel).ConfigureAwait(false); + + if (project is null || owner is null) { - var project = await FindProjectAsync(item, false, cancel).ConfigureAwait(false); - var owner = await FindOwnerAsync(item, false, cancel).ConfigureAwait(false); - - if (project is null || owner is null) - { - Logger.LogWarning( - SharedResourcesLocalizer[SharedResourceKeys.DataSourceSkippedMissingReferenceWarning], - item.Id, - item.Name, - item.Project!.Id, - project is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found], - item.Owner!.Id, - owner is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found]); - continue; - } - - results.Add(new DataSource(item, project, owner)); + Logger.LogWarning( + SharedResourcesLocalizer[SharedResourceKeys.DataSourceSkippedMissingReferenceWarning], + item.Id, + item.Name, + item.Project!.Id, + project is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found], + item.Owner!.Id, + owner is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found]); + continue; } + + results.Add(new DataSource(item, project, owner)); } // Produce immutable list of type IDataSource and return. @@ -275,7 +284,7 @@ public async Task> PullAsync( * make sure the file is disposed. We clean up orphaned * files at the end of the DI scope, but we don't want to * bloat disk usage when we're processing future pages of items.*/ - var dataSourceResult = await file.DisposeOnThrowOrFailureAsync( + var dataSourceResult = await file.DisposeOnThrowOrFailureAsync( async () => await GetDataSourceAsync(contentItem.Id, cancel).ConfigureAwait(false) ).ConfigureAwait(false); diff --git a/src/Tableau.Migration/Api/EmbeddedCredentials/EmbeddedCredentialsApiClient.cs b/src/Tableau.Migration/Api/EmbeddedCredentials/EmbeddedCredentialsApiClient.cs new file mode 100644 index 00000000..58462b11 --- /dev/null +++ b/src/Tableau.Migration/Api/EmbeddedCredentials/EmbeddedCredentialsApiClient.cs @@ -0,0 +1,87 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Net; +using Tableau.Migration.Net.Rest; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Api.EmbeddedCredentials +{ + internal class EmbeddedCredentialsApiClient : IEmbeddedCredentialsApiClient + { + private readonly IRestRequestBuilderFactory _restRequestBuilderFactory; + private readonly IHttpContentSerializer _serializer; + private readonly ISharedResourcesLocalizer _sharedResourcesLocalizer; + private readonly string _urlPrefix; + private readonly ILoggerFactory _loggerFactory; + + public EmbeddedCredentialsApiClient( + IRestRequestBuilderFactory restRequestBuilderFactory, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer sharedResourcesLocalizer, + string urlPrefix, + IHttpContentSerializer serializer) + { + _restRequestBuilderFactory = restRequestBuilderFactory; + _loggerFactory = loggerFactory; + _sharedResourcesLocalizer = sharedResourcesLocalizer; + _urlPrefix = urlPrefix; + _serializer = serializer; + } + + #region - IEmbeddedCredentialsApiClient Implementation - + + /// + public async Task> RetrieveKeychainAsync( + Guid contentItemId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + { + return await _restRequestBuilderFactory + .CreateUri($"{_urlPrefix}/{contentItemId.ToUrlSegment()}/retrievekeychain") + .ForPostRequest() + .WithXmlContent(new RetrieveKeychainRequest(destinationSiteInfo)) + .SendAsync(cancel) + .ToResultAsync( + (response) => + new EmbeddedCredentialKeychainResult(response), + _sharedResourcesLocalizer) + .ConfigureAwait(false); + } + + /// + public async Task ApplyKeychainAsync(Guid contentItemId, IApplyKeychainOptions options, CancellationToken cancel) + { + return await _restRequestBuilderFactory + .CreateUri($"{_urlPrefix}/{contentItemId.ToUrlSegment()}/applykeychain") + .ForPutRequest() + .WithXmlContent(new ApplyKeychainRequest(options)) + .SendAsync(cancel) + .ToResultAsync(_serializer, _sharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientFactory.cs b/src/Tableau.Migration/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientFactory.cs new file mode 100644 index 00000000..849ac6f8 --- /dev/null +++ b/src/Tableau.Migration/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientFactory.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Microsoft.Extensions.Logging; +using Tableau.Migration.Config; +using Tableau.Migration.Net; +using Tableau.Migration.Net.Rest; +using Tableau.Migration.Resources; + + +namespace Tableau.Migration.Api.EmbeddedCredentials +{ + internal sealed class EmbeddedCredentialsApiClientFactory : IEmbeddedCredentialsApiClientFactory + { + private readonly IRestRequestBuilderFactory _restRequestBuilderFactory; + private readonly IHttpContentSerializer _serializer; + private readonly ISharedResourcesLocalizer _sharedResourcesLocalizer; + private readonly IConfigReader _configReader; + private readonly ILoggerFactory _loggerFactory; + + public EmbeddedCredentialsApiClientFactory( + IRestRequestBuilderFactory restRequestBuilderFactory, + IHttpContentSerializer serializer, + ISharedResourcesLocalizer sharedResourcesLocalizer, + IConfigReader configReader, + ILoggerFactory loggerFactory) + { + _restRequestBuilderFactory = restRequestBuilderFactory; + _serializer = serializer; + _sharedResourcesLocalizer = sharedResourcesLocalizer; + _configReader = configReader; + _loggerFactory = loggerFactory; + } + + /// + public IEmbeddedCredentialsApiClient Create(IContentApiClient contentApiClient) + => new EmbeddedCredentialsApiClient( + _restRequestBuilderFactory, + _loggerFactory, + _sharedResourcesLocalizer, + contentApiClient.UrlPrefix, + _serializer); + } +} diff --git a/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsApiClient.cs b/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsApiClient.cs new file mode 100644 index 00000000..a0127f12 --- /dev/null +++ b/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsApiClient.cs @@ -0,0 +1,51 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Api.Models; + +namespace Tableau.Migration.Api.EmbeddedCredentials +{ + /// + /// Interface for an API client that modifies content's embedded credentials. + /// + public interface IEmbeddedCredentialsApiClient + { + /// + /// Retrieves the encrypted keychains for the content item. + /// + /// The ID of the content item. + /// The destination site information. + /// The cancellation token to obey. + /// The operation result. + Task> RetrieveKeychainAsync( + Guid contentItemId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel); + + /// + /// Uploads and applies encrypted keychains to a content item. + /// + /// The ID of the content item. + /// The apply keychain options. + /// The cancellation token to obey. + /// The operation result. + Task ApplyKeychainAsync(Guid contentItemId, IApplyKeychainOptions options, CancellationToken cancel); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsApiClientFactory.cs b/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsApiClientFactory.cs new file mode 100644 index 00000000..d33939b0 --- /dev/null +++ b/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsApiClientFactory.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.EmbeddedCredentials +{ + internal interface IEmbeddedCredentialsApiClientFactory + { + /// + /// Creates the from the given . + /// + /// The , for example, . + /// The for the input . + IEmbeddedCredentialsApiClient Create(IContentApiClient contentApiClient); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsContentApiClient.cs b/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsContentApiClient.cs new file mode 100644 index 00000000..4cde93d2 --- /dev/null +++ b/src/Tableau.Migration/Api/EmbeddedCredentials/IEmbeddedCredentialsContentApiClient.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.EmbeddedCredentials +{ + /// + /// Interface for an API client + /// for a content type that has embedded credential operations. + /// + public interface IEmbeddedCredentialsContentApiClient + { + /// + /// Gets the embedded credentials API client. + /// + IEmbeddedCredentialsApiClient EmbeddedCredentials { get; } + } +} diff --git a/src/Tableau.Migration/Api/FlowsApiClient.cs b/src/Tableau.Migration/Api/FlowsApiClient.cs index c8f68ca9..d35693d7 100644 --- a/src/Tableau.Migration/Api/FlowsApiClient.cs +++ b/src/Tableau.Migration/Api/FlowsApiClient.cs @@ -39,12 +39,12 @@ internal sealed class FlowsApiClient : ContentApiClientBase, IFlowsApiClient private readonly IFlowPublisher _flowPublisher; public FlowsApiClient( - IRestRequestBuilderFactory restRequestBuilderFactory, - IContentReferenceFinderFactory finderFactory, - ILoggerFactory loggerFactory, + IRestRequestBuilderFactory restRequestBuilderFactory, + IContentReferenceFinderFactory finderFactory, + ILoggerFactory loggerFactory, ISharedResourcesLocalizer sharedResourcesLocalizer, IContentFileStore fileStore, - IFlowPublisher flowPublisher) + IFlowPublisher flowPublisher) : base(restRequestBuilderFactory, finderFactory, loggerFactory, sharedResourcesLocalizer) { _fileStore = fileStore; diff --git a/src/Tableau.Migration/Api/GroupsApiClient.cs b/src/Tableau.Migration/Api/GroupsApiClient.cs index 41baa7c6..183da4b1 100644 --- a/src/Tableau.Migration/Api/GroupsApiClient.cs +++ b/src/Tableau.Migration/Api/GroupsApiClient.cs @@ -40,8 +40,6 @@ namespace Tableau.Migration.Api { internal sealed class GroupsApiClient : ContentApiClientBase, IGroupsApiClient { - internal const string GROUP_NAME_CONFLICT_ERROR_CODE = "409009"; - private readonly IHttpContentSerializer _serializer; private readonly IConfigReader _configReader; @@ -200,7 +198,7 @@ public async Task> GetPageAsync(int pageNumber, int pageSiz private async Task> FixupUniqueGroupErrorAsync(IPublishableGroup item, IResult publishResult, CancellationToken cancel) { if (publishResult.Success - || !publishResult.Errors.OfType().Any(e => e.Code == GROUP_NAME_CONFLICT_ERROR_CODE)) + || !publishResult.Errors.OfType().Any(e => RestErrorCodes.Equals(e.Code, RestErrorCodes.GROUP_NAME_CONFLICT_ERROR_CODE))) { return publishResult; } diff --git a/src/Tableau.Migration/Api/IAuthenticationConfigurationsApiClient.cs b/src/Tableau.Migration/Api/IAuthenticationConfigurationsApiClient.cs new file mode 100644 index 00000000..f82e89db --- /dev/null +++ b/src/Tableau.Migration/Api/IAuthenticationConfigurationsApiClient.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Immutable; +using System.Threading.Tasks; +using System.Threading; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Api +{ + /// + /// Interface for API client authentication configuration operations. + /// + public interface IAuthenticationConfigurationsApiClient : IPagedListApiClient + { + /// + /// Gets all the authentication configurations on the site. + /// + /// The cancellation token to obey. + /// The list of authentication configurations. + Task>> GetAuthenticationConfigurationsAsync(CancellationToken cancel); + } +} diff --git a/src/Tableau.Migration/Api/ICloudSubscriptionsApiClient.cs b/src/Tableau.Migration/Api/ICloudSubscriptionsApiClient.cs new file mode 100644 index 00000000..82474e51 --- /dev/null +++ b/src/Tableau.Migration/Api/ICloudSubscriptionsApiClient.cs @@ -0,0 +1,77 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Paging; + +namespace Tableau.Migration.Api +{ + /// + /// Interface for API client server subscriptions operations. + /// + public interface ICloudSubscriptionsApiClient : IPublishApiClient, IDeleteApiClient + { + /// + /// Gets all subscriptions on the cloud site. + /// + /// The page number. + /// The page size. + /// The cancellation token to obey. + /// The list of subscriptions on the site. + Task> GetAllSubscriptionsAsync(int pageNumber, int pageSize, CancellationToken cancel); + + /// + /// Creates a new subscription on the cloud site. + /// + /// The subscription to create. + /// The cancellation token to obey. + /// The newly created cloud subscription. + Task> CreateSubscriptionAsync(ICloudSubscription subscription, CancellationToken cancel); + + /// + /// Updates a subscription on the cloud site. + /// + /// The ID for the subscription to update. + /// The cancellation token to obey. + /// The new subject, or null to not update the subject. + /// The new attach image flag, or null to not update the flag. + /// The new attach PDF flag, or null to not update the flag. + /// The new page orientation, or null to not update the page orientation. + /// The new page size option, or null to not update the page size option. + /// The new suspended flag, or null to not update the flag. + /// The new message, or null to not update the message. + /// The new content reference, or null to not update the content reference. + /// The new user ID, or null to not update the user ID. + /// The new schedule, or null to not update the schedule. + /// The updated cloud subscription. + Task> UpdateSubscriptionAsync(Guid subscriptionId, CancellationToken cancel, + string? newSubject = null, + bool? newAttachImage = null, + bool? newAttachPdf = null, + string? newPageOrientation = null, + string? newPageSizeOption = null, + bool? newSuspended = null, + string? newMessage = null, + ISubscriptionContent? newContent = null, + Guid? newUserId = null, + ICloudSchedule? newSchedule = null); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/IContentFileStoreExtensions.cs b/src/Tableau.Migration/Api/IContentFileStoreExtensions.cs index 9ba83ad5..be5f4351 100644 --- a/src/Tableau.Migration/Api/IContentFileStoreExtensions.cs +++ b/src/Tableau.Migration/Api/IContentFileStoreExtensions.cs @@ -33,6 +33,6 @@ internal static class IContentFileStoreExtensions /// The cancellation token to obey. /// A handle to the newly created file. public static async Task CreateAsync(this IContentFileStore store, T contentItem, FileDownload download, CancellationToken cancel) - => await store.CreateAsync(contentItem, download.Filename ?? string.Empty, download.Content, cancel).ConfigureAwait(false); + => await store.CreateAsync(contentItem, download.Filename ?? string.Empty, download.Content, cancel, download.IsZipFile).ConfigureAwait(false); } } diff --git a/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs b/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs index 8140e0ed..a3b87d4d 100644 --- a/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs +++ b/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs @@ -30,14 +30,16 @@ namespace Tableau.Migration.Api { internal static class IContentReferenceFinderFactoryExtensions { + #region - Project Find Methods - + public static async Task FindProjectAsync( this IContentReferenceFinderFactory finderFactory, - [NotNull] T? response, + T response, ILogger logger, ISharedResourcesLocalizer localizer, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) - where T : IWithProjectType, IRestIdentifiable + where T : IWithProjectReferenceType, IRestIdentifiable => await finderFactory .FindAsync( response, @@ -54,9 +56,31 @@ internal static class IContentReferenceFinderFactoryExtensions cancel) .ConfigureAwait(false); + public static async Task FindProjectByIdAsync( + this IContentReferenceFinderFactory finderFactory, + Guid id, + ILogger logger, + ISharedResourcesLocalizer localizer, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + => await finderFactory + .FindByIdAsync( + id, + logger, + localizer, + throwIfNotFound, + SharedResourceKeys.ProjectReferenceNotFoundException, + SharedResourceKeys.ProjectReferenceNotFoundException, + cancel) + .ConfigureAwait(false); + + #endregion - Project Find Methods - + + #region - User Find Methods - + public static async Task FindUserAsync( this IContentReferenceFinderFactory finderFactory, - [NotNull] T? response, + T response, ILogger logger, ISharedResourcesLocalizer localizer, [DoesNotReturnIf(true)] bool throwIfNotFound, @@ -77,9 +101,13 @@ internal static class IContentReferenceFinderFactoryExtensions cancel) .ConfigureAwait(false); + #endregion - User Find Methods - + + #region - Owner Find Methods - + public static async Task FindOwnerAsync( this IContentReferenceFinderFactory finderFactory, - [NotNull] T? response, + T response, ILogger logger, ISharedResourcesLocalizer localizer, [DoesNotReturnIf(true)] bool throwIfNotFound, @@ -101,9 +129,13 @@ internal static class IContentReferenceFinderFactoryExtensions cancel) .ConfigureAwait(false); + #endregion - Owner Find Methods - + + #region - Workbook Find Methods - + public static async Task FindWorkbookAsync( this IContentReferenceFinderFactory finderFactory, - [NotNull] T? response, + T response, ILogger logger, ISharedResourcesLocalizer localizer, [DoesNotReturnIf(true)] bool throwIfNotFound, @@ -125,9 +157,31 @@ internal static class IContentReferenceFinderFactoryExtensions cancel) .ConfigureAwait(false); + public static async Task FindWorkbookByIdAsync( + this IContentReferenceFinderFactory finderFactory, + Guid Id, + ILogger logger, + ISharedResourcesLocalizer localizer, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + => await finderFactory + .FindByIdAsync( + Id, + logger, + localizer, + throwIfNotFound, + SharedResourceKeys.WorkbookReferenceNotFoundException, + SharedResourceKeys.WorkbookReferenceNotFoundException, + cancel) + .ConfigureAwait(false); + + #endregion - Workbook Find Methods - + + #region - Base Find Methods - + private static async Task FindAsync( this IContentReferenceFinderFactory finderFactory, - [NotNull] TResponse? response, + TResponse response, Func getResponseId, ILogger logger, ISharedResourcesLocalizer localizer, @@ -164,5 +218,37 @@ internal static class IContentReferenceFinderFactoryExtensions responseId)) : null; } + + private static async Task FindByIdAsync( + this IContentReferenceFinderFactory finderFactory, + Guid Id, + ILogger logger, + ISharedResourcesLocalizer localizer, + [DoesNotReturnIf(true)] bool throwIfNotFound, + string warningMessageResource, + string exceptionMessageResource, + CancellationToken cancel) + where TContent : class, IContentReference + { + var finder = finderFactory.ForContentType(); + + var foundContent = await finder.FindByIdAsync(Id, cancel).ConfigureAwait(false); + + if (foundContent is not null) + return foundContent; + + logger.LogWarning( + localizer[warningMessageResource], + Id); + + return throwIfNotFound + ? throw new InvalidOperationException( + string.Format( + localizer[exceptionMessageResource], + Id)) + : null; + } + + #endregion - Base Find Methods - } } diff --git a/src/Tableau.Migration/Api/ICustomViewsApiClient.cs b/src/Tableau.Migration/Api/ICustomViewsApiClient.cs index 961a7447..5468a744 100644 --- a/src/Tableau.Migration/Api/ICustomViewsApiClient.cs +++ b/src/Tableau.Migration/Api/ICustomViewsApiClient.cs @@ -44,7 +44,10 @@ public interface ICustomViewsApiClient : /// The size of the page. /// The cancellation token to obey. /// A list of a page of custom views in the current site. - Task> GetAllCustomViewsAsync(int pageNumber, int pageSize, CancellationToken cancel); + Task> GetAllCustomViewsAsync( + int pageNumber, + int pageSize, + CancellationToken cancel); /// /// Changes the owner of an existing custom view. diff --git a/src/Tableau.Migration/Api/IDataSourcesApiClient.cs b/src/Tableau.Migration/Api/IDataSourcesApiClient.cs index 3a42c6c4..9bbbce9d 100644 --- a/src/Tableau.Migration/Api/IDataSourcesApiClient.cs +++ b/src/Tableau.Migration/Api/IDataSourcesApiClient.cs @@ -18,6 +18,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Labels; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Permissions; @@ -39,7 +40,8 @@ public interface IDataSourcesApiClient IApiPageAccessor, IPermissionsContentApiClient, IConnectionsApiClient, - ILabelsContentApiClient + ILabelsContentApiClient, + IEmbeddedCredentialsContentApiClient { /// /// Gets all published data sources in the current site. diff --git a/src/Tableau.Migration/Api/IDeleteApiClient.cs b/src/Tableau.Migration/Api/IDeleteApiClient.cs new file mode 100644 index 00000000..5fcd93d2 --- /dev/null +++ b/src/Tableau.Migration/Api/IDeleteApiClient.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tableau.Migration.Api +{ + /// + /// Interface for a content typed API client that can delete items. + /// + public interface IDeleteApiClient + { + /// + /// Deletes a content item. + /// + /// The ID content item to delete. + /// The cancellation token to obey. + /// The results of the deletion. + Task DeleteAsync(Guid id, CancellationToken cancel); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs b/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs index 7e26a131..9156733b 100644 --- a/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs +++ b/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -17,10 +17,9 @@ using System; using System.Collections.Immutable; -using System.Linq; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using Tableau.Migration; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models.Responses; @@ -58,16 +57,17 @@ public static async Task ToResultAsync(this IHttpResponseMessage respon // Deserializing it here if it exists so we can include it in the result. var tsError = await serializer.TryDeserializeErrorAsync(response.Content, cancel).ConfigureAwait(false); - var correlationId = response.Headers.GetCorrelationId(); - if (tsError is not null) { - throw new RestException( + var ex = new RestException( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, - correlationId, + response.Headers.GetCorrelationId(), tsError, + new StackTrace(fNeedFileInfo: true).ToString(), sharedResourcesLocalizer); + + return Result.Failed(ex); } } @@ -123,14 +123,15 @@ public static IResult ToResult(this IHttpResponseMess if (restError is not null) { - var correlationId = response.Headers.GetCorrelationId(); - - throw new RestException( + var ex = new RestException( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, - correlationId, + response.Headers.GetCorrelationId(), restError, + new StackTrace(fNeedFileInfo: true).ToString(), sharedResourcesLocalizer); + + return Result.Failed(ex); } response.EnsureSuccessStatusCode(); @@ -157,14 +158,15 @@ public static async Task> ToResultAsync(this if (restError is not null) { - var correlationId = response.Headers.GetCorrelationId(); - - throw new RestException( + var ex = new RestException( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, - correlationId, + response.Headers.GetCorrelationId(), restError, + new StackTrace(fNeedFileInfo: true).ToString(), sharedResourcesLocalizer); + + return Result.Failed(ex); } response.EnsureSuccessStatusCode(); @@ -207,14 +209,15 @@ public static IPagedResult ToPagedResult(this IHttpRe if (restError is not null) { - var correlationId = response.Headers.GetCorrelationId(); - - throw new RestException( + var ex = new RestException( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, - correlationId, + response.Headers.GetCorrelationId(), restError, + new StackTrace(fNeedFileInfo: true).ToString(), sharedResourcesLocalizer); + + return PagedResult.Failed(ex); } response.EnsureSuccessStatusCode(); @@ -259,14 +262,15 @@ public static async Task> ToPagedResultAsync.Failed(ex); } response.EnsureSuccessStatusCode(); @@ -285,6 +289,12 @@ public static async Task> ToPagedResultAsync + /// Parses the filename value from the Content-Disposition header with known Tableau API quirks. + /// .NET's validation of the Content Disposition header value fails with Tableau APIs, so we parse the header value manually. + /// + /// The HTTP response. + /// The parsed filename, or null if no filename could be found. internal static string? GetContentDispositionFilename(this IHttpResponseMessage response) { if (!response.Content.Headers.TryGetValues(RestHeaders.ContentDisposition, out var contentDispositions)) @@ -306,7 +316,11 @@ public static async Task> ToPagedResultAsync> ToPagedResultAsync> DownloadResultAsync(this Task getResponseAsync, CancellationToken cancel) { try @@ -327,10 +356,10 @@ public static async Task> DownloadResultAsy response.EnsureSuccessStatusCode(); var filename = response.GetContentDispositionFilename(); - var content = await response.Content.ReadAsStreamAsync(cancel).ConfigureAwait(false); + var isZipFile = response.DetectZipFileFromContentType(); - var download = new FileDownload(filename, content); + var download = new FileDownload(filename, content, isZipFile); return AsyncDisposableResult.Succeeded(download); } catch (Exception ex) diff --git a/src/Tableau.Migration/Api/IPagedListApiClient.cs b/src/Tableau.Migration/Api/IPagedListApiClient.cs index d1d30f9a..e89bb845 100644 --- a/src/Tableau.Migration/Api/IPagedListApiClient.cs +++ b/src/Tableau.Migration/Api/IPagedListApiClient.cs @@ -26,7 +26,7 @@ namespace Tableau.Migration.Api /// Interface for a content typed API client that can list all of the content items the user has access to. /// /// The content type. - public interface IPagedListApiClient : IContentApiClient + public interface IPagedListApiClient { /// /// Gets a pager to list all the content the user has access to. diff --git a/src/Tableau.Migration/Api/IPullApiClient.cs b/src/Tableau.Migration/Api/IPullApiClient.cs index 710db6ed..e15cf976 100644 --- a/src/Tableau.Migration/Api/IPullApiClient.cs +++ b/src/Tableau.Migration/Api/IPullApiClient.cs @@ -24,9 +24,9 @@ namespace Tableau.Migration.Api /// Interface for an API client that can pull information to publish with. /// /// The content type. - /// The publish type. - public interface IPullApiClient - where TPublish : class + /// The pulled type. + public interface IPullApiClient + where TPrepare : class { /// /// Pulls enough information to publish the content item. @@ -34,6 +34,6 @@ public interface IPullApiClient /// The content item to pull. /// The cancellation token to obey. /// The result of the pull operation with the item to publish. - Task> PullAsync(TContent contentItem, CancellationToken cancel); + Task> PullAsync(TContent contentItem, CancellationToken cancel); } } diff --git a/src/Tableau.Migration/Api/ISchedulesApiClientFactory.cs b/src/Tableau.Migration/Api/ISchedulesApiClientFactory.cs new file mode 100644 index 00000000..401250f2 --- /dev/null +++ b/src/Tableau.Migration/Api/ISchedulesApiClientFactory.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api +{ + /// + /// Interface for an object that can create objects. + /// + internal interface ISchedulesApiClientFactory + { + /// + /// Creates the object. + /// + /// + ISchedulesApiClient Create(); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/IServerSubscriptionsApiClient.cs b/src/Tableau.Migration/Api/IServerSubscriptionsApiClient.cs new file mode 100644 index 00000000..36e6e516 --- /dev/null +++ b/src/Tableau.Migration/Api/IServerSubscriptionsApiClient.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; +using Tableau.Migration.Paging; + +namespace Tableau.Migration.Api +{ + /// + /// Interface for API client server subscriptions operations. + /// + public interface IServerSubscriptionsApiClient : IPagedListApiClient, IApiPageAccessor + { + /// + /// Gets all subscriptions on the server site. + /// + /// The page number. + /// The page size. + /// The cancellation token to obey. + /// The list of subscriptions on the site. + Task> GetAllSubscriptionsAsync(int pageNumber, int pageSize, CancellationToken cancel); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/IServerTasksApiClient.cs b/src/Tableau.Migration/Api/IServerTasksApiClient.cs index 2f49f335..7448e51f 100644 --- a/src/Tableau.Migration/Api/IServerTasksApiClient.cs +++ b/src/Tableau.Migration/Api/IServerTasksApiClient.cs @@ -18,7 +18,6 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; -using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; namespace Tableau.Migration.Api @@ -28,7 +27,6 @@ namespace Tableau.Migration.Api /// public interface IServerTasksApiClient : IContentApiClient, - IPullApiClient, IApiPageAccessor, IPagedListApiClient { diff --git a/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs index 156770b1..82438971 100644 --- a/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -18,6 +18,7 @@ using System.IO.Abstractions; using System.Linq; using Microsoft.Extensions.DependencyInjection; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Labels; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Publishing; @@ -25,8 +26,6 @@ using Tableau.Migration.Api.Simulation; using Tableau.Migration.Api.Tags; using Tableau.Migration.Content.Files; -using Tableau.Migration.Content.Schedules; -using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Content.Search; using Tableau.Migration.Net; @@ -63,9 +62,12 @@ internal static IServiceCollection AddMigrationApiClient(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); //Main API client. services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -79,6 +81,7 @@ internal static IServiceCollection AddMigrationApiClient(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); //API Simulator. services.AddSingleton(); @@ -113,11 +116,6 @@ internal static IServiceCollection AddMigrationApiClient(this IServiceCollection services.AddScoped(p => new EncryptedFileStore(p, p.GetRequiredService())); services.AddScoped(p => p.GetRequiredService().FileStore); - //Extract Refresh Task converters. - services.AddScoped, ServerToCloudExtractRefreshTaskConverter>(); - services.AddScoped, ServerScheduleValidator>(); - services.AddScoped, CloudScheduleValidator>(); - return services; } } diff --git a/src/Tableau.Migration/Api/ISitesApiClient.cs b/src/Tableau.Migration/Api/ISitesApiClient.cs index 7012a726..db714fb7 100644 --- a/src/Tableau.Migration/Api/ISitesApiClient.cs +++ b/src/Tableau.Migration/Api/ISitesApiClient.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -18,6 +18,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Tags; using Tableau.Migration.Content; @@ -29,6 +30,11 @@ namespace Tableau.Migration.Api /// public interface ISitesApiClient : IAsyncDisposable, IContentApiClient { + /// + /// Gets the API client for authentication configuration operations. + /// + IAuthenticationConfigurationsApiClient AuthenticationConfigurations { get; } + /// /// Gets the API client for group operations. /// @@ -89,6 +95,16 @@ public interface ISitesApiClient : IAsyncDisposable, IContentApiClient /// public ICustomViewsApiClient CustomViews { get; } + /// + /// Gets the API client for Tableau Server subscription operations. + /// + IServerSubscriptionsApiClient ServerSubscriptions { get; } + + /// + /// Gets the API client for Tableau Cloud subscription operations. + /// + ICloudSubscriptionsApiClient CloudSubscriptions { get; } + /// /// Gets the site with the specified ID. /// @@ -130,14 +146,14 @@ public interface ISitesApiClient : IAsyncDisposable, IContentApiClient where TContent : class; /// - /// Gets the for the given content and publish types. + /// Gets the for the given content and prepare types. /// /// The content type. - /// The publish type. - /// The pull API client for the given content and publish types. + /// The prepare type. + /// The pull API client for the given content and prepare types. /// If a pull API client for the given types is not supported. - IPullApiClient GetPullApiClient() - where TPublish : class; + IPullApiClient GetPullApiClient() + where TPrepare : class; /// /// Gets the for the given content publish type. @@ -198,5 +214,23 @@ IOwnershipApiClient GetOwnershipApiClient() /// If a ownership API client for the given content type is not supported. IConnectionsApiClient GetConnectionsApiClient() where TContent : IWithConnections; + + /// + /// Gets the for the given content type. + /// + /// The content type. + /// The embedded embedded-credentials API client for the given content type. + /// If a ownership API client for the given content type is not supported. + IEmbeddedCredentialsContentApiClient GetEmbeddedCredentialsApiClient() + where TContent : IWithEmbeddedCredentials; + + /// + /// Gets the for the given content type. + /// + /// The content type. + /// The embedded delete API client for the given content type. + /// If a delete API client for the given content type is not supported. + IDeleteApiClient GetDeleteApiClient() + where TContent : IDelible; } } diff --git a/src/Tableau.Migration/Api/ISubscriptionsApiClient.cs b/src/Tableau.Migration/Api/ISubscriptionsApiClient.cs new file mode 100644 index 00000000..1a8560e7 --- /dev/null +++ b/src/Tableau.Migration/Api/ISubscriptionsApiClient.cs @@ -0,0 +1,35 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api +{ + /// + /// Interface for API client subscriptions operations. + /// + public interface ISubscriptionsApiClient : IContentApiClient, IServerSubscriptionsApiClient, ICloudSubscriptionsApiClient + { + /// + /// Gets the server subscriptions API client + /// + IServerSubscriptionsApiClient ForServer(); + + /// + /// Gets the cloud subscriptions API client + /// + ICloudSubscriptionsApiClient ForCloud(); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/IUsersApiClient.cs b/src/Tableau.Migration/Api/IUsersApiClient.cs index 5a7a5461..9140d9c7 100644 --- a/src/Tableau.Migration/Api/IUsersApiClient.cs +++ b/src/Tableau.Migration/Api/IUsersApiClient.cs @@ -64,10 +64,10 @@ public interface IUsersApiClient : IContentApiClient, IPagedListApiClient /// /// The username. In case of Tableau Cloud, the user name is the email address of the user. /// The site role for the user. - /// The optional authentication type of the user. + /// The authentication type of the user. /// The cancellation token to obey. - /// - Task> AddUserAsync(string userName, string siteRole, string? authenticationType, CancellationToken cancel); + /// The added user information. + Task> AddUserAsync(string userName, string siteRole, UserAuthenticationType authentication, CancellationToken cancel); /// /// Updates the user already present at the destination. @@ -78,23 +78,40 @@ public interface IUsersApiClient : IContentApiClient, IPagedListApiClient /// (Optional) The new Full Name for the user. /// (Optional) The new email address for the user. /// (Optional) The new password for the user. - /// (Optional) The new email Auth Setting for the user. - - /// + /// (Optional) The new authentication for the user. + /// The updated user information. Task> UpdateUserAsync(Guid id, string newSiteRole, CancellationToken cancel, string? newfullName = null, string? newEmail = null, string? newPassword = null, - string? newAuthSetting = null); + UserAuthenticationType? newAuthentication = null); /// /// Deletes a user. /// /// The user's ID. /// The cancellation token to obey. - /// + /// A result indicating success or failure. Task DeleteUserAsync(Guid userId, CancellationToken cancel); + + /// + /// Retrieves saved credentials for a specific user. + /// + /// The user's ID. + /// The destination site information.. + /// The cancellation token. + /// The user's + Task> RetrieveUserSavedCredentialsAsync(Guid userId, IDestinationSiteInfo destinationSiteInfo, CancellationToken cancel); + + /// + /// Uploads saved credentials for a user + /// + /// The user id + /// The list of encrypted keychains + /// The cancellation token + /// + Task UploadUserSavedCredentialsAsync(Guid userId, IEnumerable encryptedKeychains, CancellationToken cancel); } } diff --git a/src/Tableau.Migration/Api/IViewsApiClient.cs b/src/Tableau.Migration/Api/IViewsApiClient.cs index 4ee18362..6ee2659b 100644 --- a/src/Tableau.Migration/Api/IViewsApiClient.cs +++ b/src/Tableau.Migration/Api/IViewsApiClient.cs @@ -17,12 +17,13 @@ using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Tags; +using Tableau.Migration.Content; namespace Tableau.Migration.Api { /// - /// Interface for an API client that modifies workbook views + /// Interface for an API client view operations. /// - public interface IViewsApiClient : IPermissionsContentApiClient, ITagsContentApiClient + public interface IViewsApiClient : IPermissionsContentApiClient, ITagsContentApiClient, IReadApiClient, IContentApiClient { } } \ No newline at end of file diff --git a/src/Tableau.Migration/Api/IViewsApiClientFactory.cs b/src/Tableau.Migration/Api/IViewsApiClientFactory.cs index 61456ff3..85acbf1e 100644 --- a/src/Tableau.Migration/Api/IViewsApiClientFactory.cs +++ b/src/Tableau.Migration/Api/IViewsApiClientFactory.cs @@ -23,7 +23,7 @@ namespace Tableau.Migration.Api public interface IViewsApiClientFactory { /// - /// + /// Creates the object. /// /// IViewsApiClient Create(); diff --git a/src/Tableau.Migration/Api/IWorkbooksApiClient.cs b/src/Tableau.Migration/Api/IWorkbooksApiClient.cs index bd88fb67..9b5ec033 100644 --- a/src/Tableau.Migration/Api/IWorkbooksApiClient.cs +++ b/src/Tableau.Migration/Api/IWorkbooksApiClient.cs @@ -18,6 +18,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Tags; @@ -37,7 +38,8 @@ public interface IWorkbooksApiClient : ITagsContentApiClient, IApiPageAccessor, IPermissionsContentApiClient, - IConnectionsApiClient + IConnectionsApiClient, + IEmbeddedCredentialsContentApiClient { /// /// Gets all workbooks in the current site except the ones in the Personal Space. diff --git a/src/Tableau.Migration/Api/JobsApiClient.cs b/src/Tableau.Migration/Api/JobsApiClient.cs index d5857963..1d945161 100644 --- a/src/Tableau.Migration/Api/JobsApiClient.cs +++ b/src/Tableau.Migration/Api/JobsApiClient.cs @@ -17,6 +17,7 @@ using System; using System.Linq; +using System.Net; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -75,7 +76,7 @@ public async Task WaitForJobAsync(Guid jobId, CancellationToken cancel) // Check job waiting timeout var timeSinceStart = _timeProvider.GetUtcNow() - startTime; - if(timeSinceStart > _configReader.Get().Jobs.JobTimeout) + if (timeSinceStart > _configReader.Get().Jobs.JobTimeout) { return Result.Failed(new TimeoutJobException(job, SharedResourcesLocalizer)); } @@ -95,8 +96,8 @@ public async Task WaitForJobAsync(Guid jobId, CancellationToken cancel) { if (jobResult.Errors[0] is RestException restError) { - if (string.Equals(restError.Code, "400031", StringComparison.Ordinal) || - restError.Code?.StartsWith("404") == true) + if (RestErrorCodes.Equals(restError.Code, RestErrorCodes.GENERIC_QUERY_JOB_ERROR) || + restError.Code?.StartsWith(HttpStatusCode.NotFound.GetHashCode().ToString()) == true) { return Result.Succeeded(); } diff --git a/src/Tableau.Migration/Api/Models/AddUserResult.cs b/src/Tableau.Migration/Api/Models/AddUserResult.cs index 3fe41a6c..7fe4b1df 100644 --- a/src/Tableau.Migration/Api/Models/AddUserResult.cs +++ b/src/Tableau.Migration/Api/Models/AddUserResult.cs @@ -16,7 +16,9 @@ // using System; +using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; namespace Tableau.Migration.Api.Models { @@ -32,7 +34,7 @@ internal class AddUserResult : IAddUserResult public string SiteRole { get; } /// - public string AuthSetting { get; } + public UserAuthenticationType Authentication { get; } public AddUserResult(AddUserResponse response) { @@ -41,7 +43,8 @@ public AddUserResult(AddUserResponse response) Id = Guard.AgainstDefaultValue(user.Id, () => response.Item.Id); Name = Guard.AgainstNullEmptyOrWhiteSpace(user.Name, () => response.Item.Name); SiteRole = Guard.AgainstNullEmptyOrWhiteSpace(user.SiteRole, () => response.Item.SiteRole); - AuthSetting = Guard.AgainstNullEmptyOrWhiteSpace(user.AuthSetting, () => response.Item.AuthSetting); + + Authentication = response.Item.GetAuthenticationType(); } } } diff --git a/src/Tableau.Migration/Api/Models/ApplyKeychainOptions.cs b/src/Tableau.Migration/Api/Models/ApplyKeychainOptions.cs new file mode 100644 index 00000000..fca60df0 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/ApplyKeychainOptions.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Default implementation. + /// + public class ApplyKeychainOptions : IApplyKeychainOptions + { + /// + /// Creates a new object. + /// + /// The encrypted keychains to apply to the content item. + /// The user mapping to use when applying the keychain. + public ApplyKeychainOptions(IEnumerable encryptedKeychains, IEnumerable keychainUserMapping) + { + EncryptedKeychains = encryptedKeychains; + KeychainUserMapping = keychainUserMapping; + } + + /// + public IEnumerable EncryptedKeychains { get; } + + /// + public IEnumerable KeychainUserMapping { get; } + } +} diff --git a/src/Tableau.Migration/Api/Models/Cloud/CreateSubscriptionOptions.cs b/src/Tableau.Migration/Api/Models/Cloud/CreateSubscriptionOptions.cs new file mode 100644 index 00000000..441469be --- /dev/null +++ b/src/Tableau.Migration/Api/Models/Cloud/CreateSubscriptionOptions.cs @@ -0,0 +1,73 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Cloud; + +namespace Tableau.Migration.Api.Models.Cloud +{ + /// + /// Default implementation. + /// + public class CreateSubscriptionOptions : ICreateSubscriptionOptions + { + /// + public string Subject { get; } + + /// + public bool AttachImage { get; } + + /// + public bool AttachPdf { get; } + + /// + public string PageOrientation { get; } + + /// + public string PageSizeOption { get; } + + /// + public string? Message { get; } + + /// + public ISubscriptionContent Content { get; } + + /// + public Guid UserId { get; } + + /// + public ICloudSchedule Schedule { get; } + + /// + /// Creates a new object. + /// + /// The subcription to create. + public CreateSubscriptionOptions(ICloudSubscription subscription) + { + Subject = subscription.Subject; + AttachImage = subscription.AttachImage; + AttachPdf = subscription.AttachPdf; + PageOrientation = subscription.PageOrientation; + PageSizeOption = subscription.PageSizeOption; + Message = subscription.Message; + Content = subscription.Content; + UserId = subscription.Owner.Id; + Schedule = subscription.Schedule; + } + } +} diff --git a/src/Tableau.Migration/Api/Models/Cloud/ICreateSubscriptionOptions.cs b/src/Tableau.Migration/Api/Models/Cloud/ICreateSubscriptionOptions.cs new file mode 100644 index 00000000..a5d649f9 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/Cloud/ICreateSubscriptionOptions.cs @@ -0,0 +1,74 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Cloud; + +namespace Tableau.Migration.Api.Models.Cloud +{ + /// + /// Interface for an API client subscription creation model. + /// + public interface ICreateSubscriptionOptions + { + /// + /// Gets the subject for the subscription. + /// + string Subject { get; } + + /// + /// Gets the attach image flag for the subscription. + /// + bool AttachImage { get; } + + /// + /// Gets the attach pdf flag for the subscription. + /// + bool AttachPdf { get; } + + /// + /// Gets the page orientation of the subscription. + /// + string PageOrientation { get; } + + /// + /// Gets the page page size option of the subscription. + /// + string PageSizeOption { get; } + + /// + /// Gets the message for the subscription. + /// + string? Message { get; } + + /// + /// Gets the content reference for the subscription. + /// + ISubscriptionContent Content { get; } + + /// + /// Gets the ID of the user for the subscription. + /// + Guid UserId { get; } + + /// + /// Gets the schedule for the subscription. + /// + ICloudSchedule Schedule { get; } + } +} diff --git a/src/Tableau.Migration/Api/Models/DestinationSiteInfo.cs b/src/Tableau.Migration/Api/Models/DestinationSiteInfo.cs new file mode 100644 index 00000000..63c53135 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/DestinationSiteInfo.cs @@ -0,0 +1,52 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Default implementation. + /// + public class DestinationSiteInfo : IDestinationSiteInfo + { + /// + /// Creates a new object. + /// + /// The Content URL of the destination Tableau site. + /// The ID of the destination Tableau site. + /// The url of the the destination Tableau instance. + public DestinationSiteInfo( + string destinationSiteContentUrl, + Guid destinationSiteId, + string destinationSiteUrl) + { + ContentUrl = destinationSiteContentUrl; + SiteId = destinationSiteId; + SiteUrl = destinationSiteUrl; + } + + /// + public string ContentUrl { get; } + + /// + public Guid SiteId { get; } + + /// + public string SiteUrl { get; } + } +} diff --git a/src/Tableau.Migration/Api/Models/EmbeddedCredentialKeychainResult.cs b/src/Tableau.Migration/Api/Models/EmbeddedCredentialKeychainResult.cs new file mode 100644 index 00000000..cfa3d835 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/EmbeddedCredentialKeychainResult.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using Tableau.Migration.Api.Rest.Models.Responses; + +namespace Tableau.Migration.Api.Models +{ + /// + public class EmbeddedCredentialKeychainResult(RetrieveKeychainResponse response) : IEmbeddedCredentialKeychainResult + { + /// + public IImmutableList EncryptedKeychains { get; } = response.EncryptedKeychainList?.ToImmutableArray() ?? new ImmutableArray(); + + /// + public IImmutableList AssociatedUserIds { get; } = response.AssociatedUserLuidList?.ToImmutableArray() ?? new ImmutableArray(); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Models/FileDownload.cs b/src/Tableau.Migration/Api/Models/FileDownload.cs index 5f66d5b8..6e461deb 100644 --- a/src/Tableau.Migration/Api/Models/FileDownload.cs +++ b/src/Tableau.Migration/Api/Models/FileDownload.cs @@ -28,7 +28,8 @@ namespace Tableau.Migration.Api.Models /// /// The server provided filename of the file to download. /// The stream with the file content to download from. - public record FileDownload(string? Filename, Stream Content) : IAsyncDisposable + /// Whether or not the file is in a ZIP format (e.g. tdsx/twbx), or null if the zip file status could not be determined. + public record FileDownload(string? Filename, Stream Content, bool? IsZipFile) : IAsyncDisposable { /// /// Performs application-defined tasks associated with freeing, releasing, or resetting diff --git a/src/Tableau.Migration/Api/Models/IAddUserResult.cs b/src/Tableau.Migration/Api/Models/IAddUserResult.cs index 68ebf664..6b1a168c 100644 --- a/src/Tableau.Migration/Api/Models/IAddUserResult.cs +++ b/src/Tableau.Migration/Api/Models/IAddUserResult.cs @@ -16,6 +16,7 @@ // using Tableau.Migration.Api.Rest; +using Tableau.Migration.Content; namespace Tableau.Migration.Api.Models { @@ -35,8 +36,8 @@ public interface IAddUserResult : IRestIdentifiable string SiteRole { get; } /// - /// The AuthSetting for the user. + /// The authentication type for the user. /// - string AuthSetting { get; } + UserAuthenticationType Authentication { get; } } } \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Models/IApplyKeychainOptions.cs b/src/Tableau.Migration/Api/Models/IApplyKeychainOptions.cs new file mode 100644 index 00000000..5dbb3dd4 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/IApplyKeychainOptions.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Interface for apply keychain options. + /// + public interface IApplyKeychainOptions + { + /// + /// Gets the encrypted keychains to apply to the content item. + /// + IEnumerable EncryptedKeychains { get; } + + /// + /// Gets the user mapping to use when applying the keychain. + /// + IEnumerable KeychainUserMapping { get; } + } +} diff --git a/src/Tableau.Migration/Api/Models/IDestinationSiteInfo.cs b/src/Tableau.Migration/Api/Models/IDestinationSiteInfo.cs new file mode 100644 index 00000000..523ded8c --- /dev/null +++ b/src/Tableau.Migration/Api/Models/IDestinationSiteInfo.cs @@ -0,0 +1,42 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Properties of the destination Tableau Site. + /// + public interface IDestinationSiteInfo + { + /// + /// The site ID for the destination. + /// + public Guid SiteId { get; } + + /// + /// The site name for the destination. + /// + public string ContentUrl { get; } + + /// + /// The Url for the destination Tableau instance. + /// + public string SiteUrl { get; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Models/IEmbeddedCredentialKeychainResult.cs b/src/Tableau.Migration/Api/Models/IEmbeddedCredentialKeychainResult.cs new file mode 100644 index 00000000..a70755f2 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/IEmbeddedCredentialKeychainResult.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Interface for retrieve saved credentials result for content items. + /// + public interface IEmbeddedCredentialKeychainResult + { + /// + /// The list of encrypted Keychains for the content item. + /// + IImmutableList EncryptedKeychains { get; } + + /// + /// The list of associated user IDs for the embedded credentials of the content item. + /// + IImmutableList AssociatedUserIds { get; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Models/IKeychainUserMapping.cs b/src/Tableau.Migration/Api/Models/IKeychainUserMapping.cs new file mode 100644 index 00000000..1756a72b --- /dev/null +++ b/src/Tableau.Migration/Api/Models/IKeychainUserMapping.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Interface for a user mapping used by servers when applying encrypted keychains. + /// + public interface IKeychainUserMapping + { + /// + /// Gets the source user ID. + /// + Guid SourceUserId { get; } + + /// + /// Gets the destination user ID. + /// + Guid DestinationUserId { get; } + } +} diff --git a/src/Tableau.Migration/Api/Models/IUpdateUserResult.cs b/src/Tableau.Migration/Api/Models/IUpdateUserResult.cs index 1b513b2d..4aa50a9d 100644 --- a/src/Tableau.Migration/Api/Models/IUpdateUserResult.cs +++ b/src/Tableau.Migration/Api/Models/IUpdateUserResult.cs @@ -15,6 +15,8 @@ // limitations under the License. // +using Tableau.Migration.Content; + namespace Tableau.Migration.Api.Models { /// @@ -23,7 +25,7 @@ namespace Tableau.Migration.Api.Models public interface IUpdateUserResult { /// - /// Gets the Name of the user. + /// Gets the name of the user. /// string Name { get; } @@ -38,13 +40,13 @@ public interface IUpdateUserResult string? Email { get; } /// - /// Gets the SiteRole of the user. + /// Gets the site role of the user. /// string SiteRole { get; } /// - /// The AuthSetting for the user. + /// The authentication for the user. /// - string AuthSetting { get; } + UserAuthenticationType Authentication { get; } } } \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Models/KeychainUserMapping.cs b/src/Tableau.Migration/Api/Models/KeychainUserMapping.cs new file mode 100644 index 00000000..1b9a81c1 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/KeychainUserMapping.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Default implementation. + /// + public class KeychainUserMapping : IKeychainUserMapping + { + /// + /// Creates a new object. + /// + /// The source user ID. + /// The destination user ID. + public KeychainUserMapping(Guid sourceUserId, Guid destinationUserId) + { + SourceUserId = sourceUserId; + DestinationUserId = destinationUserId; + } + + /// + public Guid SourceUserId { get; } + + /// + public Guid DestinationUserId { get; } + } +} diff --git a/src/Tableau.Migration/Api/Models/UpdateUserResult.cs b/src/Tableau.Migration/Api/Models/UpdateUserResult.cs index 60ab3aef..a3b04607 100644 --- a/src/Tableau.Migration/Api/Models/UpdateUserResult.cs +++ b/src/Tableau.Migration/Api/Models/UpdateUserResult.cs @@ -15,7 +15,9 @@ // limitations under the License. // +using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; namespace Tableau.Migration.Api.Models { @@ -34,7 +36,7 @@ internal class UpdateUserResult : IUpdateUserResult public string SiteRole { get; } /// - public string AuthSetting { get; } + public UserAuthenticationType Authentication { get; } public UpdateUserResult(UpdateUserResponse response) { @@ -44,7 +46,8 @@ public UpdateUserResult(UpdateUserResponse response) FullName = user.FullName; Email = user.Email; SiteRole = Guard.AgainstNullEmptyOrWhiteSpace(user.SiteRole, () => response.Item.SiteRole); - AuthSetting = Guard.AgainstNullEmptyOrWhiteSpace(user.AuthSetting, () => response.Item.AuthSetting); + + Authentication = response.Item.GetAuthenticationType(); } } } diff --git a/src/Tableau.Migration/Api/ProjectsApiClient.cs b/src/Tableau.Migration/Api/ProjectsApiClient.cs index a1ad37cd..dff473d7 100644 --- a/src/Tableau.Migration/Api/ProjectsApiClient.cs +++ b/src/Tableau.Migration/Api/ProjectsApiClient.cs @@ -42,8 +42,6 @@ namespace Tableau.Migration.Api internal sealed class ProjectsApiClient : ContentApiClientBase, IProjectsApiClient, IProjectsResponseApiClient { - internal const string PROJECT_NAME_CONFLICT_ERROR_CODE = "409006"; - private readonly IHttpContentSerializer _serializer; private readonly IDefaultPermissionsApiClient _defaultPermissionsClient; @@ -171,6 +169,40 @@ public IPager GetPager(int pageSize) #region - IPublishApiClient Implementation - + private static bool ContinueOnProjectCreationError(IProject projectToCreate, IResult createResult) + { + if(createResult.Success || !createResult.Errors.Any()) + { + return false; + } + + foreach (var e in createResult.Errors) + { + if(e is not RestException re) + { + return false; + } + + // Name conflict is OK - it means the project already exists, and we should get/update. + if(RestErrorCodes.Equals(re.Code, RestErrorCodes.PROJECT_NAME_CONFLICT_ERROR_CODE)) + { + continue; + } + /* Access error on "External Assets Default Project" is OK. + * Users can assign ownership on this built-in project, which bypasses our system ownership filter, + * but trying to overwrite it with creation gives an access error code instead of name conflict. + * We want to get/update the project so we can set the owner to reflect the source. + */ + else if(RestErrorCodes.Equals(re.Code, RestErrorCodes.CREATE_PROJECT_FORBIDDEN) && + Constants.SystemProjectNames.Contains(projectToCreate.Name)) + { + continue; + } + } + + return true; + } + public async Task> PublishAsync(IProject item, CancellationToken cancel) { var options = new CreateProjectOptions( @@ -180,12 +212,11 @@ public async Task> PublishAsync(IProject item, CancellationTok item.ContentPermissions, false); - var projectResult = await CreateProjectAsync(options, cancel).ConfigureAwait(false); + var createResult = await CreateProjectAsync(options, cancel).ConfigureAwait(false); - if (projectResult.Success - || !projectResult.Errors.OfType().Any(e => e.Code == PROJECT_NAME_CONFLICT_ERROR_CODE)) + if (createResult.Success || !ContinueOnProjectCreationError(item, createResult)) { - return projectResult; + return createResult; } // If there's a conflict find the existing project. @@ -219,21 +250,19 @@ public async Task> PublishAsync(IProject item, CancellationTok } var conflictResultBuilder = new ResultBuilder(); - conflictResultBuilder.Add(projectResult); + conflictResultBuilder.Add(createResult); if (!existingProjectResult.Success) { conflictResultBuilder.Add(existingProjectResult); } - else if (existingProjectResult.Value.Count == 0) + else if (!existingProjectResult.Value.Any()) { - conflictResultBuilder.Add( - new Exception($@"Could not find a project with the name ""{options.Name}"" and parent project ID {options.ParentProject?.Id.ToString() ?? ""}.")); + conflictResultBuilder.Add(new Exception($@"Could not find a project with the name ""{options.Name}"" and parent project ID {options.ParentProject?.Id.ToString() ?? ""}.")); } else if (existingProjectResult.Value.Count > 1) { - conflictResultBuilder.Add( - new Exception($@"Found multiple projects with the name ""{options.Name}"" and parent project ID {options.ParentProject?.Id.ToString() ?? ""}.")); + conflictResultBuilder.Add(new Exception($@"Found multiple projects with the name ""{options.Name}"" and parent project ID {options.ParentProject?.Id.ToString() ?? ""}.")); } return conflictResultBuilder.Build().CastFailure(); diff --git a/src/Tableau.Migration/Api/Rest/Models/IConnectionType.cs b/src/Tableau.Migration/Api/Rest/Models/IConnectionType.cs index 2bda36ca..47f01bba 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IConnectionType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IConnectionType.cs @@ -47,5 +47,20 @@ public interface IConnectionType : IRestIdentifiable /// This is returned only for administrator users. /// public string? QueryTaggingEnabled { get; } + + /// + /// The embed password value for the response. + /// + public string? EmbedPassword { get; set; } + + /// + /// The authentication type for the response. + /// + public string? AuthenticationType { get; } + + /// + /// Whether OAuth managed keychanins are used for the response. + /// + public string? UseOAuthManagedKeychain { get; } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/ICustomViewType.cs b/src/Tableau.Migration/Api/Rest/Models/ICustomViewType.cs index 297e2f37..b7c756ca 100644 --- a/src/Tableau.Migration/Api/Rest/Models/ICustomViewType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/ICustomViewType.cs @@ -25,7 +25,7 @@ namespace Tableau.Migration.Api.Rest.Models public interface ICustomViewType : IRestIdentifiable, INamedContent, - IWithWorkbookReferenceType, + IWithWorkbookNamedReferenceType, IWithOwnerType { /// diff --git a/src/Tableau.Migration/Api/Rest/Models/IDataSourceType.cs b/src/Tableau.Migration/Api/Rest/Models/IDataSourceType.cs index 32717664..0ba4d708 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IDataSourceType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IDataSourceType.cs @@ -20,7 +20,7 @@ namespace Tableau.Migration.Api.Rest.Models /// /// Interface for a data source REST response. /// - public interface IDataSourceType : IRestIdentifiable, INamedContent, IWithProjectType, IWithOwnerType, IWithTagTypes + public interface IDataSourceType : IRestIdentifiable, INamedContent, IWithProjectNamedReferenceType, IWithOwnerType, IWithTagTypes { /// /// Gets the description for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/IFlowType.cs b/src/Tableau.Migration/Api/Rest/Models/IFlowType.cs index 49d73a95..d03c082b 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IFlowType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IFlowType.cs @@ -20,7 +20,7 @@ namespace Tableau.Migration.Api.Rest.Models /// /// Interface for a prep flow REST response. /// - public interface IFlowType : IRestIdentifiable, INamedContent, IWithProjectType, IWithOwnerType, IWithTagTypes + public interface IFlowType : IRestIdentifiable, INamedContent, IWithProjectNamedReferenceType, IWithOwnerType, IWithTagTypes { /// /// Gets the description for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/IProjectNamedReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IProjectNamedReferenceType.cs new file mode 100644 index 00000000..f3ec553e --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IProjectNamedReferenceType.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface representing an XML element for the project of a content item. + /// + public interface IProjectNamedReferenceType : IProjectReferenceType, INamedContent + { } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IProjectReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IProjectReferenceType.cs index 0c55f260..4c80b995 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IProjectReferenceType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IProjectReferenceType.cs @@ -20,6 +20,6 @@ namespace Tableau.Migration.Api.Rest.Models /// /// Interface representing an XML element for the project of a content item. /// - public interface IProjectReferenceType : IRestIdentifiable, INamedContent + public interface IProjectReferenceType : IRestIdentifiable { } } diff --git a/src/Tableau.Migration/Api/Rest/Models/ISubscriptionType.cs b/src/Tableau.Migration/Api/Rest/Models/ISubscriptionType.cs new file mode 100644 index 00000000..664eb476 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/ISubscriptionType.cs @@ -0,0 +1,73 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Xml.Serialization; +using Tableau.Migration.Api.Rest.Models.Responses; + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface for a subscription REST response. + /// + public interface ISubscriptionType : IRestIdentifiable + { + /// + /// Gets the subject for the subscription. + /// + string? Subject { get; } + + /// + /// Gets the attach image flag for the subscription. + /// + bool AttachImage { get; } + + /// + /// Gets the attach pdf flag for the subscription. + /// + bool AttachPdf { get; } + + /// + /// Gets the page orientation of the subscription. + /// + string? PageOrientation { get; set; } + + /// + /// Gets the page page size option of the subscription. + /// + string? PageSizeOption { get; set; } + + /// + /// Gets the suspended flag for the subscription. + /// + bool Suspended { get; } + + /// + /// Gets the message for the subscription. + /// + public string? Message { get; set; } + + /// + /// Gets the content reference for the subscription. + /// + ISubscriptionContentType? Content { get; } + + /// + /// Gets the user for the subscription. + /// + IRestIdentifiable? User { get; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IUserType.cs b/src/Tableau.Migration/Api/Rest/Models/IUserType.cs new file mode 100644 index 00000000..0b1a4c1a --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IUserType.cs @@ -0,0 +1,35 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface for a user REST response. + /// + public interface IUserType + { + /// + /// Gets the auth setting. + /// + string? AuthSetting { get; } + + /// + /// Gets the IdP configuration ID. + /// + string? IdpConfigurationId { get; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IUserTypeExtensions.cs b/src/Tableau.Migration/Api/Rest/Models/IUserTypeExtensions.cs new file mode 100644 index 00000000..026e726f --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IUserTypeExtensions.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Api.Rest.Models +{ + internal static class IUserTypeExtensions + { + /// + /// Gets the parsed IdP configuration ID. + /// + /// The user response. + /// + /// The IdP configuration ID, + /// or null if is null, empty, or fails to parse. + /// + public static Guid? GetIdpConfigurationId(this IUserType user) + => Guid.TryParse(user.IdpConfigurationId, out var parsedId) ? parsedId : null; + + /// + /// Gets the authentication type information. + /// + /// The user response. + /// The authentication type information. + public static UserAuthenticationType GetAuthenticationType(this IUserType user) + => new(user.AuthSetting, user.GetIdpConfigurationId()); + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IViewReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IViewReferenceType.cs index 99721b8e..7c9ccedf 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IViewReferenceType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IViewReferenceType.cs @@ -20,16 +20,6 @@ namespace Tableau.Migration.Api.Rest.Models /// /// Interface representing an XML element for the view of a content item. /// - public interface IViewReferenceType : IRestIdentifiable, INamedContent - { - /// - /// The content URL for the view response. - /// - string? ContentUrl { get; } - - /// - /// The tags for the response. - /// - ITagType[] Tags { get; } - } + public interface IViewReferenceType : IRestIdentifiable + { } } diff --git a/src/Tableau.Migration/Api/Rest/Models/IViewType.cs b/src/Tableau.Migration/Api/Rest/Models/IViewType.cs index b39f528d..e05d77d4 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IViewType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IViewType.cs @@ -20,7 +20,7 @@ namespace Tableau.Migration.Api.Rest.Models /// /// Interface for a view REST response. /// - public interface IViewType : IRestIdentifiable, INamedContent, IWithTagTypes, IWithWorkbookReferenceType + public interface IViewType : IRestIdentifiable, INamedContent, IWithTagTypes, IWithWorkbookReferenceType, IWithProjectReferenceType { /// /// The content URL for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/IWithProjectNamedReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IWithProjectNamedReferenceType.cs new file mode 100644 index 00000000..260e7b04 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IWithProjectNamedReferenceType.cs @@ -0,0 +1,30 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface for an object that has a named project reference. + /// + public interface IWithProjectNamedReferenceType : IWithProjectReferenceType + { + /// + /// Gets the project for the response. + /// + new IProjectNamedReferenceType? Project { get; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IWithProjectType.cs b/src/Tableau.Migration/Api/Rest/Models/IWithProjectReferenceType.cs similarity index 95% rename from src/Tableau.Migration/Api/Rest/Models/IWithProjectType.cs rename to src/Tableau.Migration/Api/Rest/Models/IWithProjectReferenceType.cs index 70d19502..a8c54e0c 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IWithProjectType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IWithProjectReferenceType.cs @@ -20,7 +20,7 @@ namespace Tableau.Migration.Api.Rest.Models /// /// Interface for an object that has a project reference. /// - public interface IWithProjectType + public interface IWithProjectReferenceType { /// /// Gets the project for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/IWithWorkbookNamedReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IWithWorkbookNamedReferenceType.cs new file mode 100644 index 00000000..ab045e95 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IWithWorkbookNamedReferenceType.cs @@ -0,0 +1,30 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface for an object that has a workbook reference. + /// + public interface IWithWorkbookNamedReferenceType : IWithWorkbookReferenceType + { + /// + /// Gets the workbook for the response. + /// + new IWorkbookNamedReferenceType? Workbook { get; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IWithWorkbookReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IWithWorkbookReferenceType.cs index 5cca19c2..4fbcc447 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IWithWorkbookReferenceType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IWithWorkbookReferenceType.cs @@ -25,6 +25,6 @@ public interface IWithWorkbookReferenceType /// /// Gets the workbook for the response. /// - IRestIdentifiable? Workbook { get; } + IWorkbookReferenceType? Workbook { get; } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/IWorkbookDetailsType.cs b/src/Tableau.Migration/Api/Rest/Models/IWorkbookDetailsType.cs index 43a541a5..45c3a0b5 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IWorkbookDetailsType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IWorkbookDetailsType.cs @@ -25,6 +25,6 @@ public interface IWorkbookDetailsType : IWorkbookType /// /// Gets the views for the response. /// - IViewReferenceType[] Views { get; } + IWorkbookViewReferenceType[] Views { get; } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/IWorkbookNamedReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IWorkbookNamedReferenceType.cs new file mode 100644 index 00000000..258fa1b8 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IWorkbookNamedReferenceType.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface representing an XML element for the workbook of a content item. + /// + public interface IWorkbookNamedReferenceType : IWorkbookReferenceType, INamedContent + { } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IWorkbookReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IWorkbookReferenceType.cs new file mode 100644 index 00000000..e5cbb701 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IWorkbookReferenceType.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface representing an XML element for the workbook of a content item. + /// + public interface IWorkbookReferenceType : IRestIdentifiable + { } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IWorkbookType.cs b/src/Tableau.Migration/Api/Rest/Models/IWorkbookType.cs index 33aaf31c..25fa2383 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IWorkbookType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IWorkbookType.cs @@ -22,7 +22,7 @@ namespace Tableau.Migration.Api.Rest.Models /// /// Interface for a workbook REST response. /// - public interface IWorkbookType : IRestIdentifiable, INamedContent, IWithProjectType, IWithOwnerType, IWithTagTypes + public interface IWorkbookType : IRestIdentifiable, INamedContent, IWithProjectNamedReferenceType, IWithOwnerType, IWithTagTypes { /// /// The description for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/IWorkbookViewReferenceType.cs b/src/Tableau.Migration/Api/Rest/Models/IWorkbookViewReferenceType.cs new file mode 100644 index 00000000..5c18b674 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/IWorkbookViewReferenceType.cs @@ -0,0 +1,30 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface representing an XML element for the view of a content item. + /// + public interface IWorkbookViewReferenceType : IViewReferenceType, INamedContent, IWithTagTypes + { + /// + /// The content URL for the view response. + /// + string? ContentUrl { get; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/PermissionsCapabilityNames.cs b/src/Tableau.Migration/Api/Rest/Models/PermissionsCapabilityNames.cs index ec4a7f32..61149cca 100644 --- a/src/Tableau.Migration/Api/Rest/Models/PermissionsCapabilityNames.cs +++ b/src/Tableau.Migration/Api/Rest/Models/PermissionsCapabilityNames.cs @@ -18,122 +18,127 @@ namespace Tableau.Migration.Api.Rest.Models { /// - /// The Capability names used in the test API. + /// Enumeration class for the various capability names used in REST API permissions. /// public class PermissionsCapabilityNames : StringEnum { /// - /// Gets the name of capability name for no capabilities. + /// Gets the name of the None capability. /// public const string None = "None"; /// - /// Gets the name of capability name for AddComment. + /// Gets the name of the "Add Comment" capability. /// public const string AddComment = "AddComment"; /// - /// Gets the name of capability name for ChangeHierarchy. + /// Gets the name of the "Change Hierarchy" capability. /// public const string ChangeHierarchy = "ChangeHierarchy"; /// - /// Gets the name of capability name for ChangePermissions. + /// Gets the name of the "Change Permissions" capability. /// public const string ChangePermissions = "ChangePermissions"; /// - /// Gets the name of capability name for Connect. + /// Gets the name of the "Connect" capability. /// public const string Connect = "Connect"; /// - /// Gets the name of capability name for CreateRefreshMetrics. + /// Gets the name of the "Create Refresh Metrics" capability. /// public const string CreateRefreshMetrics = "CreateRefreshMetrics"; /// - /// Gets the name of capability name for Delete. + /// Gets the name of the "Delete" capability. /// public const string Delete = "Delete"; /// - /// Gets the name of capability name for Execute. + /// Gets the name of the "Execute" capability. /// public const string Execute = "Execute"; /// - /// Gets the name of capability name for ExportData. + /// Gets the name of the "Export Data" capability. /// public const string ExportData = "ExportData"; /// - /// Gets the name of capability name for ExportImage. + /// Gets the name of the "Export Image" capability. /// public const string ExportImage = "ExportImage"; /// - /// Gets the name of capability name for ExportXml. + /// Gets the name of the "Export XML" capability. /// public const string ExportXml = "ExportXml"; /// - /// Gets the name of capability name for Filter. + /// Gets the name of the "Extract Refresh" capability. + /// + public const string ExtractRefresh = "ExtractRefresh"; + + /// + /// Gets the name of the "Filter" capability. /// public const string Filter = "Filter"; /// - /// Gets the name of capability name for InheritedProjectLeader. + /// Gets the name of the "Inherited Project Leader" capability. /// public const string InheritedProjectLeader = "InheritedProjectLeader"; /// - /// Gets the name of capability name for ProjectLeader. + /// Gets the name of the "Project Leader" capability. /// public const string ProjectLeader = "ProjectLeader"; /// - /// Gets the name of capability name for Read. + /// Gets the name of the "Read" capability. /// public const string Read = "Read"; /// - /// Gets the name of capability name for RunExplainData. + /// Gets the name of the "Run Explain Data" capability. /// public const string RunExplainData = "RunExplainData"; /// - /// Gets the name of capability name for SaveAs. + /// Gets the name of the "Save As" capability. /// public const string SaveAs = "SaveAs"; /// - /// Gets the name of capability name for ShareView. + /// Gets the name of the "Share View" capability. /// public const string ShareView = "ShareView"; /// - /// Gets the name of capability name for ViewComments. + /// Gets the name of the "View Comments" capability. /// public const string ViewComments = "ViewComments"; /// - /// Gets the name of capability name for ViewUnderlyingData. + /// Gets the name of the "View Underlying Data" capability. /// public const string ViewUnderlyingData = "ViewUnderlyingData"; /// - /// Gets the name of capability name for WebAuthoring. + /// Gets the name of the "Web Authoring" capability. /// public const string WebAuthoring = "WebAuthoring"; /// - /// Gets the name of capability name for WebAuthoringForFlows. + /// Gets the name of the "Web Authoring" capability for flows. /// public const string WebAuthoringForFlows = "WebAuthoringForFlows"; /// - /// Gets the name of capability name for Write. + /// Gets the name of the "Write" capability. /// public const string Write = "Write"; } diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/AddUserToSiteRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/AddUserToSiteRequest.cs index 3fd2a596..7905fd64 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Requests/AddUserToSiteRequest.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/AddUserToSiteRequest.cs @@ -16,6 +16,7 @@ // using System.Xml.Serialization; +using Tableau.Migration.Content; namespace Tableau.Migration.Api.Rest.Models.Requests { @@ -40,15 +41,24 @@ public AddUserToSiteRequest() { } /// /// The username. /// The user's site role. - /// The user's authentication type. - public AddUserToSiteRequest(string name, string siteRole, string? authSetting) + /// The user's authentication. + public AddUserToSiteRequest(string name, string siteRole, UserAuthenticationType authentication) { User = new UserType { Name = name, - SiteRole = siteRole, - AuthSetting = authSetting + SiteRole = siteRole }; + + // IdP configuration ID and auth setting are mutually exclusive, set the ID if available. + if (authentication.IdpConfigurationId is not null) + { + User.IdpConfigurationId = authentication.IdpConfigurationId.ToString(); + } + else + { + User.AuthSetting = authentication.AuthenticationType; + } } /// @@ -80,6 +90,11 @@ public class UserType [XmlAttribute("authSetting")] public string? AuthSetting { get; set; } + /// + /// Gets or sets the IDP Configuration ID for the request. + /// + [XmlAttribute("idpConfigurationId")] + public string? IdpConfigurationId { get; set; } } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/ApplyKeychainRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/ApplyKeychainRequest.cs new file mode 100644 index 00000000..ab7f5ab3 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/ApplyKeychainRequest.cs @@ -0,0 +1,113 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Serialization; +using Tableau.Migration.Api.Models; + +namespace Tableau.Migration.Api.Rest.Models.Requests +{ + /// + /// Class representing an apply keychain request. + /// + [XmlType(XmlTypeName)] + public class ApplyKeychainRequest : TableauServerRequest + { + /// + /// Create a new object. + /// + public ApplyKeychainRequest() + { } + + /// + /// Create a new object. + /// + /// The encrypted keychains to include in the request. + /// The keychain user mapping. + public ApplyKeychainRequest(IEnumerable encryptedKeychains, IEnumerable? keychainUserMapping) + { + EncryptedKeychains = encryptedKeychains.ToArray(); + + if(keychainUserMapping.IsNullOrEmpty()) + { + AssociatedUserLuidMapping = null; + } + else + { + AssociatedUserLuidMapping = keychainUserMapping.Select(m => new UserLuidPairType(m)).ToArray(); + } + } + + /// + /// Create a new object. + /// + /// The request options. + public ApplyKeychainRequest(IApplyKeychainOptions options) + : this(options.EncryptedKeychains, options.KeychainUserMapping) + { } + + /// + /// Gets or sets the array of keychains to apply. + /// + [XmlArray("encryptedKeychainList")] + [XmlArrayItem("encryptedKeychain")] + public string[] EncryptedKeychains { get; set; } = Array.Empty(); + + /// + /// Gets or sets the array of user LUID mapping pairs. + /// + [XmlArray("associatedUserLuidMapping")] + [XmlArrayItem("userLuidPair")] + public UserLuidPairType[]? AssociatedUserLuidMapping { get; set; } + + /// + /// Class representing a user LUID pair in the apply keychain request. + /// + public class UserLuidPairType + { + /// + /// Creates a new object. + /// + public UserLuidPairType() + { } + + /// + /// Creates a new object. + /// + /// The keychain user mapping pair + public UserLuidPairType(IKeychainUserMapping keychainUserMapping) + { + SourceSiteUserLuid = keychainUserMapping.SourceUserId; + DestinationSiteUserLuid = keychainUserMapping.DestinationUserId; + } + + /// + /// Gets or sets the source site user LUID. + /// + [XmlAttribute("sourceSiteUserLuid")] + public Guid SourceSiteUserLuid { get; set; } + + /// + /// Gets or sets the destination site user LUID. + /// + [XmlAttribute("destinationSiteUserLuid")] + public Guid DestinationSiteUserLuid { get; set; } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/Cloud/CreateSubscriptionRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/Cloud/CreateSubscriptionRequest.cs new file mode 100644 index 00000000..15632b84 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/Cloud/CreateSubscriptionRequest.cs @@ -0,0 +1,320 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Xml.Serialization; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; + +using CloudModels = Tableau.Migration.Api.Models.Cloud; + +namespace Tableau.Migration.Api.Rest.Models.Requests.Cloud +{ + /// + /// + /// Class representing a create extract refresh task request. + /// + /// + /// See https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_subscriptions.htm#tableau-cloud-request for documentation. + /// + /// + [XmlType(XmlTypeName)] + public class CreateSubscriptionRequest : TableauServerRequest + { + /// + /// Gets or sets the subscription for the request. + /// + [XmlElement("subscription")] + public SubscriptionType? Subscription { get; set; } + + /// + /// Gets or sets the schedule for the request. + /// + [XmlElement("schedule")] + public ScheduleType? Schedule { get; set; } + + /// + /// Creates a new object. + /// + public CreateSubscriptionRequest() + { } + + /// + /// Creates a new object. + /// + /// The subscription creation options. + public CreateSubscriptionRequest(CloudModels.ICreateSubscriptionOptions options) + { + Subscription = new(options); + Schedule = new(options.Schedule); + } + + /// + /// Class representing a request subscription item. + /// + public sealed class SubscriptionType + { + /// + /// Gets or sets the subject for the subscription. + /// + [XmlAttribute("subject")] + public string? Subject { get; set; } + + /// + /// Gets or sets the attach image flag for the subscription. + /// + [XmlAttribute("attachImage")] + public bool AttachImage { get; set; } + + /// + /// Gets or sets the attach pdf flag for the subscription. + /// + [XmlAttribute("attachPdf")] + public bool AttachPdf { get; set; } + + /// + /// Gets or sets the page orientation of the subscription. + /// + [XmlAttribute("pageOrientation")] + public string? PageOrientation { get; set; } + + /// + /// Gets or sets the page page size option of the subscription. + /// + [XmlAttribute("pageSizeOption")] + public string? PageSizeOption { get; set; } + + /// + /// Gets or sets the message for the request. + /// + [XmlAttribute("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the content for the subscription. + /// + [XmlElement("content")] + public ContentType? Content { get; set; } + + /// + /// Gets or sets the user for the subscription. + /// + [XmlElement("user")] + public UserType? User { get; set; } + + /// + /// Creates a new object. + /// + public SubscriptionType() + { } + + /// + /// Creates a new object. + /// + /// The subscription creation options. + public SubscriptionType(CloudModels.ICreateSubscriptionOptions options) + { + Subject = options.Subject; + AttachImage = options.AttachImage; + AttachPdf = options.AttachPdf; + PageOrientation = options.PageOrientation; + PageSizeOption = options.PageSizeOption; + Message = options.Message; + + Content = new(options.Content); + User = new UserType { Id = options.UserId }; + } + + /// + /// Class representing a content type on the request. + /// + public class ContentType : ISubscriptionContentType + { + /// + /// Creates a new object. + /// + public ContentType() + { } + + /// + /// Creates a new object. + /// + /// A subscription content reference. + public ContentType(ISubscriptionContent content) + { + Id = content.Id; + Type = content.Type; + SendIfViewEmpty = content.SendIfViewEmpty; + } + + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("type")] + public string? Type { get; set; } + + /// + [XmlAttribute("sendIfViewEmpty")] + public bool SendIfViewEmpty { get; set; } + } + + /// + /// Class representing a subscription user on the request. + /// + public class UserType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + } + } + + /// + /// Class representing a request schedule item. + /// + public class ScheduleType + { + /// + /// Gets or sets the frequency for the request. + /// + [XmlAttribute("frequency")] + public string? Frequency { get; set; } + + /// + /// Gets or sets the frequency details for the request. + /// + [XmlElement("frequencyDetails")] + public FrequencyDetailsType? FrequencyDetails { get; set; } + + /// + /// Creates a new instance. + /// + public ScheduleType() + { } + + /// + /// Creates a new instance. + /// + /// The schedule to copy from. + public ScheduleType(ICloudSchedule schedule) + { + Frequency = schedule.Frequency; + FrequencyDetails = new(schedule.FrequencyDetails); + } + + /// + /// Class representing a request frequency details item. + /// + public class FrequencyDetailsType : IScheduleFrequencyDetailsType + { + /// + /// Gets or sets the start time for the request. + /// + [XmlAttribute("start")] + public string? Start { get; set; } + + /// + /// Gets or sets the end time for the request. + /// + [XmlAttribute("end")] + public string? End { get; set; } + + /// + /// Gets or sets the intervals for the request. + /// + [XmlArray("intervals")] + [XmlArrayItem("interval")] + public IntervalType[] Intervals { get; set; } = Array.Empty(); + + /// + IScheduleIntervalType[] IScheduleFrequencyDetailsType.Intervals => Intervals; + + /// + /// Creates a new instance. + /// + public FrequencyDetailsType() + { } + + /// + /// Creates a new instance. + /// + /// The frequency details to copy from. + public FrequencyDetailsType(IFrequencyDetails frequencyDetails) + { + Start = frequencyDetails.StartAt?.ToString(Constants.FrequencyTimeFormat); + End = frequencyDetails.EndAt?.ToString(Constants.FrequencyTimeFormat); + Intervals = frequencyDetails.Intervals.Select(i => new IntervalType(i)).ToArray(); + } + + /// + /// Class representing a request interval item. + /// + public class IntervalType : IScheduleIntervalType + { + /// + /// Gets or sets the hours for the request. + /// + [XmlAttribute("hours")] + public string? Hours { get; set; } + + /// + /// Gets or sets the minutes for the request. + /// + [XmlAttribute("minutes")] + public string? Minutes { get; set; } + + /// + /// Gets or sets the weekday for the request. + /// + [XmlAttribute("weekDay")] + public string? WeekDay { get; set; } + + /// + /// Gets or sets the month/day for the request. + /// + [XmlAttribute("monthDay")] + public string? MonthDay { get; set; } + + /// + /// Creates a new instance. + /// + public IntervalType() + { } + + /// + /// Creates a new instance. + /// + /// The interval to copy from. + public IntervalType(IInterval interval) + { + Hours = interval.Hours?.ToString(); + Minutes = interval.Minutes?.ToString(); + WeekDay = interval.WeekDay; + MonthDay = interval.MonthDay; + } + } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/Cloud/UpdateSubscriptionRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/Cloud/UpdateSubscriptionRequest.cs new file mode 100644 index 00000000..6606e97f --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/Cloud/UpdateSubscriptionRequest.cs @@ -0,0 +1,380 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Xml.Serialization; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; + +namespace Tableau.Migration.Api.Rest.Models.Requests.Cloud +{ + /// + /// + /// Class representing an update workbook request. + /// + /// + /// See Tableau API Reference for documentation. + /// + /// + [XmlType(XmlTypeName)] + public class UpdateSubscriptionRequest : TableauServerRequest + { + /// + /// Gets or sets the subscription for the request. + /// + [XmlElement("subscription")] + public SubcriptionType? Subscription { get; set; } + + /// + /// Gets or sets the schedule for the request. + /// + [XmlElement("schedule")] + public ScheduleType? Schedule { get; set; } + + /// + /// Creates a new object. + /// + public UpdateSubscriptionRequest() { } + + /// + /// Creates a new object. + /// + /// The new subject, or null to not update the subject. + /// The new attach image flag, or null to not update the flag. + /// The new attach PDF flag, or null to not update the flag. + /// The new page orientation, or null to not update the page orientation. + /// The new page size option, or null to not update the page size option. + /// The new suspended flag, or null to not update the flag. + /// The new message, or null to not update the message. + /// The new content reference, or null to not update the content reference. + /// The new user ID, or null to not update the user ID. + /// The new schedule, or null to not update the schedule. + public UpdateSubscriptionRequest( + string? newSubject = null, + bool? newAttachImage = null, + bool? newAttachPdf = null, + string? newPageOrientation = null, + string? newPageSizeOption = null, + bool? newSuspended = null, + string? newMessage = null, + ISubscriptionContent? newContent = null, + Guid? newUserId = null, + ICloudSchedule? newSchedule = null) + { + Subscription = new(newAttachImage, newAttachPdf, newSuspended) + { + Subject = newSubject, + PageOrientation = newPageOrientation, + PageSizeOption = newPageSizeOption, + Message = newMessage, + Content = newContent is null ? null : new(newContent), + User = newUserId is null ? null : new() { Id = newUserId.Value } + }; + + Schedule = newSchedule is null ? null : new(newSchedule); + } + + /// + /// Class representing a request subscription item. + /// + public sealed class SubcriptionType + { + /// + /// Gets or sets the subject for the subscription. + /// + [XmlElement("subject")] + public string? Subject { get; set; } + + /// + /// Gets or sets the attach image flag for the subscription. + /// + [XmlAttribute("attachImage")] + public bool AttachImage + { + get => _attachImage!.Value; + set => _attachImage = value; + } + private bool? _attachImage; + + /// + /// Defines the serialization for the property . + /// + [XmlIgnore] + public bool AttachImageSpecified => _attachImage.HasValue; + + /// + /// Gets or sets the attach pdf flag for the subscription. + /// + [XmlAttribute("attachPdf")] + public bool AttachPdf + { + get => _attachPdf!.Value; + set => _attachPdf = value; + } + private bool? _attachPdf; + + /// + /// Defines the serialization for the property . + /// + [XmlIgnore] + public bool AttachPdfSpecified => _attachPdf.HasValue; + + /// + /// Gets or sets the page orientation of the subscription. + /// + [XmlAttribute("pageOrientation")] + public string? PageOrientation { get; set; } + + /// + /// Gets or sets the page page size option of the subscription. + /// + [XmlAttribute("pageSizeOption")] + public string? PageSizeOption { get; set; } + + /// + /// Gets the suspended state for the subscription. + /// + [XmlAttribute("suspended")] + public bool Suspended + { + get => _suspended!.Value; + set => _suspended = value; + } + private bool? _suspended; + + /// + /// Defines the serialization for the property . + /// + [XmlIgnore] + public bool SuspendedSpecified => _suspended.HasValue; + + /// + /// Gets or sets the message for the request. + /// + [XmlElement("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the content for the subscription. + /// + [XmlElement("content")] + public ContentType? Content { get; set; } + + /// + /// Gets or sets the user for the subscription. + /// + [XmlElement("user")] + public UserType? User { get; set; } + + /// + /// Creates a new object. + /// + public SubcriptionType() + { } + + /// + /// Creates a new object. + /// + /// The new attach image flag, or null to not update the flag. + /// The new attach PDF flag, or null to not update the flag. + /// The new suspended flag, or null to not update the flag. + public SubcriptionType(bool? attachImage, bool? attachPdf, bool? suspended) + { + _attachImage = attachImage; + _attachPdf = attachPdf; + _suspended = suspended; + } + + /// + /// Class representing a content type on the request. + /// + public class ContentType : ISubscriptionContentType + { + /// + /// Creates a new object. + /// + public ContentType() + { } + + /// + /// Creates a new object. + /// + /// A subscription content reference. + public ContentType(ISubscriptionContent content) + { + Id = content.Id; + Type = content.Type; + SendIfViewEmpty = content.SendIfViewEmpty; + } + + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("type")] + public string? Type { get; set; } + + /// + [XmlAttribute("sendIfViewEmpty")] + public bool SendIfViewEmpty { get; set; } + } + + /// + /// Class representing a subscription user on the request. + /// + public class UserType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + } + } + + /// + /// Class representing a request schedule item. + /// + public class ScheduleType + { + /// + /// Gets or sets the frequency for the request. + /// + [XmlAttribute("frequency")] + public string? Frequency { get; set; } + + /// + /// Gets or sets the frequency details for the request. + /// + [XmlElement("frequencyDetails")] + public FrequencyDetailsType? FrequencyDetails { get; set; } + + /// + /// Creates a new instance. + /// + public ScheduleType() + { } + + /// + /// Creates a new instance. + /// + /// The schedule to copy from. + public ScheduleType(ICloudSchedule schedule) + { + Frequency = schedule.Frequency; + FrequencyDetails = new(schedule.FrequencyDetails); + } + + /// + /// Class representing a request frequency details item. + /// + public class FrequencyDetailsType : IScheduleFrequencyDetailsType + { + /// + /// Gets or sets the start time for the request. + /// + [XmlAttribute("start")] + public string? Start { get; set; } + + /// + /// Gets or sets the end time for the request. + /// + [XmlAttribute("end")] + public string? End { get; set; } + + /// + /// Gets or sets the intervals for the request. + /// + [XmlArray("intervals")] + [XmlArrayItem("interval")] + public IntervalType[] Intervals { get; set; } = Array.Empty(); + + /// + IScheduleIntervalType[] IScheduleFrequencyDetailsType.Intervals => Intervals; + + /// + /// Creates a new instance. + /// + public FrequencyDetailsType() + { } + + /// + /// Creates a new instance. + /// + /// The frequency details to copy from. + public FrequencyDetailsType(IFrequencyDetails frequencyDetails) + { + Start = frequencyDetails.StartAt?.ToString(Constants.FrequencyTimeFormat); + End = frequencyDetails.EndAt?.ToString(Constants.FrequencyTimeFormat); + Intervals = frequencyDetails.Intervals.Select(i => new IntervalType(i)).ToArray(); + } + + /// + /// Class representing a request interval item. + /// + public class IntervalType : IScheduleIntervalType + { + /// + /// Gets or sets the hours for the request. + /// + [XmlAttribute("hours")] + public string? Hours { get; set; } + + /// + /// Gets or sets the minutes for the request. + /// + [XmlAttribute("minutes")] + public string? Minutes { get; set; } + + /// + /// Gets or sets the weekday for the request. + /// + [XmlAttribute("weekDay")] + public string? WeekDay { get; set; } + + /// + /// Gets or sets the month/day for the request. + /// + [XmlAttribute("monthDay")] + public string? MonthDay { get; set; } + + /// + /// Creates a new instance. + /// + public IntervalType() + { } + + /// + /// Creates a new instance. + /// + /// The interval to copy from. + public IntervalType(IInterval interval) + { + Hours = interval.Hours?.ToString(); + Minutes = interval.Minutes?.ToString(); + WeekDay = interval.WeekDay; + MonthDay = interval.MonthDay; + } + } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs index 4b1956dd..8caedddd 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs @@ -112,7 +112,7 @@ public class WorkbookType /// Gets or sets the views to hide or show in the request /// [XmlArray("connections")] - [XmlArrayItem("connections")] + [XmlArrayItem("connection")] public ConnectionType[] Connections { get; set; } = Array.Empty(); /// diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/CreateLocalGroupRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/CreateLocalGroupRequest.cs index fa277277..2a260c3d 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Requests/CreateLocalGroupRequest.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/CreateLocalGroupRequest.cs @@ -49,11 +49,22 @@ public CreateLocalGroupRequest() /// The minimum site role. public CreateLocalGroupRequest(string name, string? minimumSiteRole) { - Group = new GroupType + // Unlicensed is a special case where the minimum site role can't be in the request + if (string.Compare(minimumSiteRole, "Unlicensed", true) == 0) { - Name = name, - MinimumSiteRole = minimumSiteRole - }; + Group = new GroupType + { + Name = name + }; + } + else + { + Group = new GroupType + { + Name = name, + MinimumSiteRole = minimumSiteRole + }; + } } /// diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/ImportUsersFromCsvRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/ImportUsersFromCsvRequest.cs index 79ad07de..1831f775 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Requests/ImportUsersFromCsvRequest.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/ImportUsersFromCsvRequest.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Linq; using System.Xml.Serialization; +using Tableau.Migration.Content; namespace Tableau.Migration.Api.Rest.Models.Requests { @@ -38,15 +39,15 @@ public class ImportUsersFromCsvRequest : TableauServerRequest /// which is required for import if no other user payload is provided. /// public ImportUsersFromCsvRequest() - : this(new[] { new UserType() }) + : this([new UserType()]) { } /// /// Creates a new with a single user to specify a default authentication type. /// - /// The default authentication type to request. - public ImportUsersFromCsvRequest(string? authSetting) - : this(new[] { new UserType { AuthSetting = authSetting } }) + /// The default authentication type to request. + public ImportUsersFromCsvRequest(UserAuthenticationType authentication) + : this([new UserType(null, authentication)]) { } /// @@ -69,6 +70,24 @@ public ImportUsersFromCsvRequest(IEnumerable users) /// public class UserType { + /// + /// Creates a new . + /// + public UserType() + { } + + /// + /// Creates a new . + /// + /// The user name. + /// The user authentication type. + public UserType(string? name, UserAuthenticationType authentication) + { + Name = name; + AuthSetting = authentication.AuthenticationType; + IdpConfigurationId = authentication.IdpConfigurationId?.ToString(); + } + /// /// Gets or sets the username for the item. /// Use a null name to apply an empty user or default authentication type. @@ -81,6 +100,12 @@ public class UserType /// [XmlAttribute("authSetting")] public string? AuthSetting { get; set; } + + /// + /// Gets or sets the IdP configuration ID for the item. + /// + [XmlAttribute("idpConfigurationId")] + public string? IdpConfigurationId { get; set; } } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/RetrieveKeychainRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/RetrieveKeychainRequest.cs new file mode 100644 index 00000000..68261672 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/RetrieveKeychainRequest.cs @@ -0,0 +1,64 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; +using Tableau.Migration.Api.Models; + +namespace Tableau.Migration.Api.Rest.Models.Requests +{ + /// + /// Class representing an get embedded credentials retrieve keychain request. + /// + [XmlType(XmlTypeName)] + public class RetrieveKeychainRequest : TableauServerRequest + { + /// + /// Creates a new object. + /// + public RetrieveKeychainRequest() + { } + + /// + /// Creates a new object from . + /// + public RetrieveKeychainRequest(IDestinationSiteInfo destinationSiteInfo) + { + DestinationSiteUrlNamespace = destinationSiteInfo.ContentUrl; + DestinationSiteLuid = destinationSiteInfo.SiteId; + DestinationServerUrl = destinationSiteInfo.SiteUrl; + } + + /// + /// Gets or sets the site name for the destination. + /// + [XmlElement("destinationSiteUrlNamespace")] + public string? DestinationSiteUrlNamespace { get; set; } + + /// + /// Gets or sets the site ID for the destination. + /// + [XmlElement("destinationSiteLuid")] + public Guid DestinationSiteLuid { get; set; } + + /// + /// Gets or sets the Url for the destination Tableau instance. + /// + [XmlElement("destinationServerUrl")] + public string? DestinationServerUrl { get; set; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/RetrieveUserSavedCredentialsRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/RetrieveUserSavedCredentialsRequest.cs new file mode 100644 index 00000000..7d6c8537 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/RetrieveUserSavedCredentialsRequest.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; +using Tableau.Migration.Api.Models; + +namespace Tableau.Migration.Api.Rest.Models.Requests +{ + /// + /// + /// Class representing a retrieve saved creds request. + /// + /// + /// See Tableau API Reference + /// for documentation. + /// + /// + [XmlType(XmlTypeName)] + public class RetrieveUserSavedCredentialsRequest : TableauServerRequest + { + /// + /// The default parameterless constructor. + /// + public RetrieveUserSavedCredentialsRequest() + { } + + /// + /// Builds from . + /// + public RetrieveUserSavedCredentialsRequest(IDestinationSiteInfo options) + { + DestinationSiteUrlNamespace = options.ContentUrl; + DestinationSiteLuid = options.SiteId; + DestinationServerUrl = options.SiteUrl; + } + + /// + /// Gets or sets the destinationSiteUrlNamespace for the request. + /// + [XmlElement("destinationSiteUrlNamespace")] + public string? DestinationSiteUrlNamespace { get; set; } + + /// + /// Gets or sets the destinationSiteLuid for the request. + /// + [XmlElement("destinationSiteLuid")] + public Guid DestinationSiteLuid { get; set; } + + /// + /// Gets or sets the destinationServerUrl for the request. + /// + [XmlElement("destinationServerUrl")] + public string? DestinationServerUrl { get; set; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/UpdateUserRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/UpdateUserRequest.cs index 570e4b6c..603a4ddd 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Requests/UpdateUserRequest.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/UpdateUserRequest.cs @@ -16,6 +16,7 @@ // using System.Xml.Serialization; +using Tableau.Migration.Content; namespace Tableau.Migration.Api.Rest.Models.Requests { @@ -38,30 +39,40 @@ public UpdateUserRequest() { } /// /// Builds the Update request for a user. /// - /// The new Site Role for the user. - /// (Optional) The new Full Name for the user. + /// The new site role for the user. + /// (Optional) The new full name for the user. /// (Optional) The new email address for the user. /// (Optional) The new password for the user. - /// (Optional) The new email Auth Setting for the user. + /// (Optional) The new authentication for the user. public UpdateUserRequest(string newSiteRole, - string? newfullName = null, + string? newFullName = null, string? newEmail = null, string? newPassword = null, - string? newAuthSetting = null) + UserAuthenticationType? newAuthentication = null) { User = new UserType { SiteRole = newSiteRole }; - if (newfullName != null) - User.FullName = newfullName; + if (newFullName is not null) + User.FullName = newFullName; - if (newEmail != null) + if (newEmail is not null) User.Email = newEmail; - if (newPassword != null) + if (newPassword is not null) User.Password = newPassword; - if (newAuthSetting != null) - User.AuthSetting = newAuthSetting; + if (newAuthentication is not null) + { + // IdP configuration ID and auth setting are mutually exclusive, set the ID if available. + if (newAuthentication.Value.IdpConfigurationId is not null) + { + User.IdpConfigurationId = newAuthentication.Value.IdpConfigurationId.ToString(); + } + else + { + User.AuthSetting = newAuthentication.Value.AuthenticationType; + } + } } /// @@ -76,7 +87,7 @@ public UpdateUserRequest(string newSiteRole, public class UserType { /// - /// Gets or sets the fullName for the request. + /// Gets or sets the full name for the request. /// [XmlAttribute("fullName")] public string? FullName { get; set; } @@ -94,17 +105,22 @@ public class UserType public string? Password { get; set; } /// - /// Gets or sets the SiteRole for the request. + /// Gets or sets the site role for the request. /// [XmlAttribute("siteRole")] public string? SiteRole { get; set; } /// - /// Gets or sets the authSetting for the request. + /// Gets or sets the auth setting for the request. /// [XmlAttribute("authSetting")] public string? AuthSetting { get; set; } + /// + /// Gets or sets the IdP configuration ID for the request. + /// + [XmlAttribute("idpConfigurationId")] + public string? IdpConfigurationId { get; set; } } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/UploadSavedCredentialsRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/UploadSavedCredentialsRequest.cs new file mode 100644 index 00000000..b4b94519 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/UploadSavedCredentialsRequest.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Requests +{ + /// + /// + /// Class representing an upload saved credentials request. + /// + /// + /// See Tableau API Reference + /// for documentation. + /// + /// + [XmlType(XmlTypeName)] + public class UploadUserSavedCredentialsRequest : TableauServerRequest + { + /// + /// The default parameterless constructor. + /// + public UploadUserSavedCredentialsRequest() { } + + /// + /// Creates a new object. + /// + public UploadUserSavedCredentialsRequest(IEnumerable encryptedKeychains) + { + EncryptedKeychains = encryptedKeychains.ToArray(); + } + + /// + /// Gets or sets the encrypted keychains for the request. + /// + [XmlArray("encryptedKeychainList")] + [XmlArrayItem("encryptedKeychain", typeof(string))] + public string[] EncryptedKeychains { get; set; } = Array.Empty(); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/AddUserResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/AddUserResponse.cs index 969bf9e9..f3c90929 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/AddUserResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/AddUserResponse.cs @@ -35,7 +35,7 @@ public class AddUserResponse : TableauServerResponse /// /// Type for the User object. /// - public class UserType : IRestIdentifiable + public class UserType : IUserType { /// /// The uniquer identifier for the user. @@ -56,10 +56,16 @@ public class UserType : IRestIdentifiable public string? SiteRole { get; set; } /// - /// The site role for the user. + /// The auth setting for the user. /// [XmlAttribute("authSetting")] public string? AuthSetting { get; set; } + + /// + /// The IdP configuration ID for the user. + /// + [XmlAttribute("idpConfigurationId")] + public string? IdpConfigurationId { get; set; } } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/CreateExtractRefreshTaskResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/CreateExtractRefreshTaskResponse.cs index ec1cf0f1..ed715e09 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/CreateExtractRefreshTaskResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/CreateExtractRefreshTaskResponse.cs @@ -81,8 +81,8 @@ public class ExtractRefreshType : ICloudExtractRefreshType public WorkbookType? Workbook { get; set; } IRestIdentifiable? IWithDataSourceReferenceType.DataSource => DataSource; - IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; - + IWorkbookReferenceType? IWithWorkbookReferenceType.Workbook => Workbook; + /// /// Class representing a response data source item. /// @@ -98,7 +98,7 @@ public class DataSourceType : IRestIdentifiable /// /// Class representing a response workbook item. /// - public class WorkbookType : IRestIdentifiable + public class WorkbookType : IWorkbookReferenceType { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/CreateSubscriptionResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/CreateSubscriptionResponse.cs new file mode 100644 index 00000000..4e65542e --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/CreateSubscriptionResponse.cs @@ -0,0 +1,307 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses.Cloud +{ + /// + /// Class representing a subscription creation response. + /// https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_subscriptions.htm#tableau-cloud-request + /// + [XmlType(XmlTypeName)] + public class CreateSubscriptionResponse : TableauServerResponse + { + /// + /// Gets or sets the subscription for the response. + /// + [XmlElement("subscription")] + public override SubscriptionType? Item { get; set; } + + /// + /// Class representing a response subscription item. + /// + public class SubscriptionType : ISubscriptionType + { + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("subject")] + public string? Subject { get; set; } + + /// + [XmlAttribute("attachImage")] + public bool AttachImage { get; set; } + + /// + [XmlAttribute("attachPdf")] + public bool AttachPdf { get; set; } + + /// + [XmlAttribute("pageOrientation")] + public string? PageOrientation { get; set; } + + /// + [XmlAttribute("pageSizeOption")] + public string? PageSizeOption { get; set; } + + /// + [XmlAttribute("suspended")] + public bool Suspended { get; set; } + + /// + [XmlElement("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the content for the response. + /// + [XmlElement("content")] + public ContentType? Content { get; set; } + + /// + /// Gets or sets the schedule for the response. + /// + [XmlElement("schedule")] + public ScheduleType? Schedule { get; set; } + + ISubscriptionContentType? ISubscriptionType.Content => Content; + + /// + /// Gets or sets the user for the response. + /// + [XmlElement("user")] + public UserType? User { get; set; } + + IRestIdentifiable? ISubscriptionType.User => User; + + /// + /// Creates a new object. + /// + public SubscriptionType() + { } + + /// + /// Creates a new object. + /// + /// A subscription response to copy from. + /// The subscription schedule. + public SubscriptionType(ISubscriptionType subscription, ICloudScheduleType schedule) + { + Id = subscription.Id; + Subject = subscription.Subject; + AttachImage = subscription.AttachImage; + AttachPdf = subscription.AttachPdf; + PageOrientation = subscription.PageOrientation; + PageSizeOption = subscription.PageSizeOption; + Suspended = subscription.Suspended; + Message = subscription.Message; + + Content = subscription.Content is null ? null : new(subscription.Content); + User = subscription.User is null ? null : new UserType { Id = subscription.User.Id }; + Schedule = new(schedule); + } + + /// + /// Class representing a content type on the response. + /// + public class ContentType : ISubscriptionContentType + { + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("type")] + public string? Type { get; set; } + + /// + [XmlAttribute("sendIfViewEmpty")] + public bool SendIfViewEmpty { get; set; } + + /// + /// Creates a new object. + /// + public ContentType() + { } + + /// + /// Creates a new object. + /// + /// A subscription content reference. + public ContentType(ISubscriptionContentType content) + { + Id = content.Id; + Type = content.Type; + SendIfViewEmpty = content.SendIfViewEmpty; + } + } + + /// + /// Class representing a subscription user on the response. + /// + public class UserType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + } + + /// + /// Class representing a response schedule item. + /// + public class ScheduleType : ICloudScheduleType + { + /// + /// Gets or sets the frequency for the response. + /// + [XmlAttribute("frequency")] + public string? Frequency { get; set; } + + /// + /// Gets or sets the frequency details for the response. + /// + [XmlElement("frequencyDetails")] + public FrequencyDetailsType? FrequencyDetails { get; set; } + + /// + /// Gets or sets the next run at for the response. + /// + [XmlAttribute("nextRunAt")] + public string? NextRunAt { get; set; } + + IScheduleFrequencyDetailsType? IScheduleType.FrequencyDetails => FrequencyDetails; + + /// + /// Creates a new instance. + /// + public ScheduleType() + { } + + /// + /// Creates a new instance. + /// + /// The schedule to copy from. + public ScheduleType(ICloudScheduleType schedule) + { + Frequency = schedule.Frequency; + NextRunAt = schedule.NextRunAt; + FrequencyDetails = schedule.FrequencyDetails is null ? null : new(schedule.FrequencyDetails); + } + + /// + /// Class representing a response frequency details item. + /// + public class FrequencyDetailsType : IScheduleFrequencyDetailsType + { + /// + /// Gets or sets the start time for the response. + /// + [XmlAttribute("start")] + public string? Start { get; set; } + + /// + /// Gets or sets the end time for the response. + /// + [XmlAttribute("end")] + public string? End { get; set; } + + /// + /// Gets or sets the intervals for the response. + /// + [XmlArray("intervals")] + [XmlArrayItem("interval")] + public IntervalType[] Intervals { get; set; } = Array.Empty(); + + /// + IScheduleIntervalType[] IScheduleFrequencyDetailsType.Intervals => Intervals; + + /// + /// Creates a new instance. + /// + public FrequencyDetailsType() + { } + + /// + /// Creates a new instance. + /// + /// The frequency details to copy from. + public FrequencyDetailsType(IScheduleFrequencyDetailsType frequencyDetails) + { + Start = frequencyDetails.Start; + End = frequencyDetails.End; + Intervals = frequencyDetails.Intervals.Select(i => new IntervalType(i)).ToArray(); + } + + /// + /// Class representing a response interval item. + /// + public class IntervalType : IScheduleIntervalType + { + /// + /// Gets or sets the hours for the response. + /// + [XmlAttribute("hours")] + public string? Hours { get; set; } + + /// + /// Gets or sets the minutes for the response. + /// + [XmlAttribute("minutes")] + public string? Minutes { get; set; } + + /// + /// Gets or sets the weekday for the response. + /// + [XmlAttribute("weekDay")] + public string? WeekDay { get; set; } + + /// + /// Gets or sets the month/day for the response. + /// + [XmlAttribute("monthDay")] + public string? MonthDay { get; set; } + + /// + /// Creates a new instance. + /// + public IntervalType() + { } + + /// + /// Creates a new instance. + /// + /// The interval to copy from. + public IntervalType(IScheduleIntervalType interval) + { + Hours = interval.Hours; + Minutes = interval.Minutes; + WeekDay = interval.WeekDay; + MonthDay = interval.MonthDay; + } + } + } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ExtractRefreshTasksResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ExtractRefreshTasksResponse.cs index 8a8d1f91..3b353663 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ExtractRefreshTasksResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ExtractRefreshTasksResponse.cs @@ -84,7 +84,7 @@ public class ExtractRefreshType : ICloudExtractRefreshType Workbook; + IWorkbookReferenceType? IWithWorkbookReferenceType.Workbook => Workbook; IRestIdentifiable? IWithDataSourceReferenceType.DataSource => DataSource; /// @@ -186,7 +186,7 @@ public class DataSourceType : IRestIdentifiable /// /// Class representing a response workbook item. /// - public class WorkbookType : IRestIdentifiable + public class WorkbookType : IWorkbookReferenceType { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/GetSubscriptionsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/GetSubscriptionsResponse.cs new file mode 100644 index 00000000..9bb76c3e --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/GetSubscriptionsResponse.cs @@ -0,0 +1,289 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses.Cloud +{ + /// + /// Class representing a Get Cloud Subscriptions response. + /// See Tableau API Reference for documentation. + /// + [XmlType(XmlTypeName)] + public class GetSubscriptionsResponse : PagedTableauServerResponse + { + /// + /// The default parameterless constructor. + /// + public GetSubscriptionsResponse() + { } + + /// + /// Gets or sets the subscriptions for the response. + /// + [XmlArray("subscriptions")] + [XmlArrayItem("subscription")] + public override SubscriptionType[] Items { get; set; } = []; + + /// + /// Class representing a subscription on the response. + /// + public class SubscriptionType : ISubscriptionType + { + /// + /// The default parameterless constructor. + /// + public SubscriptionType() + { } + + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("subject")] + public string? Subject { get; set; } + + /// + [XmlAttribute("attachImage")] + public bool AttachImage { get; set; } + + /// + [XmlAttribute("attachPdf")] + public bool AttachPdf { get; set; } + + /// + [XmlAttribute("pageOrientation")] + public string? PageOrientation { get; set; } + + /// + [XmlAttribute("pageSizeOption")] + public string? PageSizeOption { get; set; } + + /// + [XmlAttribute("suspended")] + public bool Suspended { get; set; } + + /// + [XmlAttribute("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the content for the response. + /// + [XmlElement("content")] + public ContentType? Content { get; set; } + + ISubscriptionContentType? ISubscriptionType.Content => Content; + + /// + /// Gets or sets the schedule for the response. + /// + [XmlElement("schedule")] + public ScheduleType? Schedule { get; set; } + + /// + /// Gets or sets the user for the response. + /// + [XmlElement("user")] + public UserType? User { get; set; } + + IRestIdentifiable? ISubscriptionType.User => User; + + + /// + /// Class representing a content type on the response. + /// + public class ContentType : ISubscriptionContentType + { + /// + /// Creates a new object. + /// + public ContentType() + { } + + /// + /// Creates a new object. + /// + /// A subscription content reference. + public ContentType(ISubscriptionContentType content) + { + Id = content.Id; + Type = content.Type; + SendIfViewEmpty = content.SendIfViewEmpty; + } + + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("type")] + public string? Type { get; set; } + + /// + [XmlAttribute("sendIfViewEmpty")] + public bool SendIfViewEmpty { get; set; } + } + + /// + /// Class representing a response schedule item. + /// + public class ScheduleType : ICloudScheduleType + { + /// + /// Gets or sets the frequency for the response. + /// + [XmlAttribute("frequency")] + public string? Frequency { get; set; } + + /// + /// Gets or sets the frequency details for the response. + /// + [XmlElement("frequencyDetails")] + public FrequencyDetailsType? FrequencyDetails { get; set; } + + /// + /// Gets or sets the next run at for the response. + /// + [XmlAttribute("nextRunAt")] + public string? NextRunAt { get; set; } + + IScheduleFrequencyDetailsType? IScheduleType.FrequencyDetails => FrequencyDetails; + + /// + /// Class representing a response frequency details item. + /// + public class FrequencyDetailsType : IScheduleFrequencyDetailsType + { + /// + /// Gets or sets the start time for the response. + /// + [XmlAttribute("start")] + public string? Start { get; set; } + + /// + /// Gets or sets the end time for the response. + /// + [XmlAttribute("end")] + public string? End { get; set; } + + /// + /// Gets or sets the intervals for the response. + /// + [XmlArray("intervals")] + [XmlArrayItem("interval")] + public IntervalType[] Intervals { get; set; } = Array.Empty(); + + /// + IScheduleIntervalType[] IScheduleFrequencyDetailsType.Intervals => Intervals; + + /// + /// Creates a new instance. + /// + public FrequencyDetailsType() + { } + + /// + /// Creates a new instance. + /// + /// The frequency details to copy from. + public FrequencyDetailsType(IScheduleFrequencyDetailsType frequencyDetails) + { + Start = frequencyDetails.Start; + End = frequencyDetails.End; + Intervals = frequencyDetails.Intervals.Select(i => new IntervalType(i)).ToArray(); + } + + /// + /// Class representing a response interval item. + /// + public class IntervalType : IScheduleIntervalType + { + /// + /// Gets or sets the hours for the response. + /// + [XmlAttribute("hours")] + public string? Hours { get; set; } + + /// + /// Gets or sets the minutes for the response. + /// + [XmlAttribute("minutes")] + public string? Minutes { get; set; } + + /// + /// Gets or sets the weekday for the response. + /// + [XmlAttribute("weekDay")] + public string? WeekDay { get; set; } + + /// + /// Gets or sets the month/day for the response. + /// + [XmlAttribute("monthDay")] + public string? MonthDay { get; set; } + + /// + /// Creates a new instance. + /// + public IntervalType() + { } + + /// + /// Creates a new instance. + /// + /// The interval to copy from. + public IntervalType(IScheduleIntervalType interval) + { + Hours = interval.Hours; + Minutes = interval.Minutes; + WeekDay = interval.WeekDay; + MonthDay = interval.MonthDay; + } + } + } + } + + /// + /// Class representing a subscription user on the response. + /// + public class UserType : IRestIdentifiable + { + /// + /// The default parameterless constructor. + /// + public UserType() + { } + + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ICloudExtractRefreshType.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ICloudExtractRefreshType.cs index c8c34cf5..6a91303e 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ICloudExtractRefreshType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/ICloudExtractRefreshType.cs @@ -27,8 +27,8 @@ public interface ICloudExtractRefreshType : IExtractRefreshType /// /// Interface for a Cloud extract refresh response type. /// - public interface ICloudExtractRefreshType : ICloudExtractRefreshType, IExtractRefreshType - where TWorkbook : IRestIdentifiable + public interface ICloudExtractRefreshType : ICloudExtractRefreshType, IExtractRefreshType, IWithWorkbookReferenceType + where TWorkbook : IWorkbookReferenceType where TDataSource : IRestIdentifiable { } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/UpdateSubscriptionResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/UpdateSubscriptionResponse.cs new file mode 100644 index 00000000..24fb7758 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Cloud/UpdateSubscriptionResponse.cs @@ -0,0 +1,305 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses.Cloud +{ + /// + /// Class representing a subscription creation response. + /// https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_subscriptions.htm#tableau-cloud-request3 + /// + [XmlType(XmlTypeName)] + public class UpdateSubscriptionResponse : TableauServerResponse + { + /// + /// Gets or sets the subscription for the response. + /// + [XmlElement("subscription")] + public override SubscriptionType? Item { get; set; } + + /// + /// Gets or sets the schedule for the response. + /// + [XmlElement("schedule")] + public ScheduleType? Schedule { get; set; } + + /// + /// Class representing a response subscription item. + /// + public class SubscriptionType : ISubscriptionType + { + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("subject")] + public string? Subject { get; set; } + + /// + [XmlAttribute("attachImage")] + public bool AttachImage { get; set; } + + /// + [XmlAttribute("attachPdf")] + public bool AttachPdf { get; set; } + + /// + [XmlAttribute("pageOrientation")] + public string? PageOrientation { get; set; } + + /// + [XmlAttribute("pageSizeOption")] + public string? PageSizeOption { get; set; } + + /// + [XmlAttribute("suspended")] + public bool Suspended { get; set; } + + /// + [XmlElement("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the content for the response. + /// + [XmlElement("content")] + public ContentType? Content { get; set; } + + ISubscriptionContentType? ISubscriptionType.Content => Content; + + /// + /// Gets or sets the user for the response. + /// + [XmlElement("user")] + public UserType? User { get; set; } + + IRestIdentifiable? ISubscriptionType.User => User; + + /// + /// Creates a new object. + /// + public SubscriptionType() + { } + + /// + /// Creates a new object. + /// + /// A subscription response to copy from. + public SubscriptionType(ISubscriptionType subscription) + { + Id = subscription.Id; + Subject = subscription.Subject; + AttachImage = subscription.AttachImage; + AttachPdf = subscription.AttachPdf; + PageOrientation = subscription.PageOrientation; + PageSizeOption = subscription.PageSizeOption; + Suspended = subscription.Suspended; + Message = subscription.Message; + + Content = subscription.Content is null ? null : new(subscription.Content); + User = subscription.User is null ? null : new UserType { Id = subscription.User.Id }; + } + + /// + /// Class representing a content type on the response. + /// + public class ContentType : ISubscriptionContentType + { + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("type")] + public string? Type { get; set; } + + /// + [XmlAttribute("sendIfViewEmpty")] + public bool SendIfViewEmpty { get; set; } + + /// + /// Creates a new object. + /// + public ContentType() + { } + + /// + /// Creates a new object. + /// + /// A subscription content reference. + public ContentType(ISubscriptionContentType content) + { + Id = content.Id; + Type = content.Type; + SendIfViewEmpty = content.SendIfViewEmpty; + } + } + + /// + /// Class representing a subscription user on the response. + /// + public class UserType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + } + } + + /// + /// Class representing a response schedule item. + /// + public class ScheduleType : ICloudScheduleType + { + /// + /// Gets or sets the frequency for the response. + /// + [XmlAttribute("frequency")] + public string? Frequency { get; set; } + + /// + /// Gets or sets the frequency details for the response. + /// + [XmlElement("frequencyDetails")] + public FrequencyDetailsType? FrequencyDetails { get; set; } + + /// + /// Gets or sets the next run at for the response. + /// + [XmlAttribute("nextRunAt")] + public string? NextRunAt { get; set; } + + IScheduleFrequencyDetailsType? IScheduleType.FrequencyDetails => FrequencyDetails; + + /// + /// Creates a new instance. + /// + public ScheduleType() + { } + + /// + /// Creates a new instance. + /// + /// The schedule to copy from. + public ScheduleType(ICloudScheduleType schedule) + { + Frequency = schedule.Frequency; + NextRunAt = schedule.NextRunAt; + FrequencyDetails = schedule.FrequencyDetails is null ? null : new(schedule.FrequencyDetails); + } + + /// + /// Class representing a response frequency details item. + /// + public class FrequencyDetailsType : IScheduleFrequencyDetailsType + { + /// + /// Gets or sets the start time for the response. + /// + [XmlAttribute("start")] + public string? Start { get; set; } + + /// + /// Gets or sets the end time for the response. + /// + [XmlAttribute("end")] + public string? End { get; set; } + + /// + /// Gets or sets the intervals for the response. + /// + [XmlArray("intervals")] + [XmlArrayItem("interval")] + public IntervalType[] Intervals { get; set; } = Array.Empty(); + + /// + IScheduleIntervalType[] IScheduleFrequencyDetailsType.Intervals => Intervals; + + /// + /// Creates a new instance. + /// + public FrequencyDetailsType() + { } + + /// + /// Creates a new instance. + /// + /// The frequency details to copy from. + public FrequencyDetailsType(IScheduleFrequencyDetailsType frequencyDetails) + { + Start = frequencyDetails.Start; + End = frequencyDetails.End; + Intervals = frequencyDetails.Intervals.Select(i => new IntervalType(i)).ToArray(); + } + + /// + /// Class representing a response interval item. + /// + public class IntervalType : IScheduleIntervalType + { + /// + /// Gets or sets the hours for the response. + /// + [XmlAttribute("hours")] + public string? Hours { get; set; } + + /// + /// Gets or sets the minutes for the response. + /// + [XmlAttribute("minutes")] + public string? Minutes { get; set; } + + /// + /// Gets or sets the weekday for the response. + /// + [XmlAttribute("weekDay")] + public string? WeekDay { get; set; } + + /// + /// Gets or sets the month/day for the response. + /// + [XmlAttribute("monthDay")] + public string? MonthDay { get; set; } + + /// + /// Creates a new instance. + /// + public IntervalType() + { } + + /// + /// Creates a new instance. + /// + /// The interval to copy from. + public IntervalType(IScheduleIntervalType interval) + { + Hours = interval.Hours; + Minutes = interval.Minutes; + WeekDay = interval.WeekDay; + MonthDay = interval.MonthDay; + } + } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionResponse.cs index 7b9070fc..2837955e 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionResponse.cs @@ -102,6 +102,34 @@ public string? QueryTaggingEnabled get => QueryTaggingEnabledFlag.HasValue ? QueryTaggingEnabledFlag.ToString() : null; set => QueryTaggingEnabledFlag = !string.IsNullOrEmpty(value) ? bool.Parse(value) : default(bool?); } + + /// + [XmlAttribute("authenticationType")] + public string? AuthenticationType { get; } + + /// + [XmlIgnore] + public bool? UseOAuthManagedKeychainFlag { get; set; } + + /// + [XmlAttribute("useOAuthManagedKeychain")] + public string? UseOAuthManagedKeychain + { + get => UseOAuthManagedKeychainFlag.HasValue ? UseOAuthManagedKeychainFlag.ToString() : null; + set => UseOAuthManagedKeychainFlag = !string.IsNullOrEmpty(value) ? bool.Parse(value) : default(bool?); + } + + /// + [XmlIgnore] + public bool? EmbedPasswordFlag { get; set; } + + /// + [XmlAttribute("embedPassword")] + public string? EmbedPassword + { + get => EmbedPasswordFlag.HasValue ? EmbedPasswordFlag.ToString() : null; + set => EmbedPasswordFlag = !string.IsNullOrEmpty(value) ? bool.Parse(value) : default(bool?); + } } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionsResponse.cs index a2beede8..f3f18c9e 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionsResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/ConnectionsResponse.cs @@ -64,11 +64,12 @@ public ConnectionType(SimulatedConnection response) ServerPort = response.ServerPort; Type = response.ConnectionType; QueryTaggingEnabledFlag = response.QueryTaggingEnabled; - + var credentials = response.Credentials; - if (credentials != null) + if (credentials is not null) { ConnectionUsername = credentials.Name; + EmbedPassword = credentials.Embed; } } @@ -103,6 +104,34 @@ public string? QueryTaggingEnabled get => QueryTaggingEnabledFlag.HasValue ? QueryTaggingEnabledFlag.ToString() : null; set => QueryTaggingEnabledFlag = !string.IsNullOrEmpty(value) ? bool.Parse(value) : default(bool?); } + + /// + [XmlAttribute("authenticationType")] + public string? AuthenticationType { get; } + + /// + [XmlIgnore] + public bool? UseOAuthManagedKeychainFlag { get; set; } + + /// + [XmlAttribute("useOAuthManagedKeychain")] + public string? UseOAuthManagedKeychain + { + get => UseOAuthManagedKeychainFlag.HasValue ? UseOAuthManagedKeychainFlag.ToString() : null; + set => UseOAuthManagedKeychainFlag = !string.IsNullOrEmpty(value) ? bool.Parse(value) : default(bool?); + } + + /// + [XmlIgnore] + public bool? EmbedPasswordFlag { get; set; } + + /// + [XmlAttribute("embedPassword")] + public string? EmbedPassword + { + get => EmbedPasswordFlag.HasValue ? EmbedPasswordFlag.ToString() : null; + set => EmbedPasswordFlag = !string.IsNullOrEmpty(value) ? bool.Parse(value) : default(bool?); + } } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewResponse.cs index 51dd6502..0b95628b 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewResponse.cs @@ -90,7 +90,8 @@ public class CustomViewType : ICustomViewType [XmlElement("workbook")] public WorkbookType? Workbook { get; set; } - IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; + IWorkbookNamedReferenceType? IWithWorkbookNamedReferenceType.Workbook => Workbook; + IWorkbookReferenceType? IWithWorkbookReferenceType.Workbook => Workbook; /// /// Gets or sets the owner for the response. @@ -138,7 +139,7 @@ public ViewType(Guid id, string name) /// /// Class representing a REST API workbook on the response. /// - public class WorkbookType : IRestIdentifiable + public class WorkbookType : IWorkbookNamedReferenceType { /// /// The default parameterless constructor. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs index 23d14dae..33a96966 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs @@ -45,7 +45,7 @@ public class CustomViewResponseType : ICustomViewType public CustomViewResponseType() { } /// - /// + /// Constructor to build from . /// /// public CustomViewResponseType(ICustomViewType customView) @@ -130,7 +130,8 @@ public CustomViewResponseType(ICustomViewType customView) [XmlElement("workbook")] public WorkbookType? Workbook { get; set; } - IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; + IWorkbookNamedReferenceType? IWithWorkbookNamedReferenceType.Workbook => Workbook; + IWorkbookReferenceType? IWithWorkbookReferenceType.Workbook => Workbook; /// /// Gets or sets the owner for the response. @@ -161,7 +162,7 @@ public class ViewType : IRestIdentifiable /// /// Class representing a REST API workbook on the response. /// - public class WorkbookType : IRestIdentifiable + public class WorkbookType : IWorkbookNamedReferenceType { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs index 67656f1c..2dbfd3e2 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs @@ -159,7 +159,8 @@ internal DataSourceType(IDataSourceDetailsType response) [XmlElement("project")] public ProjectType? Project { get; set; } - IProjectReferenceType? IWithProjectType.Project => Project; + IProjectNamedReferenceType? IWithProjectNamedReferenceType.Project => Project; + IProjectReferenceType? IWithProjectReferenceType.Project => Project; /// /// Gets or sets the data source owner for the response. @@ -188,7 +189,7 @@ ITagType[] IWithTagTypes.Tags /// /// Class representing a project on the response. /// - public class ProjectType : IProjectReferenceType + public class ProjectType : IProjectNamedReferenceType { /// /// Gets or sets the ID for the response. @@ -209,10 +210,10 @@ public ProjectType() { } /// - /// Constructor to build from . + /// Constructor to build from . /// - /// The object. - public ProjectType(IProjectReferenceType project) + /// The object. + public ProjectType(IProjectNamedReferenceType project) { Id = project.Id; Name = project.Name; diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs index 3e3c6fb3..813f1499 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs @@ -113,7 +113,8 @@ public class DataSourceType : IDataSourceType [XmlElement("project")] public ProjectType? Project { get; set; } - IProjectReferenceType? IWithProjectType.Project => Project; + IProjectNamedReferenceType? IWithProjectNamedReferenceType.Project => Project; + IProjectReferenceType? IWithProjectReferenceType.Project => Project; /// /// Gets or sets the data source owner for the response. @@ -142,7 +143,7 @@ ITagType[] IWithTagTypes.Tags /// /// Class representing a project on the response. /// - public class ProjectType : IProjectReferenceType + public class ProjectType : IProjectNamedReferenceType { /// /// Gets or sets the ID for the response. @@ -163,10 +164,10 @@ public ProjectType() { } /// - /// Constructor to build from . + /// Constructor to build from . /// - /// The object. - public ProjectType(IProjectReferenceType project) + /// The object. + public ProjectType(IProjectNamedReferenceType project) { Id = project.Id; Name = project.Name; diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs index df8c36a8..9710378c 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs @@ -15,8 +15,8 @@ // limitations under the License. // -using System.Linq; using System; +using System.Linq; using System.Xml.Serialization; namespace Tableau.Migration.Api.Rest.Models.Responses @@ -87,7 +87,8 @@ public class FlowType : IFlowType [XmlElement("project")] public ProjectType? Project { get; set; } - IProjectReferenceType? IWithProjectType.Project => Project; + IProjectNamedReferenceType? IWithProjectNamedReferenceType.Project => Project; + IProjectReferenceType? IWithProjectReferenceType.Project => Project; /// /// Gets or sets the owner for the response. @@ -113,7 +114,7 @@ ITagType[] IWithTagTypes.Tags /// /// Class representing a REST API project response. /// - public class ProjectType : IProjectReferenceType + public class ProjectType : IProjectNamedReferenceType { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs index 43769d0c..c7bdc3ae 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs @@ -89,7 +89,8 @@ public class FlowType : IFlowType [XmlElement("project")] public ProjectType? Project { get; set; } - IProjectReferenceType? IWithProjectType.Project => Project; + IProjectNamedReferenceType? IWithProjectNamedReferenceType.Project => Project; + IProjectReferenceType? IWithProjectReferenceType.Project => Project; /// /// Gets or sets the owner for the response. @@ -115,7 +116,7 @@ ITagType[] IWithTagTypes.Tags /// /// Class representing a REST API project response. /// - public class ProjectType : IProjectReferenceType + public class ProjectType : IProjectNamedReferenceType { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/ISubscriptionContentType.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/ISubscriptionContentType.cs new file mode 100644 index 00000000..3a74130d --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/ISubscriptionContentType.cs @@ -0,0 +1,43 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Xml.Serialization; +using System; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// The interface for the content type of a subscription. + /// + public interface ISubscriptionContentType + { + /// + /// Gets or sets the ID for the response. + /// + public Guid Id { get; set; } + + /// + /// Gets or sets the type of content for the response. + /// + public string? Type { get; set; } + + /// + /// Gets or sets the send view if empty flag for the response. + /// + public bool SendIfViewEmpty { get; set; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs index fa57357e..59bd9fbe 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs @@ -98,7 +98,6 @@ public class ProjectType : IProjectType public string? ContentPermissions { get; set; } /// - /// Gets or sets the contentPermissions for the response. /// Gets or sets the parentProjectId for the response. /// /// diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/RetrieveKeychainResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/RetrieveKeychainResponse.cs new file mode 100644 index 00000000..86eff475 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/RetrieveKeychainResponse.cs @@ -0,0 +1,62 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// Class representing an embedded credentials retrieve keychain response. + /// + [XmlType(XmlTypeName)] + public class RetrieveKeychainResponse : TableauServerResponse + { + /// + /// Gets or sets the associated user IDs for the response. + /// + [XmlArray("associatedUserLuidList")] + [XmlArrayItem("associatedUserLuid")] + public Guid[] AssociatedUserLuidList { get; set; } = []; + + /// + /// Gets or sets the encrypted key chains for the response. + /// + [XmlArray("encryptedKeychainList")] + [XmlArrayItem("encryptedKeychain")] + public string[] EncryptedKeychainList { get; set; } = []; + + /// + /// The default parameterless constructor. + /// + public RetrieveKeychainResponse() + { } + + /// + /// Constructor to build from encrypted keychain list and associated user LUID list. + /// + /// The list of encrypted keychains for this response. + /// The list of associated user LUIDs for this response. + public RetrieveKeychainResponse(IEnumerable encryptedKeychainList, IEnumerable? associatedUserLuidList) + { + EncryptedKeychainList = encryptedKeychainList.ToArray(); + AssociatedUserLuidList = associatedUserLuidList?.ToArray() ?? []; + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/RetrieveSavedCredentialsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/RetrieveSavedCredentialsResponse.cs new file mode 100644 index 00000000..ff42751d --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/RetrieveSavedCredentialsResponse.cs @@ -0,0 +1,45 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Linq; +using System; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// Class representing a retrieve saved credentials response. + ///Tableau API Reference for documentation. + /// + [XmlType(XmlTypeName)] + public class RetrieveSavedCredentialsResponse : TableauServerResponse + { + /// + /// Gets or sets the encrypted keychains for the response. + /// + [XmlArray("encryptedKeychainList")] + [XmlArrayItem("encryptedKeychain", typeof(string))] + public string[] EncryptedKeychains { get; set; } = Array.Empty(); + + /// + /// Gets or sets the associated user luids for the response. + /// + [XmlArray("associatedUserLuidList")] + [XmlArrayItem("associatedUserLuid", typeof(string))] + public string[] AssociatedUserLuids { get; set; } = Array.Empty(); + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Server/ExtractRefreshTasksResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Server/ExtractRefreshTasksResponse.cs index 07cb7e03..2c20c8a5 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/Server/ExtractRefreshTasksResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Server/ExtractRefreshTasksResponse.cs @@ -84,7 +84,7 @@ public class ExtractRefreshType : IServerExtractRefreshType Workbook; + IWorkbookReferenceType? IWithWorkbookReferenceType.Workbook => Workbook; IRestIdentifiable? IWithDataSourceReferenceType.DataSource => DataSource; IScheduleReferenceType? IWithScheduleReferenceType.Schedule => Schedule; @@ -163,7 +163,7 @@ public class DataSourceType : IRestIdentifiable /// /// Class representing a response workbook item. /// - public class WorkbookType : IRestIdentifiable + public class WorkbookType : IWorkbookReferenceType { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/Server/GetSubscriptionsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/Server/GetSubscriptionsResponse.cs new file mode 100644 index 00000000..e99bbcf7 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/Server/GetSubscriptionsResponse.cs @@ -0,0 +1,175 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models.Responses.Server +{ + using System; + using System.Xml.Serialization; + + /// + /// Class representing a Get Server Subscriptions response. + /// See Tableau API Reference for documentation. + /// + [XmlType(XmlTypeName)] + public class GetSubscriptionsResponse : PagedTableauServerResponse + { + /// + /// The default parameterless constructor. + /// + public GetSubscriptionsResponse() + { } + + /// + /// Gets or sets the subscriptions for the response. + /// + [XmlArray("subscriptions")] + [XmlArrayItem("subscription")] + public override SubscriptionType[] Items { get; set; } = []; + + /// + /// Class representing a subscription on the response. + /// + public class SubscriptionType : ISubscriptionType + { + /// + /// The default parameterless constructor. + /// + public SubscriptionType() + { } + + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("subject")] + public string? Subject { get; set; } + + /// + [XmlAttribute("attachImage")] + public bool AttachImage { get; set; } + + /// + [XmlAttribute("attachPdf")] + public bool AttachPdf { get; set; } + + /// + [XmlAttribute("pageOrientation")] + public string? PageOrientation { get; set; } + + /// + [XmlAttribute("pageSizeOption")] + public string? PageSizeOption { get; set; } + + /// + [XmlAttribute("suspended")] + public bool Suspended { get; set; } + + /// + [XmlAttribute("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the content for the response. + /// + [XmlElement("content")] + public ContentType? Content { get; set; } + + ISubscriptionContentType? ISubscriptionType.Content => Content; + + /// + /// Gets or sets the schedule for the response. + /// + [XmlElement("schedule")] + public ScheduleType? Schedule { get; set; } + + /// + /// Gets or sets the user for the response. + /// + [XmlElement("user")] + public UserType? User { get; set; } + + IRestIdentifiable? ISubscriptionType.User => User; + + /// + /// Class representing a content type on the response. + /// + public class ContentType : ISubscriptionContentType + { + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + [XmlAttribute("type")] + public string? Type { get; set; } + + /// + [XmlAttribute("sendIfViewEmpty")] + public bool SendIfViewEmpty { get; set; } + } + + /// + /// Class representing a schedule on the response. + /// + public class ScheduleType + { + /// + /// The default parameterless constructor. + /// + public ScheduleType() + { } + + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + + /// + /// Class representing a subscription user on the response. + /// + public class UserType : IRestIdentifiable + { + /// + /// The default parameterless constructor. + /// + public UserType() + { } + + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/SiteAuthConfigurationsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/SiteAuthConfigurationsResponse.cs new file mode 100644 index 00000000..5bc3716f --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/SiteAuthConfigurationsResponse.cs @@ -0,0 +1,73 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// Class representing a site authentication configurations response. + /// See Tableau API Reference for documentation. + /// + [XmlType(XmlTypeName)] + public class SiteAuthConfigurationsResponse : TableauServerListResponse + { + /// + /// Gets or sets the configurations for the response. + /// + [XmlArray("siteAuthConfigurations")] + [XmlArrayItem("siteAuthConfiguration")] + public override SiteAuthConfigurationType[] Items { get; set; } = Array.Empty(); + + /// + /// Class representing a site authentication configuration item. + /// + public class SiteAuthConfigurationType + { + /// + /// Gets or sets the auth setting name for the item. + /// + [XmlAttribute("authSetting")] + public string? AuthSetting { get; set; } + + /// + /// Gets or sets the known provider alias for the item. + /// + [XmlAttribute("knownProviderAlias")] + public string? KnownProviderAlias { get; set; } + + /// + /// Gets or sets the IdP configuration name for the item. + /// + [XmlAttribute("idpConfigurationName")] + public string? IdpConfigurationName { get; set; } + + /// + /// Gets or sets the IdP configuration ID for the item. + /// + [XmlAttribute("idpConfigurationId")] + public Guid IdpConfigurationId { get; set; } + + /// + /// Gets or sets the enabled flag for the item. + /// + [XmlAttribute("enabled")] + public bool Enabled { get; set; } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateCustomViewResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateCustomViewResponse.cs index 1bea9813..00cdbd64 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateCustomViewResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateCustomViewResponse.cs @@ -89,7 +89,8 @@ public class CustomViewResponseType : ICustomViewType [XmlElement("workbook")] public WorkbookType? Workbook { get; set; } - IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; + IWorkbookNamedReferenceType? IWithWorkbookNamedReferenceType.Workbook => Workbook; + IWorkbookReferenceType? IWithWorkbookReferenceType.Workbook => Workbook; /// /// Gets or sets the owner for the response. @@ -120,7 +121,7 @@ public class ViewType : IRestIdentifiable /// /// Class representing a REST API workbook on the response. /// - public class WorkbookType : IRestIdentifiable + public class WorkbookType : IWorkbookNamedReferenceType { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateUserResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateUserResponse.cs index 425ff5a0..9ea629a0 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateUserResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateUserResponse.cs @@ -34,7 +34,7 @@ public class UpdateUserResponse : TableauServerResponse /// Type for the User object. /// - public class UserType + public class UserType : IUserType { /// /// The new Username of the user. @@ -65,6 +65,12 @@ public class UserType /// [XmlAttribute("authSetting")] public string? AuthSetting { get; set; } + + /// + /// The new IdP configuration ID for the user. + /// + [XmlAttribute("idpConfigurationId")] + public string? IdpConfigurationId { get; set; } } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/UsersResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/UsersResponse.cs index dced3232..13afeed4 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/UsersResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/UsersResponse.cs @@ -37,7 +37,7 @@ public class UsersResponse : PagedTableauServerResponse /// /// Class representing a user on the response. /// - public class UserType : IRestIdentifiable + public class UserType : IUserType { /// /// Gets or sets the ID for the response. @@ -87,6 +87,12 @@ public class UserType : IRestIdentifiable [XmlAttribute("authSetting")] public string? AuthSetting { get; set; } + /// + /// Gets or sets the IdP configuration ID for the response. + /// + [XmlAttribute("idpConfigurationId")] + public string? IdpConfigurationId { get; set; } + /// /// Gets or sets the language for the response. /// @@ -118,6 +124,7 @@ public class DomainType [XmlAttribute("name")] public string? Name { get; set; } } + #endregion } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/ViewResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/ViewResponse.cs index 35735ae7..31ea39da 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/ViewResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/ViewResponse.cs @@ -70,9 +70,9 @@ public class ViewType : IViewType /// [XmlElement("workbook")] - WorkbookReferenceType? Workbook { get; set; } + public WorkbookReferenceType? Workbook { get; set; } - IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; + IWorkbookReferenceType? IWithWorkbookReferenceType.Workbook => Workbook; /// /// Gets or sets the tags for the response. @@ -88,6 +88,13 @@ ITagType[] IWithTagTypes.Tags set => Tags = value.Select(t => new TagType(t)).ToArray(); } + /// + /// Gets or sets the project for the response. + /// + [XmlElement("project")] + public ProjectReferenceType? Project { get; set; } + + IProjectReferenceType? IWithProjectReferenceType.Project => Project; #region - Object Specific Types - @@ -120,10 +127,20 @@ public TagType(ITagType tag) /// Class representing a REST API workbook reference response. /// - public class WorkbookReferenceType : IRestIdentifiable + public class WorkbookReferenceType : IWorkbookReferenceType { /// - [XmlAttribute("label")] + [XmlAttribute("id")] + public Guid Id { get; set; } + } + + /// + /// Class representing a REST API project response. + /// + public class ProjectReferenceType : IProjectReferenceType + { + /// + [XmlAttribute("id")] public Guid Id { get; set; } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs index 4c0bc061..46eda14f 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs @@ -126,7 +126,9 @@ internal WorkbookType(IWorkbookDetailsType response) public ProjectType? Project { get; set; } /// - IProjectReferenceType? IWithProjectType.Project => Project; + IProjectNamedReferenceType? IWithProjectNamedReferenceType.Project => Project; + + IProjectReferenceType? IWithProjectReferenceType.Project => Project; /// /// Gets or sets the location for the response. @@ -165,10 +167,10 @@ ITagType[] IWithTagTypes.Tags /// [XmlArray("views")] [XmlArrayItem("view")] - public ViewReferenceType[] Views { get; set; } = Array.Empty(); + public WorkbookViewReferenceType[] Views { get; set; } = Array.Empty(); /// - IViewReferenceType[] IWorkbookDetailsType.Views => Views; + IWorkbookViewReferenceType[] IWorkbookDetailsType.Views => Views; /// /// Gets or sets the data acceleration config for the response. @@ -181,7 +183,7 @@ ITagType[] IWithTagTypes.Tags /// /// Class representing a REST API project response. /// - public class ProjectType : IProjectReferenceType + public class ProjectType : IProjectNamedReferenceType { /// [XmlAttribute("id")] @@ -198,10 +200,10 @@ public ProjectType() { } /// - /// Constructor to build from . + /// Constructor to build from . /// - /// The object. - public ProjectType(IProjectReferenceType project) + /// The object. + public ProjectType(IProjectNamedReferenceType project) { Id = project.Id; Name = project.Name; @@ -289,8 +291,38 @@ public TagType(ITagType tag) /// /// Class representing a REST API view response. /// - public class ViewReferenceType : IViewReferenceType + public class WorkbookViewReferenceType : IWorkbookViewReferenceType { + /// + /// The default parameterless constructor. + /// + public WorkbookViewReferenceType() + { } + + /// + /// Constructor to build from . + /// + /// The object. + public WorkbookViewReferenceType(IWorkbookViewReferenceType view) + { + Id = view.Id; + Name = view.Name; + ContentUrl = view.ContentUrl; + Tags = view.Tags.Select(tag => new ViewTagType(tag)).ToArray(); + } + + /// + /// Constructor to build from . + /// + /// The object. + public WorkbookViewReferenceType(IViewType view) + { + Id = view.Id; + Name = view.Name; + ContentUrl = view.ContentUrl; + Tags = view.Tags.Select(tag => new ViewTagType(tag)).ToArray(); + } + /// [XmlAttribute("id")] public Guid Id { get; set; } @@ -310,59 +342,50 @@ public class ViewReferenceType : IViewReferenceType [XmlArrayItem("tag")] public ViewTagType[] Tags { get; set; } = Array.Empty(); - /// - ITagType[] IViewReferenceType.Tags => Tags; - - /// - /// The default parameterless constructor. - /// - public ViewReferenceType() - { } - - /// - /// Constructor to build from . - /// - /// The object. - public ViewReferenceType(IViewReferenceType view) - { - Id = view.Id; - ContentUrl = view.ContentUrl; - } - - /// - /// Constructor to build from . - /// - /// The object. - public ViewReferenceType(IViewType view) + ITagType[] IWithTagTypes.Tags { - Id = view.Id; - ContentUrl = view.ContentUrl; + get => Tags; + set => Tags = value.Select(t => new ViewTagType(t)).ToArray(); } /// - /// Class representing a REST API tag response. + /// Class representing a REST API view tags response. /// public class ViewTagType : ITagType { + /// + /// The default parameterless constructor. + /// + public ViewTagType() + { } + + /// + /// Constructor to build from + /// + public ViewTagType(ITagType tag) + { + Label = tag.Label; + } + /// [XmlAttribute("label")] public string? Label { get; set; } } } + } + /// + /// Class representing a REST API data acceleration config response. + /// + public class DataAccelerationConfigType + { /// - /// Class representing a REST API data acceleration config response. + /// Gets or sets the acceleration enabled value for the response. /// - public class DataAccelerationConfigType - { - /// - /// Gets or sets the acceleration enabled value for the response. - /// - [XmlAttribute("accelerationEnabled")] - public bool AccelerationEnabled { get; set; } - } - - #endregion + [XmlAttribute("accelerationEnabled")] + public bool AccelerationEnabled { get; set; } } + + #endregion } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs index 097e5b09..e38079f8 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs @@ -90,7 +90,8 @@ public class WorkbookType : IWorkbookType public ProjectType? Project { get; set; } /// - IProjectReferenceType? IWithProjectType.Project => Project; + IProjectNamedReferenceType? IWithProjectNamedReferenceType.Project => Project; + IProjectReferenceType? IWithProjectReferenceType.Project => Project; /// /// Gets or sets the location for the response. @@ -129,7 +130,7 @@ ITagType[] IWithTagTypes.Tags /// /// Class representing a REST API project response. /// - public class ProjectType : IProjectReferenceType + public class ProjectType : IProjectNamedReferenceType { /// [XmlAttribute("id")] @@ -146,10 +147,10 @@ public ProjectType() { } /// - /// Constructor to build from . + /// Constructor to build from . /// - /// The object. - public ProjectType(IProjectReferenceType project) + /// The object. + public ProjectType(IProjectNamedReferenceType project) { Id = project.Id; Name = project.Name; diff --git a/src/Tableau.Migration/Api/Rest/Models/RestProjectBuilder.cs b/src/Tableau.Migration/Api/Rest/Models/RestProjectBuilder.cs index a3a77128..677e6c5b 100644 --- a/src/Tableau.Migration/Api/Rest/Models/RestProjectBuilder.cs +++ b/src/Tableau.Migration/Api/Rest/Models/RestProjectBuilder.cs @@ -24,7 +24,6 @@ using Tableau.Migration.Content; using Tableau.Migration.Content.Search; using Tableau.Migration.Paging; -using Tableau.Migration.Resources; using RestProject = Tableau.Migration.Api.Rest.Models.Responses.ProjectsResponse.ProjectType; @@ -39,17 +38,6 @@ internal sealed class RestProjectBuilder { private readonly ImmutableDictionary _restProjectsById; - private static readonly ImmutableHashSet _systemProjectNames = - DefaultExternalAssetsProjectTranslations.GetAll() - .Append(Constants.DefaultProjectName) - // The admin insight project is usually named "Admin Insights" - // However, if that name is already taken when the real admin insights project is created - // one of the alternate names is used - .Append(Constants.AdminInsightsProjectName) - .Append(Constants.AdminInsightsTableauProjectName) - .Append(Constants.AdminInsightsTableauOnlineProjectName) - .ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); - public IEnumerable RestProjects => _restProjectsById.Values; public int Count => _restProjectsById.Count; @@ -71,7 +59,7 @@ public static async Task FindProjectOwnerAsync( if (foundOwner is null) { - if (restProject.Name is not null && _systemProjectNames.Contains(restProject.Name)) + if (restProject.Name is not null && Constants.SystemProjectNames.Contains(restProject.Name)) { return new ContentReferenceStub(ownerId, string.Empty, Constants.SystemUserLocation); } diff --git a/src/Tableau.Migration/Api/Rest/RestErrorCodes.cs b/src/Tableau.Migration/Api/Rest/RestErrorCodes.cs new file mode 100644 index 00000000..db3b88f5 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/RestErrorCodes.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Api.Rest +{ + internal static class RestErrorCodes + { + public const string BAD_REQUEST = "400000"; + + public const string CREATE_PROJECT_FORBIDDEN = "403045"; + + public const string GENERIC_CREATE_SUBSCRIPTION_ERROR = "400063"; + + public const string GENERIC_QUERY_JOB_ERROR = "400031"; + + public const string GROUP_NAME_CONFLICT_ERROR_CODE = "409009"; + + public const string INVALID_CAPABILITY_FOR_RESOURCE = "400009"; + + public const string LOGIN_ERROR = "401001"; + + public const string PROJECT_NAME_CONFLICT_ERROR_CODE = "409006"; + + public const string SITES_QUERY_NOT_SUPPORTED = "403069"; + + public const string CUSTOM_VIEW_ALREADY_EXISTS = "403166"; + + public const string FEATURE_DISABLED = "403157"; + + public static bool Equals(string? x, string? y) => string.Equals(x, y, StringComparison.Ordinal); + + } +} diff --git a/src/Tableau.Migration/Api/Rest/RestException.cs b/src/Tableau.Migration/Api/Rest/RestException.cs index d91284d9..d7ebde43 100644 --- a/src/Tableau.Migration/Api/Rest/RestException.cs +++ b/src/Tableau.Migration/Api/Rest/RestException.cs @@ -16,6 +16,7 @@ // using System; +using System.Diagnostics; using System.Net.Http; using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Resources; @@ -57,6 +58,11 @@ public class RestException : Exception, IEquatable /// public readonly string? CorrelationId; + /// + /// Gets the full stack trace. Added for easier debuggability. + /// + public readonly string? FullStackTrace; + /// /// Creates a new instance. /// @@ -65,12 +71,14 @@ public class RestException : Exception, IEquatable /// The request URI that generated the current error. /// The request Correlation ID /// The returned from the Tableau API. + /// The stack trace of the caller. Added for easier debuggability. /// Message for base Exception. internal RestException( HttpMethod? httpMethod, Uri? requestUri, string? correlationId, Error error, + string? fullStackTrace, string exceptionMessage) : base(exceptionMessage) { @@ -80,6 +88,7 @@ internal RestException( Code = error.Code; Detail = error.Detail; Summary = error.Summary; + FullStackTrace = fullStackTrace ?? new StackTrace(fNeedFileInfo: true).ToString(); } /// @@ -89,14 +98,16 @@ internal RestException( /// The request URI that generated the current error. /// The request Correlation ID /// The returned from the Tableau API. + /// The stack trace of the caller. Added for easier debuggability. /// A string localizer. public RestException( HttpMethod? httpMethod, Uri? requestUri, string? correlationId, Error error, + string? fullStackTrace, ISharedResourcesLocalizer sharedResourcesLocalizer) - : this(httpMethod, requestUri, correlationId, error, FormatError(httpMethod, requestUri, correlationId, error, sharedResourcesLocalizer)) + : this(httpMethod, requestUri, correlationId, error, fullStackTrace, FormatError(httpMethod, requestUri, correlationId, error, sharedResourcesLocalizer)) { } private static string FormatError( @@ -129,7 +140,8 @@ public bool Equals(RestException? other) RequestUri == other.RequestUri && Code == other.Code && Detail == other.Detail && - Summary == other.Summary; + Summary == other.Summary && + FullStackTrace == other.FullStackTrace; } /// @@ -146,7 +158,7 @@ public override bool Equals(object? obj) /// public override int GetHashCode() { - return HashCode.Combine(HttpMethod, RequestUri, Code, Detail, Summary); + return HashCode.Combine(HttpMethod, RequestUri, Code, Detail, Summary, FullStackTrace); } #endregion diff --git a/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs b/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs index a0e382d6..8a40313b 100644 --- a/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs +++ b/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using Tableau.Migration.Content; namespace Tableau.Migration.Api.Rest { @@ -36,7 +37,8 @@ internal static class RestUrlPrefixes [typeof(IViewsApiClient)] = Views, [typeof(IWorkbooksApiClient)] = Workbooks, [typeof(ITasksApiClient)] = Tasks, - [typeof(ICustomViewsApiClient)] = CustomViews + [typeof(ICustomViewsApiClient)] = CustomViews, + [typeof(ISubscriptionsApiClient)] = Subscriptions } .ToImmutableDictionary(InheritedTypeComparer.Instance); @@ -54,6 +56,7 @@ internal static class RestUrlPrefixes public const string Schedules = "schedules"; public const string Tasks = "tasks"; public const string CustomViews = "customviews"; + public const string Subscriptions = "subscriptions"; public static string GetUrlPrefix() where TApiClient : IContentApiClient diff --git a/src/Tableau.Migration/Api/Rest/StringExtensions.cs b/src/Tableau.Migration/Api/Rest/StringExtensions.cs index 486b3f1c..9b9c5260 100644 --- a/src/Tableau.Migration/Api/Rest/StringExtensions.cs +++ b/src/Tableau.Migration/Api/Rest/StringExtensions.cs @@ -65,5 +65,15 @@ public static TValue To(this string? value, Func conver return null; }); + + public static bool? ToBoolOrNull(this string? value) + => value.To( + v => + { + if (bool.TryParse(v, out var result)) + return result; + + return null; + }); } } diff --git a/src/Tableau.Migration/Api/SchedulesApiClientFactory.cs b/src/Tableau.Migration/Api/SchedulesApiClientFactory.cs new file mode 100644 index 00000000..4193167b --- /dev/null +++ b/src/Tableau.Migration/Api/SchedulesApiClientFactory.cs @@ -0,0 +1,60 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Microsoft.Extensions.Logging; +using Tableau.Migration.Config; +using Tableau.Migration.Content.Search; +using Tableau.Migration.Net.Rest; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Api +{ + internal sealed class SchedulesApiClientFactory : ISchedulesApiClientFactory + { + private readonly IRestRequestBuilderFactory _restRequestBuilderFactory; + private readonly IContentReferenceFinderFactory _finderFactory; + private readonly IContentCacheFactory _contentCacheFactory; + private readonly ILoggerFactory _loggerFactory; + private readonly ISharedResourcesLocalizer _sharedResourcesLocalizer; + private readonly IConfigReader _configReader; + + public SchedulesApiClientFactory( + IRestRequestBuilderFactory restRequestBuilderFactory, + IContentReferenceFinderFactory finderFactory, + IContentCacheFactory contentCacheFactory, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer sharedResourcesLocalizer, + IConfigReader configReader) + { + _restRequestBuilderFactory = restRequestBuilderFactory; + _finderFactory = finderFactory; + _contentCacheFactory = contentCacheFactory; + _loggerFactory = loggerFactory; + _sharedResourcesLocalizer = sharedResourcesLocalizer; + _configReader = configReader; + } + + public ISchedulesApiClient Create() + => new SchedulesApiClient( + _restRequestBuilderFactory, + _finderFactory, + _contentCacheFactory, + _loggerFactory, + _sharedResourcesLocalizer, + _configReader); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/CustomViewsRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/CustomViewsRestApiSimulator.cs index c98f6ce6..e6217670 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Api/CustomViewsRestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/CustomViewsRestApiSimulator.cs @@ -73,11 +73,9 @@ public CustomViewsRestApiSimulator(TableauApiResponseSimulator simulator) }); DownloadCustomView = simulator.SetupRestDownloadById( - SiteEntityUrl( - postSitePreEntitySuffix: RestUrlPrefixes.CustomViews, - postEntitySuffix: "content", - useExperimental: true), - (data) => data.CustomViewFiles, 4); + SiteEntityUrl(RestUrlPrefixes.CustomViews, "content"), + (data) => data.CustomViewFiles, + 4); CommitCustomViewUpload = simulator.SetupRestPost( SiteUrl(RestUrlPrefixes.CustomViews, useExperimental: true), @@ -94,7 +92,7 @@ public CustomViewsRestApiSimulator(TableauApiResponseSimulator simulator) data.CustomViewDefaultUsers.TryGetValue(customViewId.Value, out List? defaultUsers); - return defaultUsers == null ? [] : defaultUsers; + return defaultUsers ?? []; }); diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/DataSourcesRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/DataSourcesRestApiSimulator.cs index 4f903433..eed0b824 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Api/DataSourcesRestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/DataSourcesRestApiSimulator.cs @@ -37,7 +37,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Api /// /// Object that defines simulation of Tableau REST API data source methods. /// - public sealed class DataSourcesRestApiSimulator : TagsRestApiSimulatorBase + public sealed class DataSourcesRestApiSimulator : EmbeddedCredentialsRestApiSimulatorBase { /// /// Gets the simulated data source query API method. @@ -64,13 +64,11 @@ public sealed class DataSourcesRestApiSimulator : TagsRestApiSimulatorBase public MethodSimulator UpdateDataSource { get; } - /// /// Gets the simulated list connections API method. /// public MethodSimulator QueryDataSourceConnections { get; } - /// /// Gets the simulated update connection API method. /// @@ -81,10 +79,8 @@ public sealed class DataSourcesRestApiSimulator : TagsRestApiSimulatorBase /// A response simulator to setup with REST API methods. public DataSourcesRestApiSimulator(TableauApiResponseSimulator simulator) - : base( - simulator, - RestUrlPrefixes.DataSources, - (data) => data.DataSources) + : base(simulator, RestUrlPrefixes.DataSources, + data => data.DataSources, data => data.DataSourceKeychains) { QueryDataSource = simulator.SetupRestGet( SiteEntityUrl(ContentTypeUrlPrefix), diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/EmbeddedCredentialsRestApiSimulatorBase.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/EmbeddedCredentialsRestApiSimulatorBase.cs new file mode 100644 index 00000000..71311c6b --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/EmbeddedCredentialsRestApiSimulatorBase.cs @@ -0,0 +1,95 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Simulation.Rest.Net; +using Tableau.Migration.Api.Simulation.Rest.Net.Requests; +using Tableau.Migration.Api.Simulation.Rest.Net.Responses; +using Tableau.Migration.Net.Simulation; + +using static Tableau.Migration.Api.Simulation.Rest.Net.Requests.RestUrlPatterns; + +namespace Tableau.Migration.Api.Simulation.Rest.Api +{ + /// + /// Base class for objects that define simulation of Tableau REST API permissions, tags, and embedded credentials methods. + /// + public abstract class EmbeddedCredentialsRestApiSimulatorBase + : TagsRestApiSimulatorBase + where TContent : IRestIdentifiable, INamedContent, IWithTagTypes + where TTag : ITagType, new() + { + /// + /// Gets the simulated apply keychain API method. + /// + public MethodSimulator ApplyKeychain { get; } + + /// + /// Gets the simulated retrieve keychain API method. + /// + public MethodSimulator RetrieveKeychain { get; } + + /// + /// Creates a new object. + /// + /// A response simulator to setup with REST API methods. + /// The content type's URl prefix. + /// Delegate used to retrieve content items by ID. + /// Delegate used to retrieve keychains of content items by ID. + protected EmbeddedCredentialsRestApiSimulatorBase(TableauApiResponseSimulator simulator, string contentTypeUrlPrefix, + Func> getContent, + Func> getKeychains) + : base(simulator, contentTypeUrlPrefix, getContent) + { + ApplyKeychain = simulator.SetupRestPut(SiteEntityUrl(ContentTypeUrlPrefix, "applykeychain"), + new EmptyRestResponseBuilder(simulator.Data, simulator.Serializer, + (data, request) => + { + var id = request.GetRequestIdFromUri(hasSuffix: true); + + var applyKeychain = request.GetTableauServerRequest(); + if(applyKeychain is not null) + { + var destinationUserIds = applyKeychain.AssociatedUserLuidMapping?.Select(m => m.DestinationSiteUserLuid); + var keychains = new RetrieveKeychainResponse(applyKeychain.EncryptedKeychains, destinationUserIds); + + getKeychains(data).AddOrUpdate(id, keychains, (k, _) => keychains); + } + }, + requiresAuthentication: true)); + + RetrieveKeychain = simulator.SetupRestPost(SiteEntityUrl(ContentTypeUrlPrefix, "retrievekeychain"), + (data, request) => + { + var id = request.GetRequestIdFromUri(hasSuffix: true); + if(!getKeychains(data).TryGetValue(id, out var response)) + { + response = new(); + } + + return response; + }); + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/SubscriptionsRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/SubscriptionsRestApiSimulator.cs new file mode 100644 index 00000000..a2e87b71 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/SubscriptionsRestApiSimulator.cs @@ -0,0 +1,111 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Linq; +using System.Net; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Simulation.Rest.Net; +using Tableau.Migration.Api.Simulation.Rest.Net.Requests; +using Tableau.Migration.Api.Simulation.Rest.Net.Responses; +using Tableau.Migration.Net.Simulation; + +using static Tableau.Migration.Api.Simulation.Rest.Net.Requests.RestUrlPatterns; +using CloudResponse = Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using ServerResponse = Tableau.Migration.Api.Rest.Models.Responses.Server; + +namespace Tableau.Migration.Api.Simulation.Rest.Api +{ + /// + /// Object that defines simulation of Tableau REST API subscription methods. + /// + public sealed class SubscriptionsRestApiSimulator + { + /// + /// Gets the simulated subscription list API method. + /// + public MethodSimulator ListSubscriptions { get; } + + /// + /// Gets the simulated subscription create API method. + /// + public MethodSimulator CreateSubscription { get; } + + /// + /// Gets the simulated subscription update API method. + /// + public MethodSimulator UpdateSubscription { get; } + + /// + /// Gets the simulated subscription delete API method. + /// + public MethodSimulator DeleteSubscription { get; } + + /// + /// Creates a new object. + /// + /// A response simulator to setup with REST API methods. + public SubscriptionsRestApiSimulator(TableauApiResponseSimulator simulator) + { + var subscriptionsUrl = SiteUrl(RestUrlPrefixes.Subscriptions); + + ListSubscriptions = + simulator.Data.IsTableauServer + ? simulator.SetupRestGetList( + subscriptionsUrl, (d, r) => d.ServerSubscriptions, null, true) + : simulator.SetupRestGetList( + subscriptionsUrl, (d, r) => d.CloudSubscriptions, null, true); + + CreateSubscription = simulator.SetupRestPost(subscriptionsUrl, + new RestCreateSubscriptionResponseBuilder(simulator.Data, simulator.Serializer)); + + UpdateSubscription = simulator.SetupRestPut(EntityUrl(RestUrlPrefixes.Subscriptions), + new RestUpdateSubscriptionResponseBuilder(simulator.Data, simulator.Serializer)); + + + DeleteSubscription = simulator.SetupRestDelete(SiteEntityUrl(RestUrlPrefixes.Subscriptions), BuildDeleteResponseBuilder(simulator)); + } + + private static RestDeleteResponseBuilder BuildDeleteResponseBuilder(TableauApiResponseSimulator simulator) + { + return simulator.Data.IsTableauServer ? + new RestDeleteResponseBuilder(simulator.Data, + (data, request) => + { + var id = request.GetRequestIdFromUri(); + var serverSub = data.ServerSubscriptions.SingleOrDefault(s => s.Id == id); + if (serverSub != null) + { + data.ServerSubscriptions.Remove(serverSub); + return HttpStatusCode.OK; + } + return HttpStatusCode.NotFound; + }, simulator.Serializer) + : new RestDeleteResponseBuilder(simulator.Data, + (data, request) => + { + var id = request.GetRequestIdFromUri(); + var cloudSub = data.CloudSubscriptions.SingleOrDefault(s => s.Id == id); + if (cloudSub != null) + { + data.CloudSubscriptions.Remove(cloudSub); + return HttpStatusCode.OK; + } + return HttpStatusCode.NotFound; + }, simulator.Serializer); + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/UsersRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/UsersRestApiSimulator.cs index 7f1a8a5c..db95196a 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Api/UsersRestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/UsersRestApiSimulator.cs @@ -17,6 +17,7 @@ using System; using System.Linq; +using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Api.Simulation.Rest.Net; using Tableau.Migration.Api.Simulation.Rest.Net.Requests; @@ -57,6 +58,16 @@ public sealed class UsersRestApiSimulator /// public MethodSimulator UpdateUser { get; } + /// + /// Gets the simulated retrieve saved credentials API method. + /// + public MethodSimulator RetrieveUserSavedCredentials { get; } + + /// + /// Gets the simulated upload saved credentials API method. + /// + public MethodSimulator UploadUserSavedCredentials { get; } + /// /// Creates a new object. /// @@ -89,6 +100,36 @@ public UsersRestApiSimulator(TableauApiResponseSimulator simulator) UpdateUser = simulator.SetupRestPut( SiteEntityUrl("users"), new RestUserUpdateResponseBuilder(simulator.Data, simulator.Serializer, (d, _) => d.Users)); + + RetrieveUserSavedCredentials = simulator.SetupRestPost(SiteEntityUrl("users", "retrieveSavedCreds"), + (data, request) => + { + var userId = request.GetRequestIdFromUri(hasSuffix: true); + if (!data.UserSavedCredentials.TryGetValue(userId, out var response)) + { + response = new(); + } + + return response; + }); + + UploadUserSavedCredentials = simulator.SetupRestPut(SiteEntityUrl("users", "uploadSavedCreds"), + new EmptyRestResponseBuilder(simulator.Data, simulator.Serializer, + (data, request) => + { + var userId = request.GetRequestIdFromUri(hasSuffix: true); + + var uploadRequest = request.GetTableauServerRequest(); + + if (uploadRequest is null || uploadRequest.EncryptedKeychains.Length == 0) + { + return; + } + + var keychains = new RetrieveKeychainResponse(uploadRequest.EncryptedKeychains, [userId]); + data.UserSavedCredentials.AddOrUpdate(userId, keychains, (k, _) => keychains); + }, + requiresAuthentication: true)); } } } diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/ViewsRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/ViewsRestApiSimulator.cs index 552a7ba6..70b41ebc 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Api/ViewsRestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/ViewsRestApiSimulator.cs @@ -23,7 +23,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Api /// /// Object that defines simulation of Tableau REST API view permissions methods. /// - public sealed class ViewsRestApiSimulator : PermissionsRestApiSimulatorBase + public sealed class ViewsRestApiSimulator : PermissionsRestApiSimulatorBase { /// /// diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/WorkbooksRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/WorkbooksRestApiSimulator.cs index d3bc447a..b15b6c98 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Api/WorkbooksRestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/WorkbooksRestApiSimulator.cs @@ -37,7 +37,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Api /// /// Object that defines simulation of Tableau REST API workbook methods. /// - public sealed class WorkbooksRestApiSimulator : TagsRestApiSimulatorBase + public sealed class WorkbooksRestApiSimulator : EmbeddedCredentialsRestApiSimulatorBase { /// /// Gets the simulated workbook query API method. @@ -79,10 +79,8 @@ public sealed class WorkbooksRestApiSimulator : TagsRestApiSimulatorBase /// A response simulator to setup with REST API methods. public WorkbooksRestApiSimulator(TableauApiResponseSimulator simulator) - : base( - simulator, - RestUrlPrefixes.Workbooks, - (data) => data.Workbooks) + : base(simulator, RestUrlPrefixes.Workbooks, + data => data.Workbooks, data => data.WorkbookKeychains) { QueryWorkbook = simulator.SetupRestGet( SiteEntityUrl(ContentTypeUrlPrefix), diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs index 4e2f2696..58f0f54c 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs @@ -59,26 +59,24 @@ public static Regex RestApiUrl(string suffix, bool useExperimental = false) return new(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); } - public static Regex SiteUrl(string postSiteSuffix, bool useExperimental = false) + public static Regex SiteUrl(string postSiteSuffix, bool useExperimental = false) => RestApiUrl($"""/sites/{SiteId}/{postSiteSuffix.TrimPaths()}""", useExperimental); public static Regex EntityUrl(string preEntitySuffix) => RestApiUrl($"""{preEntitySuffix.TrimPaths()}/{EntityId}"""); - public static Regex SiteEntityUrl( - string postSitePreEntitySuffix, - string? postEntitySuffix = null, - bool useExperimental = false) + public static Regex SiteEntityUrl(string postSitePreEntitySuffix, string? postEntitySuffix = null, bool useExperimental = false) { var trimmedSuffix = postEntitySuffix?.TrimPaths(); trimmedSuffix = string.IsNullOrEmpty(trimmedSuffix) ? string.Empty : $"/{trimmedSuffix}"; - - return SiteUrl($"""{postSitePreEntitySuffix.TrimPaths()}/{EntityId}{trimmedSuffix}""", useExperimental); + var result = + SiteUrl($"""{postSitePreEntitySuffix.TrimPaths()}/{EntityId}{trimmedSuffix}""", useExperimental); + return result; } - public static Regex SiteEntityTagsUrl(string postSitePreEntitySuffix, string? postTagsSuffix = null) + public static Regex SiteEntityTagsUrl(string postSitePreEntitySuffix, string? postTagsSuffix = null) => SiteEntityUrl(postSitePreEntitySuffix, $"tags/{postTagsSuffix}".TrimEnd('/')); - public static Regex SiteEntityTagUrl(string postSitePreEntitySuffix) + public static Regex SiteEntityTagUrl(string postSitePreEntitySuffix) => SiteEntityTagsUrl(postSitePreEntitySuffix, new Regex(NamePattern, RegexOptions.IgnoreCase).ToString()); public static IEnumerable<(string Key, Regex ValuePattern)> SiteCommitFileUploadQueryString(string typeParam) diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/ResponseSimulatorExtensions.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/ResponseSimulatorExtensions.cs index f2ced500..acbcff6d 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/ResponseSimulatorExtensions.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/ResponseSimulatorExtensions.cs @@ -18,10 +18,12 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Net; using System.Net.Http; using System.Text.RegularExpressions; using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Api.Simulation.Rest.Net.Requests; using Tableau.Migration.Api.Simulation.Rest.Net.Responses; @@ -107,6 +109,52 @@ public static MethodSimulator SetupRestGet( queryStringPatterns ); + public static MethodSimulator SetupRestPost(this TableauApiResponseSimulator simulator, + Regex urlPattern, + Func buildResponse, + IEnumerable<(string Key, Regex ValuePattern)>? queryStringPatterns = null, + bool requiresAuthentication = true) + where TResponse : TableauServerResponse, new() + => simulator.SetupRestPost( + urlPattern, + new RestRequestResponseBuilder(simulator.Data, simulator.Serializer, buildResponse, requiresAuthentication), + queryStringPatterns); + + public static MethodSimulator SetupRestPost(this TableauApiResponseSimulator simulator, + Regex urlPattern, + Func buildResponse, + IEnumerable<(string Key, Regex ValuePattern)>? queryStringPatterns = null, + bool requiresAuthentication = true) + where TResponse : TableauServerResponse, new() + => simulator.SetupRestPost( + urlPattern, + new RestRequestResponseBuilder(simulator.Data, simulator.Serializer, buildResponse, requiresAuthentication), + queryStringPatterns); + + public static MethodSimulator SetupRestPost(this TableauApiResponseSimulator simulator, + Regex urlPattern, + Func buildResponse, + IEnumerable<(string Key, Regex ValuePattern)>? queryStringPatterns = null, + bool requiresAuthentication = true) + where TRequest: TableauServerRequest + where TResponse : TableauServerResponse, new() + => simulator.SetupRestPost( + urlPattern, + new RestRequestResponseBuilder(simulator.Data, simulator.Serializer, buildResponse, requiresAuthentication), + queryStringPatterns); + + public static MethodSimulator SetupRestPost(this TableauApiResponseSimulator simulator, + Regex urlPattern, + Func buildResponse, + IEnumerable<(string Key, Regex ValuePattern)>? queryStringPatterns = null, + bool requiresAuthentication = true) + where TRequest : TableauServerRequest + where TResponse : TableauServerResponse, new() + => simulator.SetupRestPost( + urlPattern, + new RestRequestResponseBuilder(simulator.Data, simulator.Serializer, buildResponse, requiresAuthentication), + queryStringPatterns); + public static MethodSimulator SetupRestPost( this TableauApiResponseSimulator simulator, Regex urlPattern, diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/EmptyRestResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/EmptyRestResponseBuilder.cs index e155cfcd..f78c66d7 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/EmptyRestResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/EmptyRestResponseBuilder.cs @@ -15,6 +15,7 @@ // limitations under the License. // +using System; using System.Linq; using System.Net; using System.Net.Http; @@ -30,6 +31,7 @@ internal class EmptyRestResponseBuilder : IRestApiResponseBuilder private static readonly UnauthorizedRestErrorBuilder _unauthorizedErrorBuilder = new(); private readonly IHttpContentSerializer _serializer; + private readonly Action _doWork; protected TableauData Data { get; } @@ -38,10 +40,18 @@ internal class EmptyRestResponseBuilder : IRestApiResponseBuilder public IRestErrorBuilder? ErrorOverride { get; set; } public EmptyRestResponseBuilder(TableauData data, IHttpContentSerializer serializer, bool requiresAuthentication) + : this(data, serializer, (_, _) => { }, requiresAuthentication) + { } + + public EmptyRestResponseBuilder(TableauData data, IHttpContentSerializer serializer, + Action doWork, + bool requiresAuthentication) { Data = data; _serializer = serializer; RequiresAuthentication = requiresAuthentication; + + _doWork = doWork; } protected virtual Task BuildResponseAsync(HttpRequestMessage request, CancellationToken cancel) @@ -76,6 +86,8 @@ public async Task RespondAsync(HttpRequestMessage request, } } + _doWork(Data, request); + return await BuildResponseAsync(request, cancel).ConfigureAwait(false); } } diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitDataSourceUploadResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitDataSourceUploadResponseBuilder.cs index 1efae4ad..4e329dce 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitDataSourceUploadResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitDataSourceUploadResponseBuilder.cs @@ -85,6 +85,9 @@ protected override DataSourceResponse.DataSourceType BuildContent( targetDataSource.Tags = []; + // Publishing resets embedded credentials. + Data.DataSourceKeychains.AddOrUpdate(targetDataSource.Id, new RetrieveKeychainResponse(), (_, _) => new RetrieveKeychainResponse()); + return targetDataSource; } diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitUploadResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitUploadResponseBuilder.cs index ba829164..7427aeb1 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitUploadResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitUploadResponseBuilder.cs @@ -31,7 +31,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal abstract class RestCommitUploadResponseBuilder : RestApiResponseBuilderBase + internal abstract class RestCommitUploadResponseBuilder : RestResponseBuilderBase where TResponse : TableauServerResponse, new() where TItem : class, IRestIdentifiable where TCommitRequest : TableauServerRequest diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitWorkbookUploadResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitWorkbookUploadResponseBuilder.cs index 5151cb6e..683beedc 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitWorkbookUploadResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitWorkbookUploadResponseBuilder.cs @@ -94,7 +94,7 @@ protected override WorkbookResponse.WorkbookType BuildContent( Id = commitWorkbook.Project?.Id ?? Data.DefaultProject.Id }, Tags = Array.Empty(), - Views = Array.Empty() + Views = Array.Empty() }; targetWorkbook.Name = commitWorkbook.Name; @@ -129,6 +129,9 @@ protected override WorkbookResponse.WorkbookType BuildContent( } } + // Publishing resets embedded credentials. + Data.WorkbookKeychains.AddOrUpdate(targetWorkbook.Id, new RetrieveKeychainResponse(), (_, _) => new RetrieveKeychainResponse()); + // Update view data foreach (var simulatedView in simulatedFileData.Views) { diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCreateSubscriptionResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCreateSubscriptionResponseBuilder.cs new file mode 100644 index 00000000..3f7c6c79 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCreateSubscriptionResponseBuilder.cs @@ -0,0 +1,125 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Api.Rest.Models.Requests.Cloud; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Tableau.Migration.Api.Simulation.Rest.Net.Requests; +using Tableau.Migration.Net; + +namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses +{ + internal sealed class RestCreateSubscriptionResponseBuilder : RestResponseBuilderBase + { + public RestCreateSubscriptionResponseBuilder(TableauData data, IHttpContentSerializer serializer) + : base(data, serializer, requiresAuthentication: true) + { } + + protected override ValueTask<(CreateSubscriptionResponse Response, HttpStatusCode ResponseCode)> BuildResponseAsync(HttpRequestMessage request, CancellationToken cancel) + { + var createRequest = request.GetTableauServerRequest(); + if (createRequest is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, "Content cannot be null.", string.Empty); + } + + var subscription = createRequest.Subscription; + if (subscription is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, $"{nameof(CreateSubscriptionRequest.Subscription)} cannot be null.", string.Empty); + } + if (subscription.Content is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, $"{nameof(CreateSubscriptionRequest.SubscriptionType.Content)} cannot be null.", string.Empty); + } + if (subscription.Content is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, $"{nameof(CreateSubscriptionRequest.SubscriptionType.Content)} cannot be null.", string.Empty); + } + if (subscription.User is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, $"{nameof(CreateSubscriptionRequest.SubscriptionType.User)} cannot be null.", string.Empty); + } + + var schedule = createRequest.Schedule; + if (schedule is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, $"{nameof(CreateSubscriptionRequest.Schedule)} cannot be null.", string.Empty); + } + if (schedule.FrequencyDetails is null) + { + return BuildEmptyErrorResponseAsync( + HttpStatusCode.BadRequest, 0, $"{nameof(CreateSubscriptionRequest.ScheduleType.FrequencyDetails)} cannot be null.", string.Empty); + } + + var user = Data.Users.SingleOrDefault(u => u.Id == subscription.User.Id); + if (user is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.NotFound, 002, $"{nameof(CreateSubscriptionRequest.SubscriptionType.User)} not found.", string.Empty); + } + + switch (subscription.Content.Type?.ToLower()) + { + case "workbook": + var wb = Data.Workbooks.SingleOrDefault(w => w.Id == subscription.Content.Id); + if (wb is null) + return BuildEmptyErrorResponseAsync(HttpStatusCode.NotFound, 006, $"Workbook not found.", string.Empty); + break; + case "view": + var view = Data.Workbooks.SingleOrDefault(w => w.Views.Any(v => v.Id == subscription.Content.Id)); + if (view is null) + return BuildEmptyErrorResponseAsync(HttpStatusCode.NotFound, 011, $"View not found.", string.Empty); + break; + default: + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadGateway, 0, $"Invalid content type.", string.Empty); + } + + var result = new GetSubscriptionsResponse.SubscriptionType + { + Id = Guid.NewGuid(), + Subject = subscription.Subject, + AttachImage = subscription.AttachImage, + AttachPdf = subscription.AttachPdf, + PageSizeOption = subscription.PageSizeOption, + PageOrientation = subscription.PageOrientation, + Suspended = false, //Created subscriptions never start as suspended. + Message = subscription.Message, + + Content = new(subscription.Content), + User = new() { Id = user.Id, Name = user.Name }, + Schedule = new() + { + Frequency = schedule.Frequency, + NextRunAt = string.Empty, + FrequencyDetails = new(schedule.FrequencyDetails) + } + }; + + Data.CloudSubscriptions.Add(result); + + return ValueTask.FromResult((new CreateSubscriptionResponse + { + Item = new(result, result.Schedule) + }, HttpStatusCode.Created)); + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCustomViewDefaultUsersAddResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCustomViewDefaultUsersAddResponseBuilder.cs index 755eb1bb..a211750b 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCustomViewDefaultUsersAddResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCustomViewDefaultUsersAddResponseBuilder.cs @@ -29,7 +29,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestCustomViewDefaultUsersAddResponseBuilder : RestApiResponseBuilderBase + internal class RestCustomViewDefaultUsersAddResponseBuilder : RestResponseBuilderBase { public RestCustomViewDefaultUsersAddResponseBuilder(TableauData data, IHttpContentSerializer serializer) : base(data, serializer, requiresAuthentication: true) diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestDefaultPermissionsCreateResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestDefaultPermissionsCreateResponseBuilder.cs index 3a71e9c4..0d583b2e 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestDefaultPermissionsCreateResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestDefaultPermissionsCreateResponseBuilder.cs @@ -28,7 +28,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestDefaultPermissionsCreateResponseBuilder : RestApiResponseBuilderBase + internal class RestDefaultPermissionsCreateResponseBuilder : RestResponseBuilderBase { private static readonly string UrlPrefix = RestUrlPrefixes.Projects; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestEntityListResponseBuilderBase.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestEntityListResponseBuilderBase.cs index 5d3f4b9d..691818ed 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestEntityListResponseBuilderBase.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestEntityListResponseBuilderBase.cs @@ -26,7 +26,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses /// /// Abstract base calss for REST API style response builders that operate on a list of entities in some form. /// - internal abstract class RestEntityListResponseBuilderBase : RestApiResponseBuilderBase + internal abstract class RestEntityListResponseBuilderBase : RestResponseBuilderBase where TResponse : TableauServerResponse, new() { private readonly Func> _getEntities; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestExtractRefreshTaskCreateResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestExtractRefreshTaskCreateResponseBuilder.cs index 96f4ac9f..6db72143 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestExtractRefreshTaskCreateResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestExtractRefreshTaskCreateResponseBuilder.cs @@ -28,7 +28,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestExtractRefreshTaskCreateResponseBuilder : RestApiResponseBuilderBase + internal class RestExtractRefreshTaskCreateResponseBuilder : RestResponseBuilderBase { public RestExtractRefreshTaskCreateResponseBuilder(TableauData data, IHttpContentSerializer serializer) : base(data, serializer, requiresAuthentication: true) diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestGroupAddResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestGroupAddResponseBuilder.cs index 6e3391bd..f52a9b88 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestGroupAddResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestGroupAddResponseBuilder.cs @@ -27,7 +27,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestGroupAddResponseBuilder : RestApiResponseBuilderBase + internal class RestGroupAddResponseBuilder : RestResponseBuilderBase { public RestGroupAddResponseBuilder(TableauData data, IHttpContentSerializer serializer) : base(data, serializer, requiresAuthentication: true) diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestInitiateFileUploadResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestInitiateFileUploadResponseBuilder.cs index f138ef48..278304bc 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestInitiateFileUploadResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestInitiateFileUploadResponseBuilder.cs @@ -25,7 +25,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestInitiateFileUploadResponseBuilder : RestApiResponseBuilderBase + internal class RestInitiateFileUploadResponseBuilder : RestResponseBuilderBase { public RestInitiateFileUploadResponseBuilder( TableauData data, diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilder.cs index 92b4147e..6cb53d8d 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilder.cs @@ -32,7 +32,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestPermissionsCreateResponseBuilder : RestApiResponseBuilderBase + internal class RestPermissionsCreateResponseBuilder : RestResponseBuilderBase where TContent : IRestIdentifiable, INamedContent { private readonly string _contentTypeUrlPrefix; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsGetResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsGetResponseBuilder.cs index 1d1e6406..2f75c693 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsGetResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestPermissionsGetResponseBuilder.cs @@ -30,7 +30,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestPermissionsGetResponseBuilder : RestApiResponseBuilderBase + internal class RestPermissionsGetResponseBuilder : RestResponseBuilderBase where TContent : IRestIdentifiable, INamedContent { private readonly string _contentTypeUrlPrefix; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestProjectCreateResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestProjectCreateResponseBuilder.cs index e55a6c14..fc4bfa58 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestProjectCreateResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestProjectCreateResponseBuilder.cs @@ -28,7 +28,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestProjectCreateResponseBuilder : RestApiResponseBuilderBase + internal class RestProjectCreateResponseBuilder : RestResponseBuilderBase { public RestProjectCreateResponseBuilder(TableauData data, IHttpContentSerializer serializer) : base(data, serializer, requiresAuthentication: true) diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestRequestResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestRequestResponseBuilder.cs new file mode 100644 index 00000000..787aafc0 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestRequestResponseBuilder.cs @@ -0,0 +1,86 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Simulation.Rest.Net.Requests; +using Tableau.Migration.Net; + +namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses +{ + internal class RestRequestResponseBuilder : RestResponseBuilderBase + where TResponse : TableauServerResponse, new() + { + private readonly Func _buildResponse; + + public RestRequestResponseBuilder(TableauData data, IHttpContentSerializer serializer, + Func buildResponse, + bool requiresAuthentication) + : base(data, serializer, requiresAuthentication) + { + _buildResponse = buildResponse; + } + + public RestRequestResponseBuilder(TableauData data, IHttpContentSerializer serializer, + Func buildResponse, + bool requiresAuthentication) + : this(data, serializer, (d, r) => (buildResponse(d, r), HttpStatusCode.OK), requiresAuthentication) + { } + + protected override ValueTask<(TResponse Response, HttpStatusCode ResponseCode)> BuildResponseAsync(HttpRequestMessage request, CancellationToken cancel) + { + return ValueTask.FromResult(_buildResponse(Data, request)); + } + } + + internal class RestRequestResponseBuilder : RestRequestResponseBuilder + where TRequest : TableauServerRequest + where TResponse : TableauServerResponse, new() + { + public RestRequestResponseBuilder(TableauData data, IHttpContentSerializer serializer, + Func buildResponse, + bool requiresAuthentication) + : base(data, serializer, ResponseBuilder(buildResponse), requiresAuthentication) + { } + + public RestRequestResponseBuilder(TableauData data, IHttpContentSerializer serializer, + Func buildResponse, + bool requiresAuthentication) + : base(data, serializer, ResponseBuilder((d, r) => (buildResponse(d, r), HttpStatusCode.OK)), requiresAuthentication) + { } + + private static Func ResponseBuilder(Func buildResponse) + { + return (TableauData data, HttpRequestMessage request) => + { + var typedRequest = request.GetTableauServerRequest(); + if(typedRequest is null) + { + return BuildEmptyErrorResponse(HttpStatusCode.BadRequest, 0, $"Request must be of the type {nameof(TRequest)} and not null", string.Empty); + } + + var result = buildResponse(data, typedRequest); + return result; + }; + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestApiResponseBuilderBase.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestResponseBuilderBase.cs similarity index 95% rename from src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestApiResponseBuilderBase.cs rename to src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestResponseBuilderBase.cs index 3e77478f..e4c08d7e 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestApiResponseBuilderBase.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestResponseBuilderBase.cs @@ -29,7 +29,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses /// /// Abstract base class for REST API style response builders. /// - internal abstract class RestApiResponseBuilderBase : IRestApiResponseBuilder + internal abstract class RestResponseBuilderBase : IRestApiResponseBuilder where TResponse : TableauServerResponse, new() { protected TableauData Data { get; } @@ -40,7 +40,7 @@ internal abstract class RestApiResponseBuilderBase : IRestApiResponse public IRestErrorBuilder? ErrorOverride { get; set; } - public RestApiResponseBuilderBase(TableauData data, IHttpContentSerializer serializer, bool requiresAuthentication) + public RestResponseBuilderBase(TableauData data, IHttpContentSerializer serializer, bool requiresAuthentication) { Data = data; Serializer = serializer; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestSingleEntityResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestSingleEntityResponseBuilder.cs index 4ee84a4c..9eee1807 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestSingleEntityResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestSingleEntityResponseBuilder.cs @@ -26,7 +26,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestSingleEntityResponseBuilder : RestApiResponseBuilderBase + internal class RestSingleEntityResponseBuilder : RestResponseBuilderBase where TResponse : TableauServerResponse, ITableauServerResponse, new() { private readonly Func _getEntity; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateConnectionResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateConnectionResponseBuilder.cs index 007b7df1..89e3dbe0 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateConnectionResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateConnectionResponseBuilder.cs @@ -30,7 +30,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestUpdateConnectionResponseBuilder : RestApiResponseBuilderBase + internal class RestUpdateConnectionResponseBuilder : RestResponseBuilderBase where TSimulatedData : SimulatedDataWithConnections { private readonly string ContentTypeUrlPrefix = string.Empty; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateFileUploadResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateFileUploadResponseBuilder.cs index d6379644..3683fa4e 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateFileUploadResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateFileUploadResponseBuilder.cs @@ -25,7 +25,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal sealed class RestUpdateFileUploadResponseBuilder : RestApiResponseBuilderBase + internal sealed class RestUpdateFileUploadResponseBuilder : RestResponseBuilderBase { public RestUpdateFileUploadResponseBuilder( TableauData data, diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateSubscriptionResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateSubscriptionResponseBuilder.cs new file mode 100644 index 00000000..af18f9f8 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUpdateSubscriptionResponseBuilder.cs @@ -0,0 +1,130 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Api.Rest.Models.Requests.Cloud; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Tableau.Migration.Api.Simulation.Rest.Net.Requests; +using Tableau.Migration.Net; + +namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses +{ + internal sealed class RestUpdateSubscriptionResponseBuilder : RestResponseBuilderBase + { + public RestUpdateSubscriptionResponseBuilder(TableauData data, IHttpContentSerializer serializer) + : base(data, serializer, requiresAuthentication: true) + { } + + protected override ValueTask<(UpdateSubscriptionResponse Response, HttpStatusCode ResponseCode)> BuildResponseAsync( + HttpRequestMessage request, + CancellationToken cancel) + { + var id = request.GetIdAfterSegment("subscriptions"); + if (id is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, "Invalid subscription ID.", string.Empty); + } + + var subscription = Data.CloudSubscriptions.SingleOrDefault(s => s.Id == id); + if(subscription is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.NotFound, 025, "Subscription not found.", string.Empty); + } + + var update = request.GetTableauServerRequest(); + if(update is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, "Invalid request.", string.Empty); + } + + if(update.Subscription is not null) + { + if(update.Subscription.Subject is not null) + { + subscription.Subject = update.Subscription.Subject; + } + + if(update.Subscription.AttachImageSpecified) + { + subscription.AttachImage = update.Subscription.AttachImage; + } + + if (update.Subscription.AttachPdfSpecified) + { + subscription.AttachPdf = update.Subscription.AttachPdf; + } + + if (update.Subscription.PageOrientation is not null) + { + subscription.PageOrientation = update.Subscription.PageOrientation; + } + + if (update.Subscription.PageSizeOption is not null) + { + subscription.PageSizeOption = update.Subscription.PageSizeOption; + } + + if (update.Subscription.SuspendedSpecified) + { + subscription.Suspended = update.Subscription.Suspended; + } + + if (update.Subscription.Message is not null) + { + subscription.Message = update.Subscription.Message; + } + + if(update.Subscription.Content is not null) + { + subscription.Content = new(update.Subscription.Content); + } + + if(update.Subscription.User is not null) + { + var user = Data.Users.SingleOrDefault(u => u.Id == update.Subscription.User.Id); + if (user is null) + { + return BuildEmptyErrorResponseAsync(HttpStatusCode.NotFound, 002, $"{nameof(UpdateSubscriptionRequest.SubcriptionType.User)} not found.", string.Empty); + } + + subscription.User = new() { Id = user.Id, Name = user.Name }; + } + } + + if(update.Schedule is not null) + { + subscription.Schedule = new() + { + Frequency = update.Schedule.Frequency, + FrequencyDetails = new(update.Schedule.FrequencyDetails!) + }; + } + + var response = new UpdateSubscriptionResponse + { + Item = new(subscription), + Schedule = new(subscription.Schedule!) + }; + + return ValueTask.FromResult((response, HttpStatusCode.OK)); + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddResponseBuilder.cs index 90a9bc47..2fb90aff 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddResponseBuilder.cs @@ -28,7 +28,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestUserAddResponseBuilder : RestApiResponseBuilderBase + internal class RestUserAddResponseBuilder : RestResponseBuilderBase { public RestUserAddResponseBuilder(TableauData data, IHttpContentSerializer serializer) : base(data, serializer, requiresAuthentication: true) @@ -49,15 +49,18 @@ public RestUserAddResponseBuilder(TableauData data, IHttpContentSerializer seria $"Request must be of the type {nameof(AddUserToSiteRequest.UserType)} and not null", ""); } + var siteRole = SiteRoleMapping.GetSiteRole( SiteRoleMapping.GetAdministratorLevel(addUserRequest?.SiteRole), SiteRoleMapping.GetLicenseLevel(addUserRequest?.SiteRole), SiteRoleMapping.GetPublishingCapability(addUserRequest?.SiteRole)); + var user = new UsersResponse.UserType() { Id = Guid.NewGuid(), Name = addUserRequest?.Name, AuthSetting = addUserRequest?.AuthSetting, + IdpConfigurationId = addUserRequest?.IdpConfigurationId, SiteRole = siteRole, Domain = TableauData.GetUserDomain(addUserRequest?.Name) ?? new() { Name = Data.DefaultDomain } }; @@ -70,6 +73,7 @@ public RestUserAddResponseBuilder(TableauData data, IHttpContentSerializer seria { Id = user.Id, AuthSetting = user.AuthSetting, + IdpConfigurationId = user.IdpConfigurationId, Name = user.Name, SiteRole = siteRole } diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddToGroupResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddToGroupResponseBuilder.cs index f9fc1451..9a4b872a 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddToGroupResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserAddToGroupResponseBuilder.cs @@ -29,7 +29,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestUserAddToGroupResponseBuilder : RestApiResponseBuilderBase + internal class RestUserAddToGroupResponseBuilder : RestResponseBuilderBase { public RestUserAddToGroupResponseBuilder(TableauData data, IHttpContentSerializer serializer) : base(data, serializer, requiresAuthentication: true) diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserImportResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserImportResponseBuilder.cs index ae16437a..c6081ff6 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserImportResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserImportResponseBuilder.cs @@ -30,7 +30,7 @@ namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses { - internal class RestUserImportResponseBuilder : RestApiResponseBuilderBase + internal class RestUserImportResponseBuilder : RestResponseBuilderBase { public RestUserImportResponseBuilder(TableauData data, IHttpContentSerializer serializer) : base(data, serializer, requiresAuthentication: true) @@ -67,9 +67,11 @@ protected static void AddUsers(TableauData data, StreamContent csvStreamContent) private static UsersResponse.UserType ParseUser(TableauData data, string[] columnData) { var username = columnData[0]; - string licenseLevel = columnData[3]; - string adminLevel = columnData[4]; - string publishingCapability = columnData[5]; + var fullName = columnData[2]; + var licenseLevel = columnData[3]; + var adminLevel = columnData[4]; + var publishingCapability = columnData[5]; + var email = columnData[6]; if (!bool.TryParse(publishingCapability, out bool canPublish)) { @@ -82,6 +84,8 @@ private static UsersResponse.UserType ParseUser(TableauData data, string[] colum { Id = Guid.NewGuid(), Name = username, + Email = data.IsTableauServer ? email : null, + FullName = data.IsTableauServer ? fullName : null, SiteRole = SiteRoleMapping.GetSiteRole(adminLevel, licenseLevel, canPublish), Domain = TableauData.GetUserDomain(username) ?? new() { Name = data.DefaultDomain } }; diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserUpdateResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserUpdateResponseBuilder.cs index 119302ca..3a4e768b 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserUpdateResponseBuilder.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestUserUpdateResponseBuilder.cs @@ -68,6 +68,9 @@ public RestUserUpdateResponseBuilder( if (!string.IsNullOrEmpty(newUser.AuthSetting)) oldUser.AuthSetting = newUser.AuthSetting; + if (!string.IsNullOrEmpty(newUser.IdpConfigurationId)) + oldUser.IdpConfigurationId = newUser.IdpConfigurationId; + return oldUser; } @@ -87,6 +90,7 @@ public RestUserUpdateResponseBuilder( Email = updatedUser.Email, SiteRole = updatedUser.SiteRole, AuthSetting = updatedUser.AuthSetting, + IdpConfigurationId = updatedUser.IdpConfigurationId, FullName = updatedUser.FullName, } diff --git a/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs index c2bb04a3..4a7df477 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs @@ -72,6 +72,11 @@ public sealed class RestApiSimulator /// public SitesRestApiSimulator Sites { get; } + /// + /// Gets the simulated subscription API methods. + /// + public SubscriptionsRestApiSimulator Subscriptions { get; } + /// /// Gets the simulated user API methods. /// @@ -157,6 +162,7 @@ public RestApiSimulator(TableauApiResponseSimulator simulator) Tasks = new(simulator); Projects = new(simulator); Sites = new(simulator); + Subscriptions = new(simulator); Users = new(simulator); Workbooks = new(simulator); Files = new(simulator); diff --git a/src/Tableau.Migration/Api/Simulation/TableauData.cs b/src/Tableau.Migration/Api/Simulation/TableauData.cs index 4059f8a9..1e153cd8 100644 --- a/src/Tableau.Migration/Api/Simulation/TableauData.cs +++ b/src/Tableau.Migration/Api/Simulation/TableauData.cs @@ -26,6 +26,7 @@ using Tableau.Migration.Content; using Tableau.Migration.Content.Permissions; using Tableau.Migration.Net.Rest; + using CloudResponse = Tableau.Migration.Api.Rest.Models.Responses.Cloud; using ServerResponse = Tableau.Migration.Api.Rest.Models.Responses.Server; @@ -91,6 +92,11 @@ public sealed class TableauData /// public ConcurrentDictionary WorkbookFiles { get; set; } = new(); + /// + /// Gets or sets the workbook keychain data, by ID. + /// + public ConcurrentDictionary WorkbookKeychains { get; set; } = new(); + /// /// Gets or sets the jobs. /// @@ -116,6 +122,16 @@ public sealed class TableauData /// public ConcurrentSet CloudExtractRefreshTasks { get; set; } = new(); + /// + /// Gets or sets the Tableau Server subscriptions. + /// + public ConcurrentSet ServerSubscriptions { get; set; } = new(); + + /// + /// Gets or sets the Tableau Cloud subscriptions. + /// + public ConcurrentSet CloudSubscriptions { get; set; } = new(); + /// /// Gets or sets the jobs. /// @@ -126,10 +142,15 @@ public sealed class TableauData /// public ConcurrentSet Users { get; set; } = new(); + /// + /// Gets or sets the user saved credential data, by ID. + /// + public ConcurrentDictionary UserSavedCredentials { get; set; } = new(); + /// /// Gets or sets the users. /// - public ConcurrentSet Views { get; set; } = new(); + public ConcurrentSet Views { get; set; } = new(); /// /// Gets or sets the data source permissions. @@ -162,10 +183,15 @@ public sealed class TableauData public ConcurrentSet DataSources { get; set; } = new(); /// - /// Gets or sets the data source fileData contents, by ID. + /// Gets or sets the data source file contents, by ID. /// public ConcurrentDictionary DataSourceFiles { get; set; } = new(); + /// + /// Gets or sets the data source keychain data, by ID. + /// + public ConcurrentDictionary DataSourceKeychains { get; set; } = new(); + /// /// Gets or sets the uploaded files, by session Id. /// @@ -176,14 +202,11 @@ public sealed class TableauData /// public ConcurrentSet CustomViews { get; set; } = new(); - /// /// Gets or sets the custom view fileData contents, by ID. /// public ConcurrentDictionary CustomViewFiles { get; set; } = new(); - - /// /// Gets or sets the custom view default users contents, by ID. /// @@ -277,6 +300,7 @@ public UsersResponse.UserType AddUser(UsersResponse.UserType user) Users.Add(user); UserGroups.TryAdd(user.Id, new()); + UserSavedCredentials.TryAdd(user.Id, new()); return user; } @@ -432,7 +456,7 @@ public void AddExtractToSchedule( { schedule = AddSchedule(schedule); ScheduleExtractRefreshTasks.Add(extract); - + ScheduleExtracts[schedule.Id].Add(extract.Id); } @@ -517,8 +541,8 @@ internal void AddWorkbook(WorkbookResponse.WorkbookType workbook, byte[]? fileDa /// /// Adds a view to simulated dataset. /// - /// The metadata - internal void AddView(WorkbookResponse.WorkbookType.ViewReferenceType view) + /// The metadata + internal void AddView(WorkbookResponse.WorkbookType.WorkbookViewReferenceType view) { Views.Add(view); } @@ -568,7 +592,7 @@ internal void AddDataSourcePermissions(IDataSourceType dataSource, PermissionsTy internal void AddWorkbookPermissions(IWorkbookType workbook, PermissionsType permission) => AddContentTypePermissions(RestUrlPrefixes.Workbooks, workbook.Id, permission); - internal void AddViewPermissions(IViewReferenceType view, PermissionsType permission) + internal void AddViewPermissions(IWorkbookViewReferenceType view, PermissionsType permission) => AddContentTypePermissions(RestUrlPrefixes.Views, view.Id, permission); internal void AddViewPermissions(Guid viewId, PermissionsType permission) diff --git a/src/Tableau.Migration/Api/SitesApiClient.cs b/src/Tableau.Migration/Api/SitesApiClient.cs index 677475f8..62a86aac 100644 --- a/src/Tableau.Migration/Api/SitesApiClient.cs +++ b/src/Tableau.Migration/Api/SitesApiClient.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -21,6 +21,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; @@ -44,6 +45,7 @@ internal sealed class SitesApiClient : ContentApiClientBase, ISitesApiClient private readonly IHttpContentSerializer _serializer; private readonly ITasksApiClient _tasksApiClient; + private readonly ISubscriptionsApiClient _subscriptionsApiClient; public SitesApiClient( IRestRequestBuilderFactory restRequestBuilderFactory, @@ -51,6 +53,7 @@ public SitesApiClient( IServerSessionProvider sessionProvider, IHttpContentSerializer serializer, ILoggerFactory loggerFactory, + IAuthenticationConfigurationsApiClient authenticationConfigurationsApiClient, IGroupsApiClient groupsApiClient, IJobsApiClient jobsApiClient, ISchedulesApiClient schedulesApiClient, @@ -62,12 +65,14 @@ public SitesApiClient( IFlowsApiClient flowsApiClient, ITasksApiClient tasksApiClient, ICustomViewsApiClient customViewsApiClient, + ISubscriptionsApiClient subscriptionsApiClient, ISharedResourcesLocalizer sharedResourcesLocalizer) : base(restRequestBuilderFactory, finderFactory, loggerFactory, sharedResourcesLocalizer) { _sessionProvider = sessionProvider; _serializer = serializer; + AuthenticationConfigurations = authenticationConfigurationsApiClient; Groups = groupsApiClient; Jobs = jobsApiClient; Schedules = schedulesApiClient; @@ -80,6 +85,7 @@ public SitesApiClient( CustomViews = customViewsApiClient; _tasksApiClient = tasksApiClient; + _subscriptionsApiClient = subscriptionsApiClient; } private static readonly ImmutableDictionary> _contentTypeAccessors = new Dictionary>(InheritedTypeComparer.Instance) @@ -95,6 +101,8 @@ public SitesApiClient( { typeof(IServerExtractRefreshTask), client => client.ServerTasks }, { typeof(ICloudExtractRefreshTask), client => client.CloudTasks }, { typeof(ICustomView), client => client.CustomViews }, + { typeof(IServerSubscription), client => client.ServerSubscriptions }, + { typeof(ICloudSubscription), client => client.CloudSubscriptions }, } .ToImmutableDictionary(InheritedTypeComparer.Instance); @@ -107,6 +115,9 @@ public SitesApiClient( #region - ISitesApiClient Implementation - + /// + public IAuthenticationConfigurationsApiClient AuthenticationConfigurations { get; } + /// public IGroupsApiClient Groups { get; } @@ -145,6 +156,14 @@ public IServerTasksApiClient ServerTasks public ICloudTasksApiClient CloudTasks => ReturnForInstanceType(TableauInstanceType.Cloud, _sessionProvider.InstanceType, _tasksApiClient); + /// + public IServerSubscriptionsApiClient ServerSubscriptions + => ReturnForInstanceType(TableauInstanceType.Server, _sessionProvider.InstanceType, _subscriptionsApiClient); + + /// + public ICloudSubscriptionsApiClient CloudSubscriptions + => ReturnForInstanceType(TableauInstanceType.Cloud, _sessionProvider.InstanceType, _subscriptionsApiClient); + /// public IReadApiClient? GetReadApiClient() where TContent : class @@ -155,9 +174,9 @@ public IPagedListApiClient GetListApiClient() => GetApiClientFromContentType>(typeof(TContent))!; /// - public IPullApiClient GetPullApiClient() - where TPublish : class - => GetApiClientFromContentType>(typeof(TContent))!; + public IPullApiClient GetPullApiClient() + where TPrepare : class + => GetApiClientFromContentType>(typeof(TContent))!; /// public IPublishApiClient GetPublishApiClient() @@ -191,6 +210,15 @@ public IConnectionsApiClient GetConnectionsApiClient() where TContent : IWithConnections => GetApiClientFromContentType(typeof(TContent))!; //TODO: Better resolution logic based on content/publish types + public IEmbeddedCredentialsContentApiClient GetEmbeddedCredentialsApiClient() + where TContent : IWithEmbeddedCredentials + => GetApiClientFromContentType(typeof(TContent))!; //TODO: Better resolution logic based on content/publish types + + /// + public IDeleteApiClient GetDeleteApiClient() + where TContent : IDelible + => GetApiClientFromContentType(typeof(TContent))!; //TODO: Better resolution logic based on content/publish types + private async Task> GetSiteAsync(Func setKey, CancellationToken cancel) { var request = RestRequestBuilderFactory.CreateUri("/"); //"sites" URL segment added by WithSiteId diff --git a/src/Tableau.Migration/Api/SubscriptionsApiClient.cs b/src/Tableau.Migration/Api/SubscriptionsApiClient.cs new file mode 100644 index 00000000..53bfd9af --- /dev/null +++ b/src/Tableau.Migration/Api/SubscriptionsApiClient.cs @@ -0,0 +1,197 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Search; +using Tableau.Migration.Net; +using Tableau.Migration.Net.Rest; +using Tableau.Migration.Paging; +using Tableau.Migration.Resources; + +using CloudModels = Tableau.Migration.Api.Models.Cloud; +using CloudRequests = Tableau.Migration.Api.Rest.Models.Requests.Cloud; +using CloudResponses = Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using ServerResponses = Tableau.Migration.Api.Rest.Models.Responses.Server; + +namespace Tableau.Migration.Api +{ + internal sealed class SubscriptionsApiClient : ContentApiClientBase, ISubscriptionsApiClient + { + private readonly IContentCacheFactory _contentCacheFactory; + private readonly IServerSessionProvider _sessionProvider; + private readonly ISchedulesApiClient _schedulesApiClient; + private readonly IHttpContentSerializer _serializer; + + public SubscriptionsApiClient( + IRestRequestBuilderFactory restRequestBuilderFactory, + IContentReferenceFinderFactory finderFactory, + IContentCacheFactory contentCacheFactory, + ISchedulesApiClientFactory schedulesApiClientFactory, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer sharedResourcesLocalizer, + IServerSessionProvider sessionProvider, + IHttpContentSerializer serializer) + : base(restRequestBuilderFactory, finderFactory, loggerFactory, sharedResourcesLocalizer, RestUrlPrefixes.Subscriptions) + { + _contentCacheFactory = contentCacheFactory; + _sessionProvider = sessionProvider; + _schedulesApiClient = schedulesApiClientFactory.Create(); + _serializer = serializer; + } + + /// + public IServerSubscriptionsApiClient ForServer() + => ExecuteForInstanceType(TableauInstanceType.Server, _sessionProvider.InstanceType, () => this); + + /// + public ICloudSubscriptionsApiClient ForCloud() + => ExecuteForInstanceType(TableauInstanceType.Cloud, _sessionProvider.InstanceType, () => this); + + private IHttpGetRequestBuilder BuildGetSubscriptionRequest(int pageNumber, int pageSize) + => RestRequestBuilderFactory.CreateUri(UrlPrefix).WithPage(pageNumber, pageSize).ForGetRequest(); + + private IHttpPostRequestBuilder BuildCreateSubscriptionRequest(TableauServerRequest request) + => RestRequestBuilderFactory + .CreateUri(UrlPrefix) + .ForPostRequest() + .WithXmlContent(request); + + private IHttpPutRequestBuilder BuildUpdateSubscriptionRequest(Guid subscriptionId, TRequest payload) + where TRequest : class + => RestRequestBuilderFactory + .CreateUri($"{UrlPrefix}/{subscriptionId.ToUrlSegment()}") + .ForPutRequest() + .WithXmlContent(payload); + + #region - IServerSubscriptionsApiClient Implementation - + async Task> IServerSubscriptionsApiClient.GetAllSubscriptionsAsync( + int pageNumber, + int pageSize, + CancellationToken cancel) + => await GetAllServerSubscriptionsAsync(pageNumber, pageSize, cancel).ConfigureAwait(false); + + async Task> GetAllServerSubscriptionsAsync( + int pageNumber, + int pageSize, + CancellationToken cancel) + { + return await BuildGetSubscriptionRequest(pageNumber, pageSize) + .SendAsync(cancel) + .ToPagedResultAsync( + async (r, c) => await ServerSubscription.CreateManyAsync(r, ContentFinderFactory, _contentCacheFactory, _schedulesApiClient.GetByIdAsync, Logger, SharedResourcesLocalizer, c).ConfigureAwait(false), + SharedResourcesLocalizer, + cancel) + .ConfigureAwait(false); + } + + #endregion + + #region - IPagedListApiClient Implementation - + + /// + public IPager GetPager(int pageSize) => new ApiListPager(this, pageSize); + + #endregion + + #region - IApiPageAccessor Implementation - + + /// + public async Task> GetPageAsync(int pageNumber, int pageSize, CancellationToken cancel) + => await GetAllServerSubscriptionsAsync(pageNumber, pageSize, cancel).ConfigureAwait(false); + + #endregion + + #region - ICloudSubscriptionsApiClient Implementation - + + async Task> ICloudSubscriptionsApiClient.GetAllSubscriptionsAsync( + int pageNumber, + int pageSize, + CancellationToken cancel) + { + return await BuildGetSubscriptionRequest(pageNumber, pageSize) + .SendAsync(cancel) + .ToPagedResultAsync( + async (r, c) => await CloudSubscription.CreateManyAsync(r, ContentFinderFactory, Logger, SharedResourcesLocalizer, c).ConfigureAwait(false), + SharedResourcesLocalizer, + cancel) + .ConfigureAwait(false); + } + + async Task> ICloudSubscriptionsApiClient.CreateSubscriptionAsync(ICloudSubscription subscription, CancellationToken cancel) + { + var options = new CloudModels.CreateSubscriptionOptions(subscription); + return await BuildCreateSubscriptionRequest(new CloudRequests.CreateSubscriptionRequest(options)) + .SendAsync(cancel) + .ToResultAsync(async (r, c) => + { + var sub = Guard.AgainstNull(r.Item, () => r.Item); + var user = await FindUserAsync(Guard.AgainstNull(sub.User, () => sub.User), true, c).ConfigureAwait(false); + + return (ICloudSubscription)new CloudSubscription(sub, user, Guard.AgainstNull(r.Item.Schedule, () => r.Item.Schedule)); + }, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + } + + async Task> ICloudSubscriptionsApiClient.UpdateSubscriptionAsync(Guid subscriptionId, CancellationToken cancel, + string? newSubject, bool? newAttachImage, bool? newAttachPdf, + string? newPageOrientation, string? newPageSizeOption, bool? newSuspended, string? newMessage, + ISubscriptionContent? newContent, Guid? newUserId, ICloudSchedule? newSchedule) + { + return await BuildUpdateSubscriptionRequest(subscriptionId, + new CloudRequests.UpdateSubscriptionRequest(newSubject, newAttachImage, newAttachPdf, + newPageOrientation, newPageSizeOption, newSuspended, newMessage, + newContent, newUserId, newSchedule)) + .SendAsync(cancel) + .ToResultAsync(async (r, c) => + { + var sub = Guard.AgainstNull(r.Item, () => r.Item); + var user = await FindUserAsync(Guard.AgainstNull(sub.User, () => sub.User), true, c).ConfigureAwait(false); + + return (ICloudSubscription)new CloudSubscription(sub, user, Guard.AgainstNull(r.Schedule, () => r.Schedule)); + }, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + } + + async Task IDeleteApiClient.DeleteAsync(Guid subscriptionId, CancellationToken cancel) + { + return await RestRequestBuilderFactory + .CreateUri($"/{UrlPrefix}/{subscriptionId}") + .ForDeleteRequest() + .SendAsync(cancel) + .ToResultAsync(_serializer, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + } + + #endregion + + #region - IPublishApiClient Implementation - + + async Task> IPublishApiClient.PublishAsync(ICloudSubscription item, CancellationToken cancel) + { + return await ForCloud().CreateSubscriptionAsync(item, cancel).ConfigureAwait(false); + } + + #endregion + } +} diff --git a/src/Tableau.Migration/Api/Tags/TagsApiClient.cs b/src/Tableau.Migration/Api/Tags/TagsApiClient.cs index de8954a6..4e9161f4 100644 --- a/src/Tableau.Migration/Api/Tags/TagsApiClient.cs +++ b/src/Tableau.Migration/Api/Tags/TagsApiClient.cs @@ -50,7 +50,7 @@ public TagsApiClient(IRestRequestBuilderFactory restRequestBuilderFactory, _serializer = serializer; } - #region - ITagsApiClient Implementation - + #region - ITagsApiClient Implementation - /// public async Task>> AddTagsAsync(Guid contentItemId, IEnumerable tags, CancellationToken cancel) diff --git a/src/Tableau.Migration/Api/TasksApiClient.cs b/src/Tableau.Migration/Api/TasksApiClient.cs index d7a4bd7a..5636c19b 100644 --- a/src/Tableau.Migration/Api/TasksApiClient.cs +++ b/src/Tableau.Migration/Api/TasksApiClient.cs @@ -46,7 +46,6 @@ internal class TasksApiClient : ContentApiClientBase, ITasksApiClient private readonly IServerSessionProvider _sessionProvider; private readonly IContentCacheFactory _contentCacheFactory; private readonly IHttpContentSerializer _serializer; - private readonly IExtractRefreshTaskConverter _extractRefreshTaskConverter; public TasksApiClient( IRestRequestBuilderFactory restRequestBuilderFactory, @@ -55,19 +54,12 @@ public TasksApiClient( IServerSessionProvider sessionProvider, ILoggerFactory loggerFactory, ISharedResourcesLocalizer sharedResourcesLocalizer, - IHttpContentSerializer serializer, - IExtractRefreshTaskConverter extractRefreshTaskConverter) - : base( - restRequestBuilderFactory, - finderFactory, - loggerFactory, - sharedResourcesLocalizer, - RestUrlPrefixes.Tasks) + IHttpContentSerializer serializer) + : base(restRequestBuilderFactory, finderFactory, loggerFactory, sharedResourcesLocalizer, RestUrlPrefixes.Tasks) { _sessionProvider = sessionProvider; _contentCacheFactory = contentCacheFactory; _serializer = serializer; - _extractRefreshTaskConverter = extractRefreshTaskConverter; } #region - ITasksApiClient - @@ -161,38 +153,20 @@ async Task>> IServerTasksApiCl cancel) .ConfigureAwait(false); - /// - public Task> PullAsync( - IServerExtractRefreshTask contentItem, - CancellationToken cancel) - { - - var cloudExtractRefreshTask = _extractRefreshTaskConverter.Convert(contentItem); - - return Task.FromResult(new ResultBuilder() - .Build(cloudExtractRefreshTask)); - } - #endregion #region - IPagedListApiClient Implementation - /// - public IPager GetPager( - int pageSize) - => new ApiListPager( - this, - pageSize); + public IPager GetPager(int pageSize) + => new ApiListPager(this, pageSize); #endregion #region - IApiPageAccessor Implementation - /// - public async Task> GetPageAsync( - int pageNumber, - int pageSize, - CancellationToken cancel) + public async Task> GetPageAsync(int pageNumber, int pageSize, CancellationToken cancel) { if (pageNumber != 1) { @@ -204,14 +178,11 @@ public async Task> GetPageAsync( true); } - var loadResult = await ForServer() - .GetAllExtractRefreshTasksAsync(cancel) - .ConfigureAwait(false); + var loadResult = await ForServer().GetAllExtractRefreshTasksAsync(cancel).ConfigureAwait(false); if (!loadResult.Success) { - return PagedResult.Failed( - loadResult.Errors); + return PagedResult.Failed(loadResult.Errors); } return PagedResult.Succeeded( diff --git a/src/Tableau.Migration/Api/UsersApiClient.cs b/src/Tableau.Migration/Api/UsersApiClient.cs index 37781d94..dec7d9d8 100644 --- a/src/Tableau.Migration/Api/UsersApiClient.cs +++ b/src/Tableau.Migration/Api/UsersApiClient.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -27,6 +27,7 @@ using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Content; @@ -42,8 +43,10 @@ namespace Tableau.Migration.Api internal sealed class UsersApiClient : ContentApiClientBase, IUsersApiClient { internal const string USER_NAME_CONFLICT_ERROR_CODE = "409017"; + private readonly IJobsApiClient _jobs; private readonly IHttpContentSerializer _serializer; + private readonly IServerSessionProvider _sessionProvider; public UsersApiClient( IJobsApiClient jobs, @@ -51,13 +54,17 @@ public UsersApiClient( IContentReferenceFinderFactory finderFactory, ILoggerFactory loggerFactory, IHttpContentSerializer serializer, - ISharedResourcesLocalizer sharedResourcesLocalizer) + ISharedResourcesLocalizer sharedResourcesLocalizer, + IServerSessionProvider sessionProvider) : base(restRequestBuilderFactory, finderFactory, loggerFactory, sharedResourcesLocalizer) { _jobs = jobs; _serializer = serializer; + _sessionProvider = sessionProvider; } + #region - IUsersApiClient Implementation - + /// public async Task> GetUserGroupsAsync(Guid userId, int pageNumber, int pageSize, CancellationToken cancel) { @@ -110,15 +117,15 @@ public async Task> ImportUsersAsync(IEnumerable users ImportUsersFromCsvRequest xmlRequest; var authTypes = users - .Select(u => u.AuthenticationType) + .Select(u => u.Authentication) .Distinct() - .Where(a => !string.IsNullOrWhiteSpace(a)) + .Where(a => a != UserAuthenticationType.Default) .ToImmutableArray(); if (authTypes.Length < 2) { var authType = authTypes.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(authType)) + if (authType == UserAuthenticationType.Default) { xmlRequest = new ImportUsersFromCsvRequest(); } @@ -130,13 +137,9 @@ public async Task> ImportUsersAsync(IEnumerable users else { var requestUsers = users - .Where(u => !string.IsNullOrWhiteSpace(u.AuthenticationType)) - .Select(u => - new ImportUsersFromCsvRequest.UserType - { - Name = u.Name, - AuthSetting = u.AuthenticationType - }); + .Where(u => u.Authentication != UserAuthenticationType.Default) + .Select(u => new ImportUsersFromCsvRequest.UserType(u.Name, u.Authentication)); + xmlRequest = new ImportUsersFromCsvRequest(requestUsers); } @@ -169,12 +172,12 @@ public async Task> ImportUsersAsync(IEnumerable users } /// - public async Task> AddUserAsync(string userName, string siteRole, string? authenticationType, CancellationToken cancel) + public async Task> AddUserAsync(string userName, string siteRole, UserAuthenticationType authentication, CancellationToken cancel) { var userResult = await RestRequestBuilderFactory .CreateUri("/users") .ForPostRequest() - .WithXmlContent(new AddUserToSiteRequest(userName, siteRole, authenticationType)) + .WithXmlContent(new AddUserToSiteRequest(userName, siteRole, authentication)) .SendAsync(cancel) .ToResultAsync(r => new AddUserResult(r), SharedResourcesLocalizer) .ConfigureAwait(false); @@ -209,7 +212,8 @@ public async Task> AddUserAsync(string userName, string Id = existingUser.Id, Name = existingUser.Name, SiteRole = existingUser.SiteRole, - AuthSetting = existingUser.AuthSetting + AuthSetting = existingUser.AuthSetting, + IdpConfigurationId = existingUser.IdpConfigurationId } }; @@ -225,11 +229,11 @@ public async Task> AddUserAsync(string userName, string } else if (existingUserResult.Value.Count == 0) { - conflictResultBuilder.Add(new Exception($"Could not find a user \"{authenticationType ?? string.Empty} \\ {userName}\".")); + conflictResultBuilder.Add(new Exception($"Could not find a user with username \"{userName}\".")); } else if (existingUserResult.Value.Count > 1) { - conflictResultBuilder.Add(new Exception($"Found multiple users \"{authenticationType ?? string.Empty} \\ {userName}\".")); + conflictResultBuilder.Add(new Exception($"Found multiple users with username \"{userName}\".")); } return conflictResultBuilder.Build().CastFailure(); @@ -242,12 +246,12 @@ public async Task> UpdateUserAsync(Guid id, string? newfullName = null, string? newEmail = null, string? newPassword = null, - string? newAuthSetting = null) + UserAuthenticationType? newAuthentication = null) { var userResult = await RestRequestBuilderFactory .CreateUri($"/users/{id}") .ForPutRequest() - .WithXmlContent(new UpdateUserRequest(newSiteRole, newfullName, newEmail, newPassword, newAuthSetting)) + .WithXmlContent(new UpdateUserRequest(newSiteRole, newfullName, newEmail, newPassword, newAuthentication)) .SendAsync(cancel) .ToResultAsync(r => new UpdateUserResult(r), SharedResourcesLocalizer) .ConfigureAwait(false); @@ -268,6 +272,37 @@ public async Task DeleteUserAsync(Guid userId, CancellationToken cancel return result; } + public async Task> RetrieveUserSavedCredentialsAsync( + Guid userId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + { + var retrieveUserSavedCredsResult = await RestRequestBuilderFactory + .CreateUri($"/users/{userId}/retrieveSavedCreds") + .ForPostRequest() + .WithXmlContent(new RetrieveUserSavedCredentialsRequest(destinationSiteInfo)) + .SendAsync(cancel) + .ToResultAsync(r => new EmbeddedCredentialKeychainResult(r), SharedResourcesLocalizer) + .ConfigureAwait(false); + + return retrieveUserSavedCredsResult; + } + + public async Task UploadUserSavedCredentialsAsync(Guid userId, IEnumerable encryptedKeychains, CancellationToken cancel) + { + var uploadSavedCredsResult = await RestRequestBuilderFactory + .CreateUri($"/users/{userId}/uploadSavedCreds") + .ForPutRequest() + .WithXmlContent(new UploadUserSavedCredentialsRequest(encryptedKeychains)) + .SendAsync(cancel) + .ToResultAsync(_serializer, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + + return uploadSavedCredsResult; + } + + #endregion + #region - IPagedListApiClient Implementation - /// @@ -281,7 +316,7 @@ public async Task DeleteUserAsync(Guid userId, CancellationToken cancel public async Task PublishBatchAsync(IEnumerable items, CancellationToken cancel) { // Create user CSV - using var csvStream = UsersApiClient.GenerateUserCsvStream(items); + using var csvStream = GenerateUserCsvStream(items); cancel.ThrowIfCancellationRequested(); @@ -344,27 +379,46 @@ public async Task> GetByIdAsync(Guid contentId, CancellationToken public async Task> PublishAsync(IUser item, CancellationToken cancel) { - var result = await AddUserAsync( - item.Name, - item.SiteRole, - item.AuthenticationType, - cancel) - .ConfigureAwait(false); + var addResult = await AddUserAsync(item.Name, item.SiteRole, item.Authentication, cancel).ConfigureAwait(false); + if (!addResult.Success) + { + return addResult.CastFailure(); + } + + /* + * We need to call update to set the full name/email. + * The add user API will not overwrite existing users so we update those fields as well. + */ + IResult updateResult; + if(_sessionProvider.InstanceType is TableauInstanceType.Cloud) + { + // Tableau Cloud only allows updating site role and authentication. + updateResult = await UpdateUserAsync(addResult.Value.Id, item.SiteRole, cancel, + null, null, null, item.Authentication).ConfigureAwait(false); + } + else + { + // Tableau Server allows updating full name and email. + updateResult = await UpdateUserAsync(addResult.Value.Id, item.SiteRole, cancel, + item.FullName, item.Email, null, item.Authentication).ConfigureAwait(false); + } + + if(!updateResult.Success) + { + return updateResult.CastFailure(); + } - if (!result.Success) + /* + * The Add/update user APIs will succeed if there are insufficient licenses for the intended site role. + * We want to call the attention of the caller to this difference between intended and actual site role, + * so we consider the publish failed, which will be logged in the manifest. + */ + if(!SiteRoles.IsAMatch(item.SiteRole, SiteRoles.Unlicensed) && SiteRoles.IsAMatch(updateResult.Value.SiteRole, SiteRoles.Unlicensed)) { - return Result.Failed(result.Errors); + return Result.Failed(new Exception($"User {item.Location} could not be published with site role {item.SiteRole} due to insufficient licenses. User was published as unlicensed.")); } - return Result.Succeeded( - new User( - result.Value.Id, - null, - null, - result.Value.Name, - null, - result.Value.SiteRole, - result.Value.AuthSetting)); + return Result.Succeeded(new User(addResult.Value.Id, updateResult.Value)); } #endregion diff --git a/src/Tableau.Migration/Api/ViewsApiClient.cs b/src/Tableau.Migration/Api/ViewsApiClient.cs index 5d329fbf..5d5c2fe6 100644 --- a/src/Tableau.Migration/Api/ViewsApiClient.cs +++ b/src/Tableau.Migration/Api/ViewsApiClient.cs @@ -15,9 +15,14 @@ // limitations under the License. // +using System; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Permissions; +using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Api.Tags; +using Tableau.Migration.Content; using Tableau.Migration.Content.Search; using Tableau.Migration.Net.Rest; using Tableau.Migration.Resources; @@ -39,6 +44,32 @@ public ViewsApiClient( Tags = tagsClientFactory.Create(this); } + #region - IReadClientImplementation - + + public async Task> GetByIdAsync(Guid contentId, CancellationToken cancel) + { + var getViewResult = await RestRequestBuilderFactory + .CreateUri($"/{UrlPrefix}/{contentId.ToUrlSegment()}") + .ForGetRequest() + .SendAsync(cancel) + .ToResultAsync(async (r, c) => + { + Guard.AgainstNull(r.Item, () => r.Item); + Guard.AgainstNull(r.Item.Workbook, () => r.Item.Workbook); + Guard.AgainstNull(r.Item.Project, () => r.Item.Project); + + var workbook = await FindWorkbookByIdAsync(r.Item.Workbook.Id, true, c).ConfigureAwait(false); + var project = await FindProjectByIdAsync(r.Item.Project.Id, true, c).ConfigureAwait(false); + + return new View(r.Item, project, workbook); + }, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + + return getViewResult; + } + + #endregion - IReadClientImplementation - + #region - IPermissionsContentApiClientImplementation - /// diff --git a/src/Tableau.Migration/Api/WorkbooksApiClient.cs b/src/Tableau.Migration/Api/WorkbooksApiClient.cs index f45929d4..5a6fd690 100644 --- a/src/Tableau.Migration/Api/WorkbooksApiClient.cs +++ b/src/Tableau.Migration/Api/WorkbooksApiClient.cs @@ -20,6 +20,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Publishing; @@ -56,6 +57,7 @@ public WorkbooksApiClient( IWorkbookPublisher workbookPublisher, ITagsApiClientFactory tagsClientFactory, IViewsApiClientFactory viewsClientFactory, + IEmbeddedCredentialsApiClientFactory embeddedCredentialsApiClientFactory, IConnectionManager connectionManager, IConfigReader configReader) : base(restRequestBuilderFactory, finderFactory, loggerFactory, sharedResourcesLocalizer) @@ -65,6 +67,7 @@ public WorkbooksApiClient( Permissions = permissionsClientFactory.Create(this); Tags = tagsClientFactory.Create(this); Views = viewsClientFactory.Create(); + EmbeddedCredentials = embeddedCredentialsApiClientFactory.Create(this); _connectionManager = connectionManager; _configReader = configReader; } @@ -90,6 +93,12 @@ public WorkbooksApiClient( #endregion + #region - IEmbeddedCredentialsContentApiClient Implementation + + /// + public IEmbeddedCredentialsApiClient EmbeddedCredentials { get; } + + #endregion /// public async Task> GetAllWorkbooksAsync( int pageNumber, diff --git a/src/Tableau.Migration/AsyncDisposableResult.cs b/src/Tableau.Migration/AsyncDisposableResult.cs index cfb02859..eeb5cf11 100644 --- a/src/Tableau.Migration/AsyncDisposableResult.cs +++ b/src/Tableau.Migration/AsyncDisposableResult.cs @@ -35,7 +35,7 @@ internal sealed record AsyncDisposableResult : Result, IAsyncDisposableRes /// True if the operation is successful, false otherwise. /// The result of the operation. /// The errors encountered during the operation, if any. - private AsyncDisposableResult(bool success, T? value, params Exception[] errors) + private AsyncDisposableResult(bool success, T? value, params IEnumerable errors) : base(success, value, errors) { } diff --git a/src/Tableau.Migration/ComparerBase.cs b/src/Tableau.Migration/ComparerBase.cs index f9243a18..f4749d75 100644 --- a/src/Tableau.Migration/ComparerBase.cs +++ b/src/Tableau.Migration/ComparerBase.cs @@ -72,7 +72,7 @@ protected static int CompareValues(T x, T y, Func getValue, I protected static int CompareValues(T x, T y, Func getValue, StringComparison comparison) => CompareValues(x, y, getValue, StringComparer.FromComparison(comparison)); - protected static int CompareValues(params Func[] comparisons) + protected static int CompareValues(params IEnumerable> comparisons) { Guard.AgainstNullOrEmpty(comparisons, nameof(comparisons)); diff --git a/src/Tableau.Migration/Config/ConfigReader.cs b/src/Tableau.Migration/Config/ConfigReader.cs index 0cf519a2..06e374a6 100644 --- a/src/Tableau.Migration/Config/ConfigReader.cs +++ b/src/Tableau.Migration/Config/ConfigReader.cs @@ -53,23 +53,15 @@ public MigrationSdkOptions Get() public ContentTypesOptions Get() where TContent : IContentReference { - var contentType = ServerToCloudMigrationPipeline.ContentTypes - .FirstOrDefault(c => c.ContentType.Name == typeof(TContent).Name); + var configKey = MigrationPipelineContentType.GetConfigKeyForType(typeof(TContent)); + var contentTypeOptions = Get() + .ContentTypes + .FirstOrDefault(o => string.Equals(o.Type, configKey, StringComparison.OrdinalIgnoreCase)); - if (contentType != null) + return contentTypeOptions ?? new ContentTypesOptions() { - var configKey = contentType.GetConfigKey(); - var contentTypeOptions = Get() - .ContentTypes - .FirstOrDefault(o => string.Equals(o.Type, configKey, StringComparison.OrdinalIgnoreCase)); - - return contentTypeOptions ?? new ContentTypesOptions() - { - Type = configKey - }; - } - - throw new NotSupportedException($"Content type specific options are not supported for {typeof(TContent)} since it is not supported for migration."); + Type = configKey + }; } } } diff --git a/src/Tableau.Migration/Config/ContentTypesOptions.cs b/src/Tableau.Migration/Config/ContentTypesOptions.cs index fe8dea43..1e10dda4 100644 --- a/src/Tableau.Migration/Config/ContentTypesOptions.cs +++ b/src/Tableau.Migration/Config/ContentTypesOptions.cs @@ -73,7 +73,7 @@ public bool BatchPublishingEnabled set => _batchPublishingEnabled = value; } private bool? _batchPublishingEnabled; - + /// /// Gets or sets the include extract flag for supported types. Default: enabled.
/// Important: This option is only available to and . @@ -90,9 +90,8 @@ public bool IncludeExtractEnabled /// Checks if the content type in is valid. ///
/// - public bool IsContentTypeValid() - => ServerToCloudMigrationPipeline - .ContentTypes + public bool IsContentTypeValid() + => MigrationPipelineContentType.GetAllMigrationPipelineContentTypes() .Any(c => string.Equals(c.GetConfigKey(), Type, StringComparison.OrdinalIgnoreCase)); } } \ No newline at end of file diff --git a/src/Tableau.Migration/Config/NetworkOptions.cs b/src/Tableau.Migration/Config/NetworkOptions.cs index d975ff51..9a928c87 100644 --- a/src/Tableau.Migration/Config/NetworkOptions.cs +++ b/src/Tableau.Migration/Config/NetworkOptions.cs @@ -47,6 +47,11 @@ public static class Defaults ///
public const bool LOG_BINARY_CONTENT_ENABLED = false; + /// + /// The default Network Workbook Content Logging Flag - Disabled as Default. + /// + public const bool LOG_WORKBOOK_CONTENT_ENABLED = false; + /// /// The default Network Exceptions Logging Flag - Disabled as Default. /// @@ -98,6 +103,16 @@ public bool BinaryContentLoggingEnabled } private bool? _binaryContentLoggingEnabled; + /// + /// Indicates whether the SDK logs workbook content when downloading a .twb workbook. The default value is disabled. + /// + public bool WorkbookContentLoggingEnabled + { + get => _workbookContentLoggingEnabled ?? Defaults.LOG_WORKBOOK_CONTENT_ENABLED; + set => _workbookContentLoggingEnabled = value; + } + private bool? _workbookContentLoggingEnabled; + /// /// Indicates whether the SDK logs network exceptions. The default value is disabled. /// diff --git a/src/Tableau.Migration/Config/ResilienceOptions.cs b/src/Tableau.Migration/Config/ResilienceOptions.cs index 2e36bbbe..bc2b2bb9 100644 --- a/src/Tableau.Migration/Config/ResilienceOptions.cs +++ b/src/Tableau.Migration/Config/ResilienceOptions.cs @@ -259,7 +259,7 @@ public TimeSpan MaxPublishRequestsInterval /// /// Gets or sets whether to wait and retry on server throttle responses. /// The default value is enabled. - /// There is not limit to the number of retries from server throttle responses. + /// There is no limit to the number of retries from server throttle responses. /// is used to determine the length of time between retries. /// If no Retry-After header is supplied by the server, /// with the last retry interval used if the retry count exceeds the retry interval count. diff --git a/src/Tableau.Migration/Constants.cs b/src/Tableau.Migration/Constants.cs index 022abc2f..3b87412e 100644 --- a/src/Tableau.Migration/Constants.cs +++ b/src/Tableau.Migration/Constants.cs @@ -15,7 +15,11 @@ // limitations under the License. // +using System.Collections.Immutable; +using System.Linq; using System.Text; +using Tableau.Migration.Content; +using Tableau.Migration.Resources; namespace Tableau.Migration { @@ -56,6 +60,20 @@ public static class Constants /// public const string SystemUsername = "_system"; + /// + /// The names of system-managed projects. + /// + public static readonly ImmutableHashSet SystemProjectNames = + DefaultExternalAssetsProjectTranslations.GetAll() + .Append(Constants.DefaultProjectName) + // The admin insight project is usually named "Admin Insights" + // However, if that name is already taken when the real admin insights project is created + // one of the alternate names is used + .Append(Constants.AdminInsightsProjectName) + .Append(Constants.AdminInsightsTableauProjectName) + .Append(Constants.AdminInsightsTableauOnlineProjectName) + .ToImmutableHashSet(Project.NameComparer); + /// /// The location for the local system user. /// diff --git a/src/Tableau.Migration/Content/AuthenticationConfiguration.cs b/src/Tableau.Migration/Content/AuthenticationConfiguration.cs new file mode 100644 index 00000000..caaa1f2d --- /dev/null +++ b/src/Tableau.Migration/Content/AuthenticationConfiguration.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Rest.Models.Responses; + +namespace Tableau.Migration.Content +{ + internal sealed class AuthenticationConfiguration : ContentBase, IAuthenticationConfiguration + { + public AuthenticationConfiguration(SiteAuthConfigurationsResponse.SiteAuthConfigurationType response) + { + AuthSetting = Guard.AgainstNullOrEmpty(response.AuthSetting, () => response.AuthSetting); + KnownProviderAlias = response.KnownProviderAlias; + IdpConfigurationName = Guard.AgainstNullOrEmpty(response.IdpConfigurationName, () => response.IdpConfigurationName); + Id = response.IdpConfigurationId; + Enabled = response.Enabled; + Location = new(IdpConfigurationName); + } + + #region - IAuthenticationConfiguration Implementation - + + /// + public string AuthSetting { get; } + + /// + public string? KnownProviderAlias { get; } + + /// + public string IdpConfigurationName { get; } + + /// + public bool Enabled { get; } + + #endregion + } +} diff --git a/src/Tableau.Migration/Content/CloudSubscription.cs b/src/Tableau.Migration/Content/CloudSubscription.cs new file mode 100644 index 00000000..51db4797 --- /dev/null +++ b/src/Tableau.Migration/Content/CloudSubscription.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Search; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Content +{ + internal sealed class CloudSubscription : SubscriptionBase, ICloudSubscription + { + public CloudSubscription(ISubscriptionType subscription, IContentReference user, ICloudScheduleType schedule) + : base(subscription, user, new CloudSchedule(schedule)) + { } + + public CloudSubscription(Guid id, string? subject, bool attachImage, bool attachPdf, + string? pageOrientation, string? pageSizeOption, bool suspended, string? message, + ISubscriptionContent content, IContentReference user, ICloudSchedule schedule) + : base(id, subject, attachImage, attachPdf, pageOrientation, pageSizeOption, suspended, message, content, + user, schedule) + { } + + public static async Task> CreateManyAsync( + GetSubscriptionsResponse response, + IContentReferenceFinderFactory finderFactory, + ILogger logger, ISharedResourcesLocalizer localizer, + CancellationToken cancel) + => await CreateManyAsync( + response, + response => response.Items.ExceptNulls(), + (r, u, cnl) => Task.FromResult(new CloudSubscription(r, u, Guard.AgainstNull(r.Schedule, () => r.Schedule))), + finderFactory, logger, localizer, + cancel).ConfigureAwait(false); + } +} diff --git a/src/Tableau.Migration/Content/Connection.cs b/src/Tableau.Migration/Content/Connection.cs index e9d34aa4..c4ca8726 100644 --- a/src/Tableau.Migration/Content/Connection.cs +++ b/src/Tableau.Migration/Content/Connection.cs @@ -16,6 +16,7 @@ // using System; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models; namespace Tableau.Migration.Content @@ -31,12 +32,10 @@ public Connection(IConnectionType response) ServerAddress = response.ServerAddress; ServerPort = response.ServerPort; ConnectionUsername = response.ConnectionUsername; - - if (response.QueryTaggingEnabled is not null && - bool.TryParse(response.QueryTaggingEnabled, out var queryTaggingEnabled)) - { - QueryTaggingEnabled = queryTaggingEnabled; - } + QueryTaggingEnabled = response.QueryTaggingEnabled.ToBoolOrNull(); + AuthenticationType = response.AuthenticationType; + EmbedPassword = response.EmbedPassword.ToBoolOrNull(); + UseOAuthManagedKeychain = response.UseOAuthManagedKeychain.ToBoolOrNull(); } /// @@ -56,5 +55,14 @@ public Connection(IConnectionType response) /// public bool? QueryTaggingEnabled { get; set; } + + /// + public string? AuthenticationType { get; set; } + + /// + public bool? UseOAuthManagedKeychain { get; set; } + + /// + public bool? EmbedPassword { get; set; } } } diff --git a/src/Tableau.Migration/Content/ContentBase.cs b/src/Tableau.Migration/Content/ContentBase.cs index b8156689..fb9b7070 100644 --- a/src/Tableau.Migration/Content/ContentBase.cs +++ b/src/Tableau.Migration/Content/ContentBase.cs @@ -74,7 +74,7 @@ public ContentBase(IContentReference reference) /// public bool Equals(IContentReference? other) { - if (other == null && GetType() != other!.GetType()) + if (other is null || GetType() != other.GetType()) return false; return Id.Equals(other.Id) && (ContentUrlComparer.Compare(ContentUrl, other.ContentUrl) == 0) && Location.Equals(other.Location) && Name.Equals(other.Name); @@ -83,14 +83,10 @@ public bool Equals(IContentReference? other) /// public override bool Equals(object? obj) { - if (obj == null) + if (obj is null || !(obj is ContentBase contentBaseObj)) return false; - ContentBase? contentBaseObj = obj as ContentBase; - if (contentBaseObj == null) - return false; - else - return Equals(contentBaseObj); + return Equals(contentBaseObj); } /// @@ -99,7 +95,6 @@ public override int GetHashCode() return HashCode.Combine(Id, ContentUrl, Location, Name); } - /// public static bool operator ==(ContentBase? a, ContentBase? b) { diff --git a/src/Tableau.Migration/Content/Files/ContentFileHandle.cs b/src/Tableau.Migration/Content/Files/ContentFileHandle.cs index bc837c84..1b38b564 100644 --- a/src/Tableau.Migration/Content/Files/ContentFileHandle.cs +++ b/src/Tableau.Migration/Content/Files/ContentFileHandle.cs @@ -27,7 +27,8 @@ namespace Tableau.Migration.Content.Files /// The file store the handle is for. /// The path to the file. /// The original filename of the file, used for the upload filename when publishing the content item. - public record ContentFileHandle(IContentFileStore Store, string Path, string OriginalFileName) + /// + public record ContentFileHandle(IContentFileStore Store, string Path, string OriginalFileName, bool? IsZipFile) : IContentFileHandle { private bool _disposed = false; diff --git a/src/Tableau.Migration/Content/Files/DirectoryContentFileStore.cs b/src/Tableau.Migration/Content/Files/DirectoryContentFileStore.cs index c6489ef7..8a415a5c 100644 --- a/src/Tableau.Migration/Content/Files/DirectoryContentFileStore.cs +++ b/src/Tableau.Migration/Content/Files/DirectoryContentFileStore.cs @@ -107,7 +107,7 @@ private Task DeleteAsync(string path, CancellationToken cancel) #region - IContentFileStore Implementation - /// - public IContentFileHandle Create(string relativeStorePath, string originalFileName) + public IContentFileHandle Create(string relativeStorePath, string originalFileName, bool? zipFormatOverride = null) { Guard.AgainstNullOrWhiteSpace(relativeStorePath, nameof(relativeStorePath)); @@ -115,12 +115,12 @@ public IContentFileHandle Create(string relativeStorePath, string originalFileNa TrackedFilePaths.Add(path); - return new ContentFileHandle(this, path, originalFileName); + return new ContentFileHandle(this, path, originalFileName, zipFormatOverride); } /// - public IContentFileHandle Create(TContent contentItem, string originalFileName) - => Create(PathResolver.ResolveRelativePath(contentItem, originalFileName), originalFileName); + public IContentFileHandle Create(TContent contentItem, string originalFileName, bool? zipFormatOverride = null) + => Create(PathResolver.ResolveRelativePath(contentItem, originalFileName), originalFileName, zipFormatOverride); /// public Task OpenReadAsync(IContentFileHandle handle, CancellationToken cancel) @@ -144,10 +144,10 @@ public Task OpenWriteAsync(IContentFileHandle handle, Cancel } /// - public async Task GetTableauFileEditorAsync(IContentFileHandle handle, CancellationToken cancel, bool? zipFormatOverride = null) + public async Task GetTableauFileEditorAsync(IContentFileHandle handle, CancellationToken cancel) => await _openTableauFileEditors.GetOrAddAsync( handle.Path, - async path => (ITableauFileEditor)await TableauFileEditor.OpenAsync(handle, MemoryStreamManager, cancel, zipFormatOverride).ConfigureAwait(false)) + async path => (ITableauFileEditor)await TableauFileEditor.OpenAsync(handle, MemoryStreamManager, cancel).ConfigureAwait(false)) .ConfigureAwait(false); /// diff --git a/src/Tableau.Migration/Content/Files/EncryptedFileHandle.cs b/src/Tableau.Migration/Content/Files/EncryptedFileHandle.cs index d6d6c522..6bf7a505 100644 --- a/src/Tableau.Migration/Content/Files/EncryptedFileHandle.cs +++ b/src/Tableau.Migration/Content/Files/EncryptedFileHandle.cs @@ -26,9 +26,10 @@ namespace Tableau.Migration.Content.Files /// /// /// + /// /// The file handle to the inner file store. - public record EncryptedFileHandle(IContentFileStore Store, string Path, string OriginalFileName, IContentFileHandle Inner) - : ContentFileHandle(Store, Path, OriginalFileName) + public record EncryptedFileHandle(IContentFileStore Store, string Path, string OriginalFileName, bool? IsZipFile, IContentFileHandle Inner) + : ContentFileHandle(Store, Path, OriginalFileName, IsZipFile) { /// /// Creates a new object. @@ -36,7 +37,7 @@ public record EncryptedFileHandle(IContentFileStore Store, string Path, string O /// The file store the handle is for. /// The file handle to the inner file store. public EncryptedFileHandle(IContentFileStore store, IContentFileHandle inner) - : this(store, inner.Path, inner.OriginalFileName, inner) + : this(store, inner.Path, inner.OriginalFileName, inner.IsZipFile, inner) { } /// diff --git a/src/Tableau.Migration/Content/Files/EncryptedFileStore.cs b/src/Tableau.Migration/Content/Files/EncryptedFileStore.cs index 0683b6ee..2a3f1664 100644 --- a/src/Tableau.Migration/Content/Files/EncryptedFileStore.cs +++ b/src/Tableau.Migration/Content/Files/EncryptedFileStore.cs @@ -87,12 +87,12 @@ public EncryptedFileStore(ISymmetricEncryptionFactory encryptionFactory, #region - IContentFileStore Implementation - /// - public IContentFileHandle Create(string relativeStorePath, string originalFileName) - => new EncryptedFileHandle(this, _innerStore.Create(relativeStorePath, originalFileName)); + public IContentFileHandle Create(string relativeStorePath, string originalFileName, bool? zipFormatOverride = null) + => new EncryptedFileHandle(this, _innerStore.Create(relativeStorePath, originalFileName, zipFormatOverride)); /// - public IContentFileHandle Create(TContent contentItem, string originalFileName) - => new EncryptedFileHandle(this, _innerStore.Create(contentItem, originalFileName)); + public IContentFileHandle Create(TContent contentItem, string originalFileName, bool? zipFormatOverride = null) + => new EncryptedFileHandle(this, _innerStore.Create(contentItem, originalFileName, zipFormatOverride)); /// public async Task DeleteAsync(IContentFileHandle handle, CancellationToken cancel) @@ -101,8 +101,8 @@ public async Task DeleteAsync(IContentFileHandle handle, CancellationToken cance /// public async Task GetTableauFileEditorAsync(IContentFileHandle handle, - CancellationToken cancel, bool? zipFormatOverride = null) - => await _innerStore.GetTableauFileEditorAsync(handle, cancel, zipFormatOverride).ConfigureAwait(false); + CancellationToken cancel) + => await _innerStore.GetTableauFileEditorAsync(handle, cancel).ConfigureAwait(false); /// public async Task CloseTableauFileEditorAsync(IContentFileHandle handle, CancellationToken cancel) diff --git a/src/Tableau.Migration/Content/Files/IContentFileHandle.cs b/src/Tableau.Migration/Content/Files/IContentFileHandle.cs index 8ba95a04..b24ae42c 100644 --- a/src/Tableau.Migration/Content/Files/IContentFileHandle.cs +++ b/src/Tableau.Migration/Content/Files/IContentFileHandle.cs @@ -41,6 +41,12 @@ public interface IContentFileHandle : IAsyncDisposable /// IContentFileStore Store { get; } + /// + /// Gets whether or not the file is a zip archive, + /// or null if the zip file status is unknown. + /// + bool? IsZipFile { get; } + /// /// Opens a stream to read from a file. /// @@ -62,5 +68,8 @@ public interface IContentFileHandle : IAsyncDisposable ///
/// The XML stream to edit. Task GetXmlStreamAsync(CancellationToken cancel); + + internal bool? HasZipFilePath => + new FilePath(OriginalFileName).IsZipFile ?? new FilePath(Path).IsZipFile; } } \ No newline at end of file diff --git a/src/Tableau.Migration/Content/Files/IContentFileHandleExtensions.cs b/src/Tableau.Migration/Content/Files/IContentFileHandleExtensions.cs index 1a147a48..3d5eec44 100644 --- a/src/Tableau.Migration/Content/Files/IContentFileHandleExtensions.cs +++ b/src/Tableau.Migration/Content/Files/IContentFileHandleExtensions.cs @@ -15,7 +15,6 @@ // limitations under the License. // -using System; using System.Threading; using System.Threading.Tasks; @@ -25,29 +24,5 @@ internal static class IContentFileHandleExtensions { internal static async Task CloseTableauFileEditorAsync(this IContentFileHandle contentFileHandle, CancellationToken cancel) => await contentFileHandle.Store.CloseTableauFileEditorAsync(contentFileHandle, cancel).ConfigureAwait(false); - - internal static async Task IsZipAsync(this IContentFileHandle handle, CancellationToken cancel) - { - var isZipFile = IsZipFile(h => h.GetOriginalFilePath()) ?? IsZipFile(h => h.GetStoreFilePath()); - - if (isZipFile is not null) - return isZipFile.Value; - - var fileStream = await handle.OpenReadAsync(cancel).ConfigureAwait(false); - - await using (fileStream) - { - return fileStream.Content.IsZip(); - } - - bool? IsZipFile(Func getFilePath) - => getFilePath(handle).IsZipFile; - } - - internal static FilePath GetOriginalFilePath(this IContentFileHandle handle) - => new(handle.OriginalFileName); - - internal static FilePath GetStoreFilePath(this IContentFileHandle handle) - => new(handle.Path); } } \ No newline at end of file diff --git a/src/Tableau.Migration/Content/Files/IContentFileStore.cs b/src/Tableau.Migration/Content/Files/IContentFileStore.cs index d78eb5ed..ad3ccd9a 100644 --- a/src/Tableau.Migration/Content/Files/IContentFileStore.cs +++ b/src/Tableau.Migration/Content/Files/IContentFileStore.cs @@ -37,8 +37,9 @@ public interface IContentFileStore : IAsyncDisposable ///
/// The relative path and file name to create a file for within the file store. /// The original file name external to the file store to preserve when publishing content items. + /// Whether or not the file is in zip format, or null if the zip status is unknown. /// A handle to the newly created file. - IContentFileHandle Create(string relativeStorePath, string originalFileName); + IContentFileHandle Create(string relativeStorePath, string originalFileName, bool? zipFormatOverride = null); /// /// Creates a file managed by the file store. @@ -46,8 +47,9 @@ public interface IContentFileStore : IAsyncDisposable /// The content type. /// The content item to resolve a relative file store path from. /// The original file name external to the file store to preserve when publishing content items. + /// Whether or not the file is in zip format, or null if the zip status is unknown. /// A handle to the newly created file. - IContentFileHandle Create(TContent contentItem, string originalFileName); + IContentFileHandle Create(TContent contentItem, string originalFileName, bool? zipFormatOverride = null); /// /// Creates a file managed by the file store. @@ -56,11 +58,12 @@ public interface IContentFileStore : IAsyncDisposable /// The original file name external to the file store to preserve when publishing content items. /// The initial content to save the file with. /// The cancellation token to obey. + /// Whether or not the file is in zip format, or null if the zip status is unknown. /// A handle to the newly created file. public async Task CreateAsync(string relativeStorePath, string originalFileName, - Stream initialContent, CancellationToken cancel) + Stream initialContent, CancellationToken cancel, bool? zipFormatOverride = null) { - var handle = Create(relativeStorePath, originalFileName); + var handle = Create(relativeStorePath, originalFileName, zipFormatOverride); var writeStream = await OpenWriteAsync(handle, cancel).ConfigureAwait(false); await using (writeStream) @@ -79,11 +82,12 @@ public async Task CreateAsync(string relativeStorePath, stri /// The original file name external to the file store to preserve when publishing content items. /// The initial content to save the file with. /// The cancellation token to obey. + /// Whether or not the file is in zip format, or null if the zip status is unknown. /// A handle to the newly created file. public async Task CreateAsync(TContent contentItem, string originalFileName, - Stream initialContent, CancellationToken cancel) + Stream initialContent, CancellationToken cancel, bool? zipFormatOverride = null) { - var handle = Create(contentItem, originalFileName); + var handle = Create(contentItem, originalFileName, zipFormatOverride); var writeStream = await OpenWriteAsync(handle, cancel).ConfigureAwait(false); await using (writeStream) @@ -116,16 +120,11 @@ public async Task CreateAsync(TContent contentItem /// /// The handle to the file to get the editor for. /// The cancellation token to obey. - /// - /// True to consider the file a zip archive, - /// false to consider the file an XML file, - /// or null to detect whether the file is a zip archive. - /// /// /// The editor to use. /// Changes made will be flushed automatically before the content item is published. /// - Task GetTableauFileEditorAsync(IContentFileHandle handle, CancellationToken cancel, bool? zipFormatOverride = null); + Task GetTableauFileEditorAsync(IContentFileHandle handle, CancellationToken cancel); /// /// Closes the current Tableau file format editor for the content file, diff --git a/src/Tableau.Migration/Content/Files/TableauFileEditor.cs b/src/Tableau.Migration/Content/Files/TableauFileEditor.cs index 47e505df..aa8360df 100644 --- a/src/Tableau.Migration/Content/Files/TableauFileEditor.cs +++ b/src/Tableau.Migration/Content/Files/TableauFileEditor.cs @@ -80,7 +80,7 @@ internal static bool IsXmlFile(string fileName) _ => false }; } - + /// public ITableauFileXmlStream GetXmlStream() { @@ -109,17 +109,8 @@ public ITableauFileXmlStream GetXmlStream() /// The file store file to edit. /// The memory stream manager. /// A cancellation token to obey, and to use when the editor is disposed. - /// - /// True to consider the file a zip archive, - /// false to consider the file an XML file, - /// or null to detect whether the file is a zip archive. - /// /// The newly created file editor. - public static async Task OpenAsync( - IContentFileHandle handle, - IMemoryStreamManager memoryStreamManager, - CancellationToken cancel, - bool? zipFormatOverride = null) + public static async Task OpenAsync(IContentFileHandle handle, IMemoryStreamManager memoryStreamManager, CancellationToken cancel) { var fileStream = await handle.OpenReadAsync(cancel).ConfigureAwait(false); @@ -127,14 +118,22 @@ public static async Task OpenAsync( await using (fileStream) { - //Read the file into a seekable memory stream - //that the ZipArchive requires for update mode. + /* + * Read the file into a seekable memory stream + * that the ZipArchive requires for update mode. + */ await fileStream.Content.CopyToAsync(outputStream, cancel).ConfigureAwait(false); } outputStream.Seek(0, SeekOrigin.Begin); - var isZip = zipFormatOverride == true || await handle.IsZipAsync(cancel).ConfigureAwait(false); + /* + * Determine zip format by with priority of effort to detect: + * 1. The zip flag on the file handle (usually based on download response Content-Type header). + * 2. The original filename/file store filename + * 3. The (potentially decrypted) output stream, looking for Zip file header bytes. + */ + var isZip = handle.IsZipFile is true || handle.HasZipFilePath is true || outputStream.IsZip(); var archive = isZip ? new ZipArchive(outputStream, ZipArchiveMode.Update, leaveOpen: true) : null; diff --git a/src/Tableau.Migration/Content/IAuthenticationConfiguration.cs b/src/Tableau.Migration/Content/IAuthenticationConfiguration.cs new file mode 100644 index 00000000..660dcf16 --- /dev/null +++ b/src/Tableau.Migration/Content/IAuthenticationConfiguration.cs @@ -0,0 +1,45 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Content +{ + /// + /// Interface for an authentication configuration + /// + public interface IAuthenticationConfiguration : IContentReference + { + /// + /// Gets the auth setting name. + /// + public string AuthSetting { get; } + + /// + /// Gets the known provider alias. + /// + public string? KnownProviderAlias { get; } + + /// + /// Gets the IdP configuration name. + /// + public string IdpConfigurationName { get; } + + /// + /// Gets the enabled flag. + /// + public bool Enabled { get; } + } +} diff --git a/src/Tableau.Migration/Content/ICloudSubscription.cs b/src/Tableau.Migration/Content/ICloudSubscription.cs new file mode 100644 index 00000000..9e386ff0 --- /dev/null +++ b/src/Tableau.Migration/Content/ICloudSubscription.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Content.Schedules.Cloud; + +namespace Tableau.Migration.Content +{ + /// + /// The interface for a cloud subscription. + /// + public interface ICloudSubscription : ISubscription, IDelible + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/IConnection.cs b/src/Tableau.Migration/Content/IConnection.cs index 27722985..bd84c54a 100644 --- a/src/Tableau.Migration/Content/IConnection.cs +++ b/src/Tableau.Migration/Content/IConnection.cs @@ -54,5 +54,20 @@ public interface IConnection /// This is returned only for administrator users. /// bool? QueryTaggingEnabled { get; } + + /// + /// Gets the authentication type for the response. + /// + string? AuthenticationType { get; } + + /// + /// Gets whether to use OAuth managed keychain. + /// + bool? UseOAuthManagedKeychain { get; } + + /// + /// Gets whether to embed the password. + /// + bool? EmbedPassword { get; } } } diff --git a/src/Tableau.Migration/Content/IConnectionsContent.cs b/src/Tableau.Migration/Content/IConnectionsContent.cs index d51d9c3d..436b41e5 100644 --- a/src/Tableau.Migration/Content/IConnectionsContent.cs +++ b/src/Tableau.Migration/Content/IConnectionsContent.cs @@ -15,7 +15,9 @@ // limitations under the License. // +using System; using System.Collections.Immutable; +using System.Linq; namespace Tableau.Migration.Content { @@ -32,5 +34,23 @@ public interface IConnectionsContent /// 2) updating connection metadata in a post-publish hook. /// IImmutableList Connections { get; } + + /// + /// Gets whether any have an embedded password. + /// + public bool HasEmbeddedPassword => + Connections.Any(c => c.EmbedPassword is true); + + /// + /// Gets whether any have an embedded password and uses OAuth managed keychain. + /// + public bool HasEmbeddedOAuthManagedKeychain => + Connections.Any(c => c.EmbedPassword is true && c.UseOAuthManagedKeychain is true); + + /// + /// Gets whether any have an embedded password and an OAuth authentication type. + /// + public bool HasEmbeddedOAuthCredentials => + Connections.Any(c => c.EmbedPassword is true && StringComparer.OrdinalIgnoreCase.Equals(c.AuthenticationType, "oauth")); } } diff --git a/src/Tableau.Migration/Content/IDataSource.cs b/src/Tableau.Migration/Content/IDataSource.cs index ee19455d..307f17a0 100644 --- a/src/Tableau.Migration/Content/IDataSource.cs +++ b/src/Tableau.Migration/Content/IDataSource.cs @@ -31,7 +31,8 @@ public interface IDataSource : IPermissionsContent, IRequiresOwnerUpdate, IWithConnections, - IRequiresLabelUpdate + IRequiresLabelUpdate, + IRequiresEmbeddedCredentialMigration { /// /// Gets whether or not the data source has extracts. diff --git a/src/Tableau.Migration/Content/IDelible.cs b/src/Tableau.Migration/Content/IDelible.cs new file mode 100644 index 00000000..3ef78aee --- /dev/null +++ b/src/Tableau.Migration/Content/IDelible.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Content +{ + /// + /// The interface for a content type that can be deleted. + /// + public interface IDelible + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/IRequiresEmbeddedCredentialMigration.cs b/src/Tableau.Migration/Content/IRequiresEmbeddedCredentialMigration.cs new file mode 100644 index 00000000..f57af25d --- /dev/null +++ b/src/Tableau.Migration/Content/IRequiresEmbeddedCredentialMigration.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Content +{ + /// + /// Interface for a content item that needs embedded credentials migrated. + /// + public interface IRequiresEmbeddedCredentialMigration : IWithEmbeddedCredentials + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/IServerSubscription.cs b/src/Tableau.Migration/Content/IServerSubscription.cs new file mode 100644 index 00000000..3ed3f62b --- /dev/null +++ b/src/Tableau.Migration/Content/IServerSubscription.cs @@ -0,0 +1,27 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Content.Schedules.Server; + +namespace Tableau.Migration.Content +{ + /// + /// The interface for a server subscription. + /// + public interface IServerSubscription : ISubscription + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/ISubscription.cs b/src/Tableau.Migration/Content/ISubscription.cs new file mode 100644 index 00000000..59d11b53 --- /dev/null +++ b/src/Tableau.Migration/Content/ISubscription.cs @@ -0,0 +1,68 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Content.Schedules; + +namespace Tableau.Migration.Content +{ + /// + /// Interface for a subscription. + /// + public interface ISubscription : IWithSchedule, IWithOwner + where TSchedule : ISchedule + { + /// + /// Gets or sets the subject of the subscription. + /// + string Subject { get; set; } + + /// + /// Gets or sets whether or not an image file should be attached to the notification. + /// + bool AttachImage { get; set; } + + /// + /// Gets or sets whether or not a pdf file should be attached to the notification. + /// + bool AttachPdf { get; set; } + + /// + /// Gets or set the page orientation of the subscription. + /// + string PageOrientation { get; set; } + + /// + /// Gets or set the page page size option of the subscription. + /// + string PageSizeOption { get; set; } + + /// + /// Gets or sets whether or not the subscription is suspended. + /// + bool Suspended { get; set; } + + /// + /// Gets or sets the message of the subscription. + /// + string Message { get; set; } + + /// + /// Gets or set the content reference of the subscription. + /// + ISubscriptionContent Content { get; set; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/ISubscriptionContent.cs b/src/Tableau.Migration/Content/ISubscriptionContent.cs new file mode 100644 index 00000000..0b95df36 --- /dev/null +++ b/src/Tableau.Migration/Content/ISubscriptionContent.cs @@ -0,0 +1,42 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Content +{ + /// + /// The content of the subscription. + /// + public interface ISubscriptionContent + { + /// + /// The ID of the content item tied to the subscription. + /// + public Guid Id { get; set; } + + /// + /// The content type of the subscription. + /// + public string Type { get; set; } + + /// + /// Whether or not send the notification if the view is empty. + /// + public bool SendIfViewEmpty { get; set; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/ITag.cs b/src/Tableau.Migration/Content/ITag.cs index ad1bee18..2047b486 100644 --- a/src/Tableau.Migration/Content/ITag.cs +++ b/src/Tableau.Migration/Content/ITag.cs @@ -18,7 +18,7 @@ namespace Tableau.Migration.Content { /// - /// Inteface for tags associated with content items. + /// Interface for tags associated with content items. /// public interface ITag { diff --git a/src/Tableau.Migration/Content/IUser.cs b/src/Tableau.Migration/Content/IUser.cs index 20e1a1ab..697d86e6 100644 --- a/src/Tableau.Migration/Content/IUser.cs +++ b/src/Tableau.Migration/Content/IUser.cs @@ -43,7 +43,18 @@ public interface IUser : IUsernameContent /// Gets or sets the authentication type of the user, /// or null to not send an explicit authentication type for the user during migration. /// - string? AuthenticationType { get; set; } + public string? AuthenticationType + { + get => Authentication.AuthenticationType; + set => Authentication = + string.IsNullOrEmpty(value) ? new(null, Authentication.IdpConfigurationId) : UserAuthenticationType.ForAuthenticationType(value); + } + + /// + /// Gets or sets the authentication type of the user. + /// Use to use either the default authentication type of the site. + /// + UserAuthenticationType Authentication { get; set; } /// /// Gets the user's administrator level derived from . diff --git a/src/Tableau.Migration/Content/IView.cs b/src/Tableau.Migration/Content/IView.cs index 1ade43ad..359ad8dd 100644 --- a/src/Tableau.Migration/Content/IView.cs +++ b/src/Tableau.Migration/Content/IView.cs @@ -20,6 +20,13 @@ namespace Tableau.Migration.Content /// /// Interface for view associated with the content item. /// - public interface IView : IWithTags, IPermissionsContent - { } + public interface IView : + IWithTags, + IPermissionsContent + { + /// + /// Gets the parent workbook of the view. + /// + IContentReference ParentWorkbook { get; } + } } \ No newline at end of file diff --git a/src/Tableau.Migration/Content/IWithEmbeddedCredentials.cs b/src/Tableau.Migration/Content/IWithEmbeddedCredentials.cs new file mode 100644 index 00000000..6e452ce5 --- /dev/null +++ b/src/Tableau.Migration/Content/IWithEmbeddedCredentials.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Content +{ + /// + /// Interface for a content item that has embedded credentials. + /// + public interface IWithEmbeddedCredentials : IContentReference + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/IWorkbook.cs b/src/Tableau.Migration/Content/IWorkbook.cs index f8223c54..4ec9dd4a 100644 --- a/src/Tableau.Migration/Content/IWorkbook.cs +++ b/src/Tableau.Migration/Content/IWorkbook.cs @@ -30,7 +30,8 @@ public interface IWorkbook : IMappableContainerContent, IPermissionsContent, IRequiresOwnerUpdate, - IWithConnections + IWithConnections, + IRequiresEmbeddedCredentialMigration { /// /// Gets or sets whether tabs are shown. diff --git a/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs b/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs index 7e74fbc8..4afa2310 100644 --- a/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs +++ b/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs @@ -54,5 +54,10 @@ public class DefaultPermissionsContentTypeUrlSegments : StringEnum public const string Tables = "tables"; + + /// + /// Gets the virtual connections content type URL path segment. + /// + public const string VirtualConnections = "virtualconnections"; } } diff --git a/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs b/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs index 07460945..35aaba37 100644 --- a/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs +++ b/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -35,17 +35,13 @@ public FrequencyDetails(IScheduleFrequencyDetailsType response) : this(response.Start.ToTimeOrNull(), response.End.ToTimeOrNull(), response.Intervals.Select(i => new Interval(i) as IInterval)) { } - public FrequencyDetails(TimeOnly? startAt, TimeOnly? endAt, IEnumerable intervals) + public FrequencyDetails(TimeOnly? startAt, TimeOnly? endAt, params IEnumerable intervals) { StartAt = startAt; EndAt = endAt; Intervals = intervals.ToList(); } - public FrequencyDetails(TimeOnly? startAt, TimeOnly? endAt, params IInterval[] intervals) - : this(startAt, endAt, (IEnumerable)intervals) - { } - public override string ToString() { var sb = new StringBuilder(); diff --git a/src/Tableau.Migration/Content/Schedules/IExtractRefreshTaskConverter.cs b/src/Tableau.Migration/Content/Schedules/IExtractRefreshTaskConverter.cs deleted file mode 100644 index 65171c65..00000000 --- a/src/Tableau.Migration/Content/Schedules/IExtractRefreshTaskConverter.cs +++ /dev/null @@ -1,42 +0,0 @@ -// -// Copyright (c) 2025, Salesforce, Inc. -// SPDX-License-Identifier: Apache-2 -// -// Licensed under the Apache License, Version 2.0 (the "License") -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -using System; - -namespace Tableau.Migration.Content.Schedules -{ - /// - /// Interface for converting extract refresh tasks from one type to another. - /// - /// The type of the source extract refresh task. - /// The type of the source extract refresh task. - /// The type of the target extract refresh task. - /// The type of the source extract refresh task. - public interface IExtractRefreshTaskConverter - where TSourceTask : IExtractRefreshTask - where TSourceSchedule : ISchedule - where TTargetTask : IExtractRefreshTask - where TTargetSchedule : ISchedule - { - /// - /// Converts a source extract refresh task to a target extract refresh task. - /// - /// The source extract refresh task to convert. - /// The converted target extract refresh task. - TTargetTask Convert(TSourceTask source); - } -} diff --git a/src/Tableau.Migration/Content/ServerSubscription.cs b/src/Tableau.Migration/Content/ServerSubscription.cs new file mode 100644 index 00000000..13927f61 --- /dev/null +++ b/src/Tableau.Migration/Content/ServerSubscription.cs @@ -0,0 +1,83 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models.Responses.Server; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.Content.Search; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Content +{ + internal sealed class ServerSubscription : SubscriptionBase, IServerSubscription + { + public ServerSubscription(GetSubscriptionsResponse.SubscriptionType sub, IContentReference user, IServerSchedule schedule) + : base(sub, user, schedule) + { } + + private static async Task CreateAsync( + GetSubscriptionsResponse.SubscriptionType sub, + IContentReference user, + IContentCacheFactory contentCacheFactory, + Func>> getScheduleById, + CancellationToken cancel) + { + var scheduleCache = contentCacheFactory.ForContentType(true); + + var scheduleId = sub.Schedule.Id; + + var schedule = await scheduleCache.ForIdAsync(scheduleId, cancel).ConfigureAwait(false); + + if (schedule == null) + { + + var getScheduleResult = await getScheduleById(scheduleId, cancel) + .ConfigureAwait(false); + + if (!getScheduleResult.Success) + { + throw new InvalidOperationException($"A schedule could not be fetched for Server Subscription. {sub.Id}"); + } + + schedule = getScheduleResult.Value; + scheduleCache.AddOrUpdate(schedule); + } + + Guard.AgainstNull(schedule, nameof(schedule)); + + return new ServerSubscription(sub, user, schedule); + } + + public static async Task> CreateManyAsync( + GetSubscriptionsResponse response, + IContentReferenceFinderFactory finderFactory, + IContentCacheFactory contentCacheFactory, + Func>> getScheduleById, + ILogger logger, ISharedResourcesLocalizer localizer, + CancellationToken cancel) + => await CreateManyAsync( + response, + response => response.Items.ExceptNulls(), + async (r, u, cnl) => await CreateAsync(r, u, contentCacheFactory, getScheduleById, cnl).ConfigureAwait(false), + finderFactory, logger, localizer, + cancel).ConfigureAwait(false); + } +} diff --git a/src/Tableau.Migration/Content/SubscriptionBase.cs b/src/Tableau.Migration/Content/SubscriptionBase.cs new file mode 100644 index 00000000..7ef5b4c8 --- /dev/null +++ b/src/Tableau.Migration/Content/SubscriptionBase.cs @@ -0,0 +1,123 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Search; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Content +{ + internal abstract class SubscriptionBase : ContentBase, ISubscription + where TSchedule : ISchedule + { + /// + public string Subject { get; set; } + + /// + public bool AttachImage { get; set; } + + /// + public bool AttachPdf { get; set; } + + /// + public string PageOrientation { get; set; } + + /// + public string PageSizeOption { get; set; } + + /// + public bool Suspended { get; set; } + + /// + public string Message { get; set; } + + /// + public ISubscriptionContent Content { get; set; } + + /// + public IContentReference Owner { get; set; } + + /// + public TSchedule Schedule { get; } + + internal SubscriptionBase(Guid id, string? subject, bool attachImage, bool attachPdf, + string? pageOrientation, string? pageSizeOption, bool suspended, string? message, + ISubscriptionContent content, IContentReference user, TSchedule schedule) + { + Id = Guard.AgainstDefaultValue(id, () => id); + Subject = Guard.AgainstNull(subject, () => subject); + AttachImage = attachImage; + AttachPdf = attachPdf; + PageOrientation = pageOrientation ?? string.Empty; + PageSizeOption = pageSizeOption ?? string.Empty; + Suspended = suspended; + Message = message ?? string.Empty; + + Content = content; + Owner = user; + Schedule = schedule; + + Name = Id.ToString(); + Location = new(Name); + } + + internal SubscriptionBase(ISubscriptionType response, IContentReference user, TSchedule schedule) + : this(response.Id, response.Subject, response.AttachImage, response.AttachPdf, + response.PageOrientation, response.PageSizeOption, response.Suspended, response.Message, + new SubscriptionContent(response.Content), user, schedule) + { } + + internal SubscriptionBase(ISubscription response, IContentReference user, TSchedule schedule) + : this(response.Id, response.Subject, response.AttachImage, response.AttachPdf, + response.PageOrientation, response.PageSizeOption, response.Suspended, response.Message, + new SubscriptionContent(response.Content), user, schedule) + { } + + protected static async Task> CreateManyAsync( + TResponse response, + Func> responseItemFactory, + Func> modelFactory, + IContentReferenceFinderFactory finderFactory, + ILogger logger, ISharedResourcesLocalizer localizer, + CancellationToken cancel) + where TResponse : ITableauServerResponse + where TSubscriptionType : class, ISubscriptionType + where TSubscription : ISubscription + { + var items = responseItemFactory(response).ExceptNulls().ToImmutableArray(); + var results = ImmutableArray.CreateBuilder(items.Length); + + foreach (var item in items) + { + + var user = await finderFactory.FindUserAsync(Guard.AgainstNull(item.User, () => item.User), logger, localizer, true, cancel).ConfigureAwait(false); + + results.Add(await modelFactory(item, user, cancel).ConfigureAwait(false)); + } + + return results.ToImmutable(); + } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/SubscriptionContent.cs b/src/Tableau.Migration/Content/SubscriptionContent.cs new file mode 100644 index 00000000..a64980ed --- /dev/null +++ b/src/Tableau.Migration/Content/SubscriptionContent.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Rest.Models.Responses; + +namespace Tableau.Migration.Content +{ + internal class SubscriptionContent : ISubscriptionContent + { + public Guid Id { get; set; } + public string Type { get; set; } + public bool SendIfViewEmpty { get; set; } + + public SubscriptionContent(Guid id, string type, bool sendIfViewEmpty) + { + Id = id; + Type = type; + SendIfViewEmpty = sendIfViewEmpty; + } + + public SubscriptionContent(ISubscriptionContentType? response) + { + Guard.AgainstNull(response, () => response); + Id = Guard.AgainstDefaultValue(response.Id, () => response.Id); + Type = Guard.AgainstNull(response.Type, () => response.Type); + SendIfViewEmpty = response.SendIfViewEmpty; + } + + public SubscriptionContent(ISubscriptionContent content) + : this(content.Id, content.Type, content.SendIfViewEmpty) + { } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/User.cs b/src/Tableau.Migration/Content/User.cs index b80b123e..4d49f01a 100644 --- a/src/Tableau.Migration/Content/User.cs +++ b/src/Tableau.Migration/Content/User.cs @@ -16,6 +16,8 @@ // using System; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Responses; namespace Tableau.Migration.Content @@ -32,7 +34,7 @@ internal sealed class User : UsernameContentBase, IUser public string SiteRole { get; set; } /// - public string? AuthenticationType { get; set; } + public UserAuthenticationType Authentication { get; set; } public User(UsersResponse.UserType response) : this( @@ -42,25 +44,29 @@ public User(UsersResponse.UserType response) response.Name, response.FullName, response.SiteRole, - response.AuthSetting) - { - } + response.GetAuthenticationType() + ) + { } + + public User(Guid id, IUpdateUserResult result) + : this(id, null, result.Email, result.Name, result.FullName, result.SiteRole, result.Authentication) + { } - public User( + private User( Guid id, string? userDomain, string? email, string? name, string? fullName, string? siteRole, - string? authSetting) + UserAuthenticationType authentication) { Id = Guard.AgainstDefaultValue(id, () => id); Email = email ?? string.Empty; Name = Guard.AgainstNullEmptyOrWhiteSpace(name, () => name); FullName = fullName ?? string.Empty; SiteRole = Guard.AgainstNullEmptyOrWhiteSpace(siteRole, () => siteRole); - AuthenticationType = authSetting; + Authentication = authentication; Domain = userDomain ?? string.Empty; } } diff --git a/src/Tableau.Migration/Content/UserAuthenticationType.cs b/src/Tableau.Migration/Content/UserAuthenticationType.cs new file mode 100644 index 00000000..cb3d675a --- /dev/null +++ b/src/Tableau.Migration/Content/UserAuthenticationType.cs @@ -0,0 +1,71 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Content +{ + /// + /// Structure representing the authentication type of a user. + /// + public readonly record struct UserAuthenticationType + { + /// + /// Gets the authentication type, + /// or null if the site uses s. + /// + public string? AuthenticationType { get; } + + /// + /// Gets the IdP configuration ID, + /// or null if the site uses s. + /// + public Guid? IdpConfigurationId { get; } + + /// + /// Gets a value representing the site default authentication type. + /// + public static readonly UserAuthenticationType Default = new(null, null); + + /// + /// Creates a new value. + /// + /// The authentication type, or null if is non-null. + /// The IdP configuration ID, or null if is non-null. + internal UserAuthenticationType(string? authenticationType, Guid? idpConfigurationId) + { + AuthenticationType = authenticationType; + IdpConfigurationId = idpConfigurationId; + } + + /// + /// Creates a new value. + /// + /// The authentication type. + /// The created value. + public static UserAuthenticationType ForAuthenticationType(string authenticationType) + => new(authenticationType, null); + + /// + /// Creates a new value. + /// + /// The IdP configuration ID. + /// The created value. + public static UserAuthenticationType ForConfigurationId(Guid idpConfigurationId) + => new(null, idpConfigurationId); + } +} diff --git a/src/Tableau.Migration/Content/View.cs b/src/Tableau.Migration/Content/View.cs index 1327edef..15ae2b90 100644 --- a/src/Tableau.Migration/Content/View.cs +++ b/src/Tableau.Migration/Content/View.cs @@ -25,15 +25,41 @@ internal sealed class View : ContentBase, IView /// public IList Tags { get; set; } - public View(IViewReferenceType view, IContentReference project, string? workbookName) - { - Guard.AgainstNullEmptyOrWhiteSpace(workbookName, () => workbookName); + public IContentReference ParentWorkbook { get; set; } + /// + /// Initializes a new instance of the class. + /// + /// The view reference to initialize from. + /// The parent project of the parent workbook. + /// The parent workbook this view belongs to. + public View(IWorkbookViewReferenceType view, IContentReference project, IContentReference workbook) + { Id = view.Id; Name = Guard.AgainstNullEmptyOrWhiteSpace(view.Name, () => view.Name); ContentUrl = Guard.AgainstNull(view.ContentUrl, () => view.ContentUrl); - Location = project.Location.Append(workbookName).Append(Name); + Location = project.Location.Append(workbook.Name).Append(Name); Tags = view.Tags.ToTagList(t => new Tag(t)); + ParentWorkbook = workbook; + } + + /// + /// Initializes a new instance of the class. + /// + /// The view response to initialize from. + /// The parent project of the parent workbook. + /// The parent workbook this view belongs to. + public View( + IViewType response, + IContentReference project, + IContentReference workbook) + { + Id = response.Id; + Name = Guard.AgainstNullEmptyOrWhiteSpace(response.Name, () => response.Name); + ContentUrl = Guard.AgainstNull(response.ContentUrl, () => response.ContentUrl); + Location = project.Location.Append(workbook.Name).Append(Name); + Tags = response.Tags.ToTagList(t => new Tag(t)); + ParentWorkbook = workbook; } } } diff --git a/src/Tableau.Migration/Content/WorkbookDetails.cs b/src/Tableau.Migration/Content/WorkbookDetails.cs index 93c1ea3d..d38029fd 100644 --- a/src/Tableau.Migration/Content/WorkbookDetails.cs +++ b/src/Tableau.Migration/Content/WorkbookDetails.cs @@ -30,7 +30,7 @@ internal class WorkbookDetails : Workbook, IWorkbookDetails public WorkbookDetails(IWorkbookDetailsType response, IContentReference project, IContentReference owner) : base(response, project, owner) { - Views = response.Views.Select(v => new View(v, project, Name)).ToImmutableArray(); + Views = response.Views.Select(v => new View(v, project, this)).ToImmutableArray(); } public WorkbookDetails(IWorkbookDetails workbook) diff --git a/src/Tableau.Migration/ContentLocation.cs b/src/Tableau.Migration/ContentLocation.cs index 9d4b8bd6..51cdd03d 100644 --- a/src/Tableau.Migration/ContentLocation.cs +++ b/src/Tableau.Migration/ContentLocation.cs @@ -52,25 +52,25 @@ public readonly record struct ContentLocation(ImmutableArray PathSegment /// /// Creates a new value. /// - /// The location path segments. - public ContentLocation(params string[] segments) - : this((IEnumerable)segments) + /// The parent location to use as a base path. + /// The item name to use as the last path segment. + public ContentLocation(ContentLocation parent, string name) + : this(parent.PathSegments.Append(name).ToImmutableArray(), parent.PathSeparator) { } /// /// Creates a new value. /// - /// The parent location to use as a base path. - /// The item name to use as the last path segment. - public ContentLocation(ContentLocation parent, string name) - : this(parent.PathSegments.Append(name).ToImmutableArray(), parent.PathSeparator) + /// The location path segments. + public ContentLocation(params IEnumerable segments) + : this(segments.ToImmutableArray()) { } /// /// Creates a new value. /// /// The location path segments. - public ContentLocation(IEnumerable segments) + public ContentLocation(params string[] segments) //Array overload for Python interop. : this(segments.ToImmutableArray()) { } @@ -144,16 +144,7 @@ public static ContentLocation FromPath( /// The content type to create the location for. /// The location path segments. /// - public static ContentLocation ForContentType(params string[] pathSegments) - => ForContentType(typeof(TContent), (IEnumerable)pathSegments); - - /// - /// Creates a new with the appropriate path separator for the content type. - /// - /// The content type to create the location for. - /// The location path segments. - /// - public static ContentLocation ForContentType(IEnumerable pathSegments) + public static ContentLocation ForContentType(params IEnumerable pathSegments) => ForContentType(typeof(TContent), pathSegments); /// @@ -162,16 +153,7 @@ public static ContentLocation ForContentType(IEnumerable pathS /// The content type to create the location for. /// The location path segments. /// - public static ContentLocation ForContentType(Type contentType, params string[] pathSegments) - => ForContentType(contentType, (IEnumerable)pathSegments); - - /// - /// Creates a new with the appropriate path separator for the content type. - /// - /// The content type to create the location for. - /// The location path segments. - /// - public static ContentLocation ForContentType(Type contentType, IEnumerable pathSegments) + public static ContentLocation ForContentType(Type contentType, params IEnumerable pathSegments) { string pathSeparator; switch(contentType) diff --git a/src/Tableau.Migration/Engine/Actions/MigrateContentAction.cs b/src/Tableau.Migration/Engine/Actions/MigrateContentAction.cs index 9a89fc4c..3a24f6c5 100644 --- a/src/Tableau.Migration/Engine/Actions/MigrateContentAction.cs +++ b/src/Tableau.Migration/Engine/Actions/MigrateContentAction.cs @@ -17,8 +17,10 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Tableau.Migration.Engine.Migrators; using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Actions { @@ -30,19 +32,39 @@ public class MigrateContentAction : IMigrateContentAction where TContent : class, IContentReference { private readonly IContentMigrator _contentMigrator; + private readonly IMigrationCapabilities _migrationCapabilities; + private readonly ILogger> _logger; + private readonly ISharedResourcesLocalizer _localizer; /// /// Creates a new object. /// /// A pipeline to use to get the content migrator. - public MigrateContentAction(IMigrationPipeline pipeline) + /// The migration capabilities. + /// The logger. + /// The localizer. + public MigrateContentAction( + IMigrationPipeline pipeline, + IMigrationCapabilities migrationCapabilities, + ILogger> logger, + ISharedResourcesLocalizer localizer) { _contentMigrator = pipeline.GetMigrator(); + _migrationCapabilities = migrationCapabilities; + _logger = logger; + _localizer = localizer; } /// public async Task ExecuteAsync(CancellationToken cancel) { + var contentType = typeof(TContent); + if (_migrationCapabilities.ContentTypesDisabledAtDestination.Contains(contentType)) + { + _logger.LogWarning(_localizer[SharedResourceKeys.ContentTypeDisabledWarning], contentType.GetFormattedName()); + return MigrationActionResult.Succeeded(); + } + var migrateResult = await _contentMigrator.MigrateAsync(cancel).ConfigureAwait(false); return MigrationActionResult.FromResult(migrateResult); diff --git a/src/Tableau.Migration/Engine/Actions/MigrationActionResult.cs b/src/Tableau.Migration/Engine/Actions/MigrationActionResult.cs index b5cf22b4..f5d0dfe9 100644 --- a/src/Tableau.Migration/Engine/Actions/MigrationActionResult.cs +++ b/src/Tableau.Migration/Engine/Actions/MigrationActionResult.cs @@ -28,7 +28,7 @@ internal record MigrationActionResult : Result, IMigrationActionResult /// public bool PerformNextAction { get; } - protected MigrationActionResult(bool success, bool performNextAction, IEnumerable errors) + protected MigrationActionResult(bool success, bool performNextAction, params IEnumerable errors) : base(success, errors) { PerformNextAction = performNextAction; @@ -38,10 +38,6 @@ protected MigrationActionResult(IResult baseResult, bool performNextAction) : this(baseResult.Success, performNextAction, baseResult.Errors) { } - protected MigrationActionResult(bool success, bool performNextAction, params Exception[] errors) - : this(success, performNextAction, (IEnumerable)errors) - { } - /// /// Creates a new instance for successful operations. /// diff --git a/src/Tableau.Migration/Engine/Actions/PreflightAction.cs b/src/Tableau.Migration/Engine/Actions/PreflightAction.cs index 257eb4c4..e6471f6c 100644 --- a/src/Tableau.Migration/Engine/Actions/PreflightAction.cs +++ b/src/Tableau.Migration/Engine/Actions/PreflightAction.cs @@ -21,7 +21,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tableau.Migration.Config; -using Tableau.Migration.Content; using Tableau.Migration.Engine.Hooks; using Tableau.Migration.Resources; @@ -48,9 +47,13 @@ public class PreflightAction : IMigrationAction /// The hook runner /// A logger. /// A localizer. - public PreflightAction(IServiceProvider services, IOptions options, - IMigration migration, IMigrationHookRunner hooks, - ILogger logger, ISharedResourcesLocalizer localizer) + public PreflightAction( + IServiceProvider services, + IOptions options, + IMigration migration, + IMigrationHookRunner hooks, + ILogger logger, + ISharedResourcesLocalizer localizer) { _services = services; _options = options.Value; diff --git a/src/Tableau.Migration/Engine/Conversion/DirectContentItemConverter.cs b/src/Tableau.Migration/Engine/Conversion/DirectContentItemConverter.cs new file mode 100644 index 00000000..c962c877 --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/DirectContentItemConverter.cs @@ -0,0 +1,45 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Tableau.Migration.Engine.Conversion +{ + /// + /// Converter that returns the item with no conversion, for when the prepartion and publish type are the same. + /// + /// + /// + public class DirectContentItemConverter : IContentItemConverter + where TPrepare : class + where TPublish : class + { + /// + public Task ConvertAsync(TPrepare sourceItem, CancellationToken cancel) + { + var publish = sourceItem as TPublish; + if(publish is null) + { + throw new InvalidCastException($"Content item of preparation type {typeof(TPrepare)} cannot be converted to publish type {typeof(TPublish)}. Register a converter override in the pipeline."); + } + + return Task.FromResult(publish); + } + } +} diff --git a/src/Tableau.Migration/Content/Schedules/ExtractRefreshTaskConverterBase.cs b/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/ExtractRefreshTaskConverterBase.cs similarity index 58% rename from src/Tableau.Migration/Content/Schedules/ExtractRefreshTaskConverterBase.cs rename to src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/ExtractRefreshTaskConverterBase.cs index 92df707a..d7edc99b 100644 --- a/src/Tableau.Migration/Content/Schedules/ExtractRefreshTaskConverterBase.cs +++ b/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/ExtractRefreshTaskConverterBase.cs @@ -15,38 +15,33 @@ // limitations under the License. // -using System; -using Microsoft.Extensions.Logging; -using Tableau.Migration.Resources; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Engine.Conversion.Schedules; -namespace Tableau.Migration.Content.Schedules +namespace Tableau.Migration.Engine.Conversion.ExtractRefreshTasks { - internal abstract class ExtractRefreshTaskConverterBase : IExtractRefreshTaskConverter + internal abstract class ExtractRefreshTaskConverterBase : IExtractRefreshTaskConverter where TSourceTask : IExtractRefreshTask where TSourceSchedule : ISchedule where TTargetTask : IExtractRefreshTask where TTargetSchedule : ISchedule { - protected ILogger> Logger { get; } - protected ISharedResourcesLocalizer Localizer { get; } + private readonly IScheduleConverter _scheduleConverter; - protected ExtractRefreshTaskConverterBase( - ILogger> logger, - ISharedResourcesLocalizer localizer) + protected ExtractRefreshTaskConverterBase(IScheduleConverter scheduleConverter) { - Logger = logger; - Localizer = localizer; + _scheduleConverter = scheduleConverter; } /// - public TTargetTask Convert(TSourceTask source) + public async Task ConvertAsync(TSourceTask source, CancellationToken cancel) { - var targetSchedule = ConvertSchedule(source.Schedule); + var targetSchedule = await _scheduleConverter.ConvertAsync(source.Schedule, cancel).ConfigureAwait(false); return ConvertExtractRefreshTask(source, targetSchedule); } - protected abstract TTargetSchedule ConvertSchedule(TSourceSchedule sourceSchedule); - protected abstract TTargetTask ConvertExtractRefreshTask(TSourceTask source, TTargetSchedule targetSchedule); } } diff --git a/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/IExtractRefreshTaskConverter.cs b/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/IExtractRefreshTaskConverter.cs new file mode 100644 index 00000000..1038062f --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/IExtractRefreshTaskConverter.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Engine.Conversion.ExtractRefreshTasks +{ + /// + /// Interface for converting extract refresh tasks from one type to another. + /// + /// The source extract refresh task type. + /// The target extract refresh task type. + public interface IExtractRefreshTaskConverter + : IContentItemConverter + { } +} diff --git a/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/ServerToCloudExtractRefreshTaskConverter.cs b/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/ServerToCloudExtractRefreshTaskConverter.cs new file mode 100644 index 00000000..529c9cb2 --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/ExtractRefreshTasks/ServerToCloudExtractRefreshTaskConverter.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.Engine.Conversion.ExtractRefreshTasks; +using Tableau.Migration.Engine.Conversion.Schedules; + +namespace Tableau.Migration.ContentConverters.Schedules +{ + /// + /// Converter for converting ServerExtractRefreshTask to CloudExtractRefreshTask. + /// + internal class ServerToCloudExtractRefreshTaskConverter : + ExtractRefreshTaskConverterBase + { + /// + /// Initializes a new instance of the class. + /// + /// The schedule converter. + public ServerToCloudExtractRefreshTaskConverter(IScheduleConverter scheduleConverter) + : base(scheduleConverter) + { } + + /// + /// Creates a new instance of the target extract refresh task. + /// + /// The source extract refresh task. + /// The converted target schedule. + /// A new instance of the target extract refresh task. + protected override ICloudExtractRefreshTask ConvertExtractRefreshTask(IServerExtractRefreshTask source, ICloudSchedule targetSchedule) + { + var type = source.Type; + if (type == ExtractRefreshType.ServerIncrementalRefresh) + { + type = ExtractRefreshType.CloudIncrementalRefresh; + } + + return new CloudExtractRefreshTask(source.Id, type, source.ContentType, source.Content, targetSchedule); + } + } +} diff --git a/src/Tableau.Migration/Engine/Conversion/IContentItemConverter.cs b/src/Tableau.Migration/Engine/Conversion/IContentItemConverter.cs new file mode 100644 index 00000000..5279a0e9 --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/IContentItemConverter.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; + +namespace Tableau.Migration.Engine.Conversion +{ + /// + /// Interface for an object that can convert items during migration. + /// + /// The type of item being prepared from the source. + /// The type of item to be published to the destination. + public interface IContentItemConverter + { + /// + /// Converts the item to a publishable type. + /// + /// The item being prepared for publishing. + /// The cancellation token to obey. + /// The converted item. + Task ConvertAsync(TPrepare sourceItem, CancellationToken cancel); + } +} diff --git a/src/Tableau.Migration/Engine/Conversion/Schedules/IScheduleConverter.cs b/src/Tableau.Migration/Engine/Conversion/Schedules/IScheduleConverter.cs new file mode 100644 index 00000000..a44f4fac --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/Schedules/IScheduleConverter.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Content.Schedules; + +namespace Tableau.Migration.Engine.Conversion.Schedules +{ + /// + /// Interface for converting schedules from one type to another. + /// + /// The type of the source schedule. + /// The type of the source schedule. + public interface IScheduleConverter + : IContentItemConverter + where TSourceSchedule : ISchedule + where TTargetSchedule : ISchedule + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/Schedules/ServerToCloudExtractRefreshTaskConverter.cs b/src/Tableau.Migration/Engine/Conversion/Schedules/ServerToCloudScheduleConverter.cs similarity index 50% rename from src/Tableau.Migration/Content/Schedules/ServerToCloudExtractRefreshTaskConverter.cs rename to src/Tableau.Migration/Engine/Conversion/Schedules/ServerToCloudScheduleConverter.cs index b557bd1e..a0b4c537 100644 --- a/src/Tableau.Migration/Content/Schedules/ServerToCloudExtractRefreshTaskConverter.cs +++ b/src/Tableau.Migration/Engine/Conversion/Schedules/ServerToCloudScheduleConverter.cs @@ -19,49 +19,49 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Threading; +using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Resources; -namespace Tableau.Migration.Content.Schedules +namespace Tableau.Migration.Engine.Conversion.Schedules { /// - /// Converter for converting ServerExtractRefreshTask to CloudExtractRefreshTask. + /// Class to convert a server extract refresh schedule to a cloud extract refresh schedule. /// - internal class ServerToCloudExtractRefreshTaskConverter : - ExtractRefreshTaskConverterBase + internal class ServerToCloudScheduleConverter : IScheduleConverter { private readonly IScheduleValidator _serverScheduleValidator; private readonly IScheduleValidator _cloudScheduleValidator; - - private ScheduleComparers _scheduleComparers = new ScheduleComparers(); + private readonly ILogger _logger; + private readonly ISharedResourcesLocalizer _localizer; + private readonly ScheduleComparers _scheduleComparers = new(); /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// Validator for server schedule. /// Validator for cloud schedule. /// The logger instance for logging. /// The localizer instance for localization. - public ServerToCloudExtractRefreshTaskConverter( + public ServerToCloudScheduleConverter( IScheduleValidator serverScheduleValidator, IScheduleValidator cloudScheduleValidator, - ILogger logger, + ILogger logger, ISharedResourcesLocalizer localizer) - : base(logger, localizer) { _serverScheduleValidator = serverScheduleValidator; _cloudScheduleValidator = cloudScheduleValidator; + _logger = logger; + _localizer = localizer; } - /// - /// Converts a server extract refresh schedule to a cloud extract refresh schedule. - /// - /// The server schedule to convert. - /// The converted cloud schedule. - protected override ICloudSchedule ConvertSchedule(IServerSchedule sourceSchedule) + /// + public Task ConvertAsync(IServerSchedule sourceSchedule, CancellationToken cancel) { _serverScheduleValidator.Validate(sourceSchedule); @@ -89,52 +89,17 @@ protected override ICloudSchedule ConvertSchedule(IServerSchedule sourceSchedule case null: case "": - throw new InvalidScheduleException(Localizer[SharedResourceKeys.FrequencyNotSetError]); + throw new InvalidScheduleException(_localizer[SharedResourceKeys.FrequencyNotSetError]); default: - throw new ArgumentException(Localizer[SharedResourceKeys.FrequencyNotSupportedError]); + throw new ArgumentException(_localizer[SharedResourceKeys.FrequencyNotSupportedError]); } _cloudScheduleValidator.Validate(cloudSchedule); - if (changeMessage.Length > 0) - { - // We have schedule updates - if (_scheduleComparers.Compare(sourceSchedule, cloudSchedule) == 0) - { - // We have updates, but the schedules are the same. Something went wrong. - throw new InvalidOperationException(Localizer[SharedResourceKeys.ScheduleUpdateFailedError, sourceSchedule, cloudSchedule]); - } - Logger.LogInformation(Localizer[SharedResourceKeys.ScheduleUpdatedMessage, sourceSchedule, cloudSchedule, changeMessage.ToString()]); - } - else - { - // We have not made updates - if (_scheduleComparers.Compare(sourceSchedule, cloudSchedule) != 0) - { - // We have not made updates, but the schedules are different. Something went wrong. - throw new InvalidOperationException(Localizer[SharedResourceKeys.ScheduleUpdateFailedError, sourceSchedule, cloudSchedule]); - } - } - - return cloudSchedule; - } + HandleConversionResult(sourceSchedule, cloudSchedule, changeMessage); - /// - /// Creates a new instance of the target extract refresh task. - /// - /// The source extract refresh task. - /// The converted target schedule. - /// A new instance of the target extract refresh task. - protected override ICloudExtractRefreshTask ConvertExtractRefreshTask(IServerExtractRefreshTask source, ICloudSchedule targetSchedule) - { - var type = source.Type; - if (type == ExtractRefreshType.ServerIncrementalRefresh) - { - type = ExtractRefreshType.CloudIncrementalRefresh; - } - - return new CloudExtractRefreshTask(source.Id, type, source.ContentType, source.Content, targetSchedule); + return Task.FromResult(cloudSchedule); } private void ConvertHourly(CloudSchedule schedule, StringBuilder changeMessage) @@ -143,42 +108,35 @@ private void ConvertHourly(CloudSchedule schedule, StringBuilder changeMessage) { // This is a schedule that should run every n hours, where n is not 1. This means we need to convert it to a daily schedule. schedule.Frequency = ScheduleFrequencies.Daily; - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedFrequencyToDailyMessage]); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleUpdatedFrequencyToDailyMessage]); ConvertDaily(schedule, changeMessage); return; } - // If schedule has no weekday intervals, add all weekdays - if (!schedule.FrequencyDetails.Intervals.Any(interval => !interval.WeekDay.IsNullOrEmpty())) + if (!HasWeekdayIntervals(schedule.FrequencyDetails.Intervals)) { - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Monday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Tuesday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Wednesday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Thursday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Friday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Saturday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Sunday")); - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedAddedWeekdayMessage]); + AddAllWeekdays(schedule); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleUpdatedAddedWeekdayMessage]); } - if (schedule.FrequencyDetails.Intervals.Any(interval => interval.Hours.HasValue || interval.Minutes.HasValue)) { // If the schedule has an interval with hours, but the hours are not 1, then it was caught earlier and we don't need to deal with it here var invalidMinuteIntervals = schedule.FrequencyDetails.Intervals.Where(interval => interval.Minutes.HasValue && interval.Minutes.Value != 60).ToList(); - if (invalidMinuteIntervals.Any()) + + if (invalidMinuteIntervals.Count == 0) { - // We have invalid minute intervals - foreach (var interval in invalidMinuteIntervals) - { - schedule.FrequencyDetails.Intervals.Remove(interval); - } - schedule.FrequencyDetails.Intervals.Add(Interval.WithMinutes(60)); - changeMessage.AppendLine(Localizer[SharedResourceKeys.ReplacingHourlyIntervalMessage]); + return; } - return; + // We have invalid minute intervals + foreach (var interval in invalidMinuteIntervals) + { + schedule.FrequencyDetails.Intervals.Remove(interval); + } + schedule.FrequencyDetails.Intervals.Add(Interval.WithMinutes(60)); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ReplacingHourlyIntervalMessage]); } } @@ -191,14 +149,15 @@ private void ConvertDaily(CloudSchedule schedule, StringBuilder changeMessage) // if hours does not exist, end time must not exist // If there are more then 1 hours interval, remove all but the last one - if (schedule.FrequencyDetails.Intervals.Where(i => i.Hours.HasValue).ToList().Count > 1) + var hourlyIntervals = schedule.FrequencyDetails.Intervals.Where(i => i.Hours.HasValue); + if (hourlyIntervals.Count() > 1) { - schedule.FrequencyDetails.Intervals = new List() { schedule.FrequencyDetails.Intervals.Last() }; - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleShouldOnlyHaveOneHoursIntervalWarning, schedule]); + schedule.FrequencyDetails.Intervals = [schedule.FrequencyDetails.Intervals.Last()]; + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleShouldOnlyHaveOneHoursIntervalWarning, schedule]); } // Validate that the hours interval is one of the valid values - var hourInterval = schedule.FrequencyDetails.Intervals.Where(i => i.Hours.HasValue).LastOrDefault(); + var hourInterval = hourlyIntervals.LastOrDefault(); if (hourInterval is not null) { if (!IsValidHour(hourInterval.Hours!.Value)) @@ -206,7 +165,7 @@ private void ConvertDaily(CloudSchedule schedule, StringBuilder changeMessage) var newHourInterval = Interval.WithHours(FindNearestValidHour(hourInterval.Hours!.Value)); schedule.FrequencyDetails.Intervals.Remove(hourInterval); schedule.FrequencyDetails.Intervals.Add(newHourInterval); - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedHoursMessage, hourInterval.Hours, newHourInterval.Hours!]); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleUpdatedHoursMessage, hourInterval.Hours, newHourInterval.Hours!]); } } @@ -217,7 +176,7 @@ private void ConvertDaily(CloudSchedule schedule, StringBuilder changeMessage) { // End is always required if hours are set schedule.FrequencyDetails.EndAt = schedule.FrequencyDetails.StartAt; // Ending 24h after start - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedAddedEndAtMessage]); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleUpdatedAddedEndAtMessage]); } } else @@ -226,46 +185,67 @@ private void ConvertDaily(CloudSchedule schedule, StringBuilder changeMessage) if (schedule.FrequencyDetails.EndAt is not null) { schedule.FrequencyDetails.EndAt = null; - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedRemovedEndAtMessage]); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleUpdatedRemovedEndAtMessage]); } } - // If schedule has no weekday intervals, add all weekdays - if (!schedule.FrequencyDetails.Intervals.Any(interval => !interval.WeekDay.IsNullOrEmpty())) + if (!HasWeekdayIntervals(schedule.FrequencyDetails.Intervals)) { - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Monday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Tuesday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Wednesday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Thursday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Friday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Saturday")); - schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday("Sunday")); - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedAddedWeekdayMessage]); + AddAllWeekdays(schedule); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleUpdatedAddedWeekdayMessage]); + } + } + + private void HandleConversionResult(IServerSchedule sourceSchedule, CloudSchedule cloudSchedule, StringBuilder changeMessage) + { + if (changeMessage.Length > 0) + { + // We have schedule updates + if (_scheduleComparers.Compare(sourceSchedule, cloudSchedule) == 0) + { + // We have updates, but the schedules are the same. Something went wrong. + throw new InvalidOperationException(_localizer[SharedResourceKeys.ScheduleUpdateFailedError, sourceSchedule, cloudSchedule]); + } + _logger.LogInformation(_localizer[SharedResourceKeys.ScheduleUpdatedMessage, sourceSchedule, cloudSchedule, changeMessage.ToString()]); return; } + + // We have not made updates + if (_scheduleComparers.Compare(sourceSchedule, cloudSchedule) != 0) + { + // We have not made updates, but the schedules are different. Something went wrong. + throw new InvalidOperationException(_localizer[SharedResourceKeys.ScheduleUpdateFailedError, sourceSchedule, cloudSchedule]); + } + } + + private static void AddAllWeekdays(CloudSchedule schedule) + { + schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday(WeekDays.Monday)); + schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday(WeekDays.Tuesday)); + schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday(WeekDays.Wednesday)); + schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday(WeekDays.Thursday)); + schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday(WeekDays.Friday)); + schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday(WeekDays.Saturday)); + schedule.FrequencyDetails.Intervals.Add(Interval.WithWeekday(WeekDays.Sunday)); } private void ConvertWeekly(CloudSchedule schedule, StringBuilder changeMessage) { - if (schedule.FrequencyDetails.Intervals.Count(interval => !interval.WeekDay.IsNullOrEmpty()) > 1) + if (HasWeekdayIntervals(schedule.FrequencyDetails.Intervals)) { // We have more than 1 weekday interval in a weekly schedule. This must be converted to Daily. schedule.Frequency = ScheduleFrequencies.Daily; - changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedFrequencyToDailyMessage]); + changeMessage.AppendLine(_localizer[SharedResourceKeys.ScheduleUpdatedFrequencyToDailyMessage]); ConvertDaily(schedule, changeMessage); } } - public bool IsValidHour(int hour) - { - return IntervalValues.CloudHoursValues.Contains(hour); - } + private static bool HasWeekdayIntervals(IList intervals) + => intervals.Count(interval => !interval.WeekDay.IsNullOrEmpty()) > 1; - public int FindNearestValidHour(int hour) - { - return IntervalValues.CloudHoursValues - .OrderBy(h => Math.Abs(h - hour)) - .FirstOrDefault(); - } + public static bool IsValidHour(int hour) => IntervalValues.CloudHoursValues.Contains(hour); + + public static int FindNearestValidHour(int hour) + => IntervalValues.CloudHoursValues.OrderBy(h => Math.Abs(h - hour)).FirstOrDefault(); } } diff --git a/src/Tableau.Migration/Engine/Conversion/Subscriptions/ISubscriptionConverter.cs b/src/Tableau.Migration/Engine/Conversion/Subscriptions/ISubscriptionConverter.cs new file mode 100644 index 00000000..a07f29ce --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/Subscriptions/ISubscriptionConverter.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Engine.Conversion.Subscriptions +{ + /// + /// Interface for converting subscriptions from one type to another. + /// + /// The source subscription type + /// The target subscription type. + public interface ISubscriptionConverter + : IContentItemConverter + { } +} diff --git a/src/Tableau.Migration/Engine/Conversion/Subscriptions/ServerToCloudSubscriptionConverter.cs b/src/Tableau.Migration/Engine/Conversion/Subscriptions/ServerToCloudSubscriptionConverter.cs new file mode 100644 index 00000000..6fe0cc5a --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/Subscriptions/ServerToCloudSubscriptionConverter.cs @@ -0,0 +1,37 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.Engine.Conversion.Schedules; + +namespace Tableau.Migration.Engine.Conversion.Subscriptions +{ + internal class ServerToCloudSubscriptionConverter + : SubscriptionConverterBase + { + public ServerToCloudSubscriptionConverter(IScheduleConverter scheduleConverter) + : base(scheduleConverter) + { } + + protected override ICloudSubscription ConvertSubscription(IServerSubscription source, ICloudSchedule targetSchedule) + => new CloudSubscription( + source.Id, source.Subject, source.AttachImage, source.AttachPdf, source.PageOrientation, + source.PageSizeOption, source.Suspended, source.Message, source.Content, source.Owner, targetSchedule); + } +} diff --git a/src/Tableau.Migration/Engine/Conversion/Subscriptions/SubscriptionConverterBase.cs b/src/Tableau.Migration/Engine/Conversion/Subscriptions/SubscriptionConverterBase.cs new file mode 100644 index 00000000..560d5413 --- /dev/null +++ b/src/Tableau.Migration/Engine/Conversion/Subscriptions/SubscriptionConverterBase.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Engine.Conversion.Schedules; + +namespace Tableau.Migration.Engine.Conversion.Subscriptions +{ + internal abstract class SubscriptionConverterBase : ISubscriptionConverter + where TSourceSubscription : ISubscription + where TSourceSchedule : ISchedule + where TTargetSubscription : ISubscription + where TTargetSchedule : ISchedule + { + private readonly IScheduleConverter _scheduleConverter; + + protected SubscriptionConverterBase(IScheduleConverter scheduleConverter) + { + _scheduleConverter = scheduleConverter; + } + + /// + public async Task ConvertAsync(TSourceSubscription source, CancellationToken cancel) + { + var targetSchedule = await _scheduleConverter.ConvertAsync(source.Schedule, cancel).ConfigureAwait(false); + return ConvertSubscription(source, targetSchedule); + } + + protected abstract TTargetSubscription ConvertSubscription(TSourceSubscription source, TTargetSchedule targetSchedule); + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/ApiContentClientFactory.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/ApiContentClientFactory.cs new file mode 100644 index 00000000..c0b16e19 --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/ApiContentClientFactory.cs @@ -0,0 +1,83 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api; +using Tableau.Migration.Content; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + internal class ApiContentClientFactory : IContentClientFactory + { + private readonly ConcurrentDictionary _contentClients = new(); + private readonly ILoggerFactory _loggerFactory; + private readonly ISharedResourcesLocalizer _localizer; + private readonly ISitesApiClient _sitesApiClient; + + public ApiContentClientFactory( + ISitesApiClient sitesApiClient, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer localizer) + { + _sitesApiClient = sitesApiClient; + _loggerFactory = loggerFactory; + _localizer = localizer; + } + + /// + /// Get a content client for a specific content type. + /// + /// The content type of the client to get. + /// The content client of the requested type. + /// Exception that is thrown if a content client of the requested type can not be created, usually because it does not exist. + public IContentClient GetContentClient() + { + return (IContentClient)_contentClients.GetOrAdd( + typeof(TContent), _ => CreateContentClient() + ); + } + + /// + /// Create a content client for the specified content type. + /// + /// The content type of the client to create. + /// The content client of the requested type. + /// Exception that is thrown if a content client of the requested type can not be created, usually because it does not exist. + private IContentClient CreateContentClient() + { + if (typeof(TContent) == typeof(IWorkbook)) + { + var logger = _loggerFactory.CreateLogger(); + return (IContentClient)new WorkbooksContentClient(_sitesApiClient.Workbooks, logger, _localizer); + } + + if (typeof(TContent) == typeof(IView)) + { + var logger = _loggerFactory.CreateLogger(); + return (IContentClient)new ViewsContentClient(_sitesApiClient.Views, logger, _localizer); + } + + // Add other content client types here as needed + + throw new InvalidOperationException($"Content client for type {typeof(TContent).Name} does not exist."); + } + } + +} \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/ContentClientBase.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/ContentClientBase.cs new file mode 100644 index 00000000..946b72fc --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/ContentClientBase.cs @@ -0,0 +1,52 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + /// + /// Base class for content clients. + /// + /// + public abstract class ContentClientBase : IContentClient + { + /// + /// The logger for the content client. + /// + protected ILogger> Logger { get; } + + /// + /// The localizer for the content client. + /// + protected ISharedResourcesLocalizer Localizer { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + public ContentClientBase( + ILogger> logger, + ISharedResourcesLocalizer localizer) + { + Logger = logger; + Localizer = localizer; + } + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/IContentClient.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IContentClient.cs new file mode 100644 index 00000000..10064c7c --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IContentClient.cs @@ -0,0 +1,26 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + /// + /// Interface to work with content types. + /// + /// This allows work to be done on a content item, abstracted from the ApiClient. + public interface IContentClient + { } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/IContentClientFactory.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IContentClientFactory.cs new file mode 100644 index 00000000..2b098a8e --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IContentClientFactory.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + /// + /// Factory for creating content clients. + /// + public interface IContentClientFactory + { + /// + /// Get a content client for a specific content type. + /// + /// Content type of the client. + /// The content type client. + IContentClient GetContentClient(); + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/IViewsContentClient.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IViewsContentClient.cs new file mode 100644 index 00000000..f59efb2e --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IViewsContentClient.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + /// + /// Interface for a client that can interact with workbooks. + /// + public interface IViewsContentClient : IContentClient + { + /// + /// Get the View by Id. + /// + /// Id of the view to get + /// Cancellation token to obey + /// + Task> GetByIdAsync(Guid id, CancellationToken cancel); + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/IWorkbooksContentClient.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IWorkbooksContentClient.cs new file mode 100644 index 00000000..1a1e9d90 --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/IWorkbooksContentClient.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + /// + /// Interface for a client that can interact with workbooks. + /// + public interface IWorkbooksContentClient : IContentClient + { + /// + /// Gets the views for a workbook. + /// + /// The Workbook Id to get views for. + /// The cancellation token to obey. + /// Collection of views for the workbook. + Task>> GetViewsForWorkbookIdAsync(Guid workbookId, CancellationToken cancel); + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/ViewsContentClient.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/ViewsContentClient.cs new file mode 100644 index 00000000..170326ee --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/ViewsContentClient.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api; +using Tableau.Migration.Content; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + /// + /// Content client to interact with workbooks. + /// + public class ViewsContentClient : ContentClientBase, IViewsContentClient + { + private readonly IViewsApiClient _viewsApiClient; + + /// + public ViewsContentClient( + IViewsApiClient viewsApiClient, + ILogger logger, + ISharedResourcesLocalizer localizer) : base(logger, localizer) + { + _viewsApiClient = viewsApiClient; + } + + /// + public Task> GetByIdAsync(Guid id, CancellationToken cancel) + { + return _viewsApiClient.GetByIdAsync(id, cancel); + } + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/ContentClients/WorkbooksContentClient.cs b/src/Tableau.Migration/Engine/Endpoints/ContentClients/WorkbooksContentClient.cs new file mode 100644 index 00000000..95b91842 --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/ContentClients/WorkbooksContentClient.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api; +using Tableau.Migration.Content; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Endpoints.ContentClients +{ + /// + /// Content client to interact with workbooks. + /// + public class WorkbooksContentClient : ContentClientBase, IWorkbooksContentClient + { + private readonly IWorkbooksApiClient _workbooksApiClient; + + /// + public WorkbooksContentClient( + IWorkbooksApiClient workbooksApiClient, + ILogger logger, + ISharedResourcesLocalizer localizer) : base(logger, localizer) + { + _workbooksApiClient = workbooksApiClient; + } + + /// + public async Task>> GetViewsForWorkbookIdAsync(Guid workbookId, CancellationToken cancel) + { + var workbook = await _workbooksApiClient.GetWorkbookAsync(workbookId, cancel).ConfigureAwait(false); + + if (!workbook.Success) + { + return workbook.CastFailure>(); + } + + return Result>.Succeeded(workbook.Value.Views); + } + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs index 9ddca5c6..b0713a27 100644 --- a/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -21,6 +21,7 @@ using System.Threading; using System.Threading.Tasks; using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Content; using Tableau.Migration.Content.Permissions; @@ -159,5 +160,33 @@ Task>> SetCustomViewD Guid id, IEnumerable users, CancellationToken cancel); + + /// + /// Uploads and applies encrypted keychains to a content item. + /// + /// The ID of the content item. + /// The apply keychain options. + /// The cancellation token to obey. + /// The operation result. + Task ApplyKeychainsAsync(Guid contentItemId, IApplyKeychainOptions options, CancellationToken cancel) + where TContent : IWithEmbeddedCredentials; + + /// + /// Uploads saved credentials for a user + /// + /// The user id + /// The list of encrypted keychains + /// The cancellation token + /// The success or failure result. + Task UploadUserSavedCredentialsAsync(Guid userId, IEnumerable encryptedKeychains, CancellationToken cancel); + + /// + /// Deletes a content item + /// + /// The ID for the content item to be deleted. + /// The cancellation token to obey. + /// Result of the operation. + Task DeleteAsync(Guid id, CancellationToken cancel) + where TContent : IDelible, IRestIdentifiable; } } diff --git a/src/Tableau.Migration/Engine/Endpoints/IMigrationEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/IMigrationEndpoint.cs index 0be849e9..63fecd2f 100644 --- a/src/Tableau.Migration/Engine/Endpoints/IMigrationEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/IMigrationEndpoint.cs @@ -19,6 +19,7 @@ using System.Threading; using System.Threading.Tasks; using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.ContentClients; using Tableau.Migration.Paging; namespace Tableau.Migration.Engine.Endpoints @@ -49,5 +50,12 @@ public interface IMigrationEndpoint : IAsyncDisposable /// The cancellation token to obey. /// An awaitable task with the server session result. Task> GetSessionAsync(CancellationToken cancel); + + /// + /// Gets a content client for the given content type. + /// + /// The content type. + /// The content client. + IContentClient GetContentClient(); } } diff --git a/src/Tableau.Migration/Engine/Endpoints/ISourceEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/ISourceEndpoint.cs index 87fd12ec..46d7abe4 100644 --- a/src/Tableau.Migration/Engine/Endpoints/ISourceEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/ISourceEndpoint.cs @@ -19,6 +19,7 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; +using Tableau.Migration.Api.Models; using Tableau.Migration.Content; using Tableau.Migration.Content.Permissions; @@ -31,15 +32,15 @@ namespace Tableau.Migration.Engine.Endpoints public interface ISourceEndpoint : IMigrationEndpoint { /// - /// Pulls enough information to publish the content item. + /// Pulls enough information to prepare and publish the content item. /// /// The content type. - /// The publish type. + /// The preparation type. /// The content item to pull. /// The cancellation token to obey. - /// The result of the pull operation with the item to publish. - Task> PullAsync(TContent contentItem, CancellationToken cancel) - where TPublish : class; + /// The result of the pull operation with the item to prepare and publish. + Task> PullAsync(TContent contentItem, CancellationToken cancel) + where TPrepare : class; /// /// Gets permissions for the content item. @@ -71,5 +72,32 @@ Task>> ListConnectionsAsync( Guid contentItemId, CancellationToken cancel) where TContent : IWithConnections; + + + /// + /// Retrieves the encrypted keychains for the content item. + /// + /// The ID of the content item. + /// The destination site information. + /// The cancellation token to obey. + /// The operation result. + Task> RetrieveKeychainsAsync( + Guid contentItemId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + where TContent : IWithEmbeddedCredentials; + + + /// + /// Retrieves saved credentials for a specific user. + /// + /// The user's ID. + /// The destination site information. + /// The cancellation token. + /// The user's saved credentials. + Task> RetrieveUserSavedCredentialsAsync( + Guid userId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel); } } \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Endpoints/MigrationEndpointFactory.cs b/src/Tableau.Migration/Engine/Endpoints/MigrationEndpointFactory.cs index 63ccffe3..a787b9e0 100644 --- a/src/Tableau.Migration/Engine/Endpoints/MigrationEndpointFactory.cs +++ b/src/Tableau.Migration/Engine/Endpoints/MigrationEndpointFactory.cs @@ -17,6 +17,7 @@ using System; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Tableau.Migration.Content.Files; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Resources; @@ -32,6 +33,7 @@ public class MigrationEndpointFactory : IMigrationEndpointFactory private readonly IDestinationContentReferenceFinderFactory _destinationFinderFactory; private readonly ISourceContentReferenceFinderFactory _sourceFinderFactory; private readonly IContentFileStore _fileStore; + private readonly ILoggerFactory _loggerFactory; private readonly ISharedResourcesLocalizer _localizer; /// @@ -41,17 +43,20 @@ public class MigrationEndpointFactory : IMigrationEndpointFactory /// A source content reference finder factory. /// A destination content reference finder factory. /// The file store to use. + /// The logger factory to use. /// A string localizer. public MigrationEndpointFactory(IServiceScopeFactory serviceScopeFactory, ISourceContentReferenceFinderFactory sourceFinderFactory, IDestinationContentReferenceFinderFactory destinationFinderFactory, IContentFileStore fileStore, + ILoggerFactory loggerFactory, ISharedResourcesLocalizer localizer) { _serviceScopeFactory = serviceScopeFactory; _destinationFinderFactory = destinationFinderFactory; _sourceFinderFactory = sourceFinderFactory; _fileStore = fileStore; + _loggerFactory = loggerFactory; _localizer = localizer; } @@ -60,7 +65,7 @@ public IDestinationEndpoint CreateDestination(IMigrationPlan plan) { if (plan.Destination is ITableauApiEndpointConfiguration apiConfig) { - return new TableauApiDestinationEndpoint(_serviceScopeFactory, apiConfig, _destinationFinderFactory, _fileStore, _localizer); + return new TableauApiDestinationEndpoint(_serviceScopeFactory, apiConfig, _destinationFinderFactory, _fileStore, _loggerFactory, _localizer); } throw new ArgumentException($"Cannot create a destination endpoint for type {plan.Source.GetType()}"); @@ -71,7 +76,7 @@ public ISourceEndpoint CreateSource(IMigrationPlan plan) { if (plan.Source is ITableauApiEndpointConfiguration apiConfig) { - return new TableauApiSourceEndpoint(_serviceScopeFactory, apiConfig, _sourceFinderFactory, _fileStore, _localizer); + return new TableauApiSourceEndpoint(_serviceScopeFactory, apiConfig, _sourceFinderFactory, _fileStore, _loggerFactory, _localizer); } throw new ArgumentException($"Cannot create a source endpoint for type {plan.Source.GetType()}"); diff --git a/src/Tableau.Migration/Engine/Endpoints/Search/BulkApiAuthenticationConfigurationsCache.cs b/src/Tableau.Migration/Engine/Endpoints/Search/BulkApiAuthenticationConfigurationsCache.cs new file mode 100644 index 00000000..562fb124 --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/Search/BulkApiAuthenticationConfigurationsCache.cs @@ -0,0 +1,76 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Api; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Engine.Endpoints.Search +{ + /// + /// Default implementation + /// + public class BulkApiAuthenticationConfigurationsCache : IDestinationAuthenticationConfigurationsCache + { + private readonly IPagedListApiClient _apiListClient; + private readonly SemaphoreSlim _writeSemaphore = new(1, 1); + + private IImmutableList? _authenticationConfigurations; + + /// + /// Creates a new object. + /// + /// The destination endpoint. + public BulkApiAuthenticationConfigurationsCache(IDestinationEndpoint endpoint) + { + _apiListClient = ((IDestinationApiEndpoint)endpoint).SiteApi.AuthenticationConfigurations; + } + + #region - IDestinationAuthenticationConfigurationsCache Implementation - + + /// + public async Task> GetAllAsync(CancellationToken cancel) + { + if (_authenticationConfigurations is not null) + { + return _authenticationConfigurations; + } + + await _writeSemaphore.WaitAsync(cancel).ConfigureAwait(false); + + try + { + // Retry lookup in case a semaphore wait means the populated for this attempt. + if (_authenticationConfigurations is not null) + { + return _authenticationConfigurations; + } + + var loadResult = await _apiListClient.GetAllAsync(AuthenticationConfigurationsApiClient.MAX_CONFIGURATIONS, cancel).ConfigureAwait(false); + return _authenticationConfigurations = loadResult.Success ? loadResult.Value : ImmutableArray.Empty; + } + finally + { + _writeSemaphore.Release(); + } + } + + #endregion + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/Search/IDestinationAuthenticationConfigurationsCache.cs b/src/Tableau.Migration/Engine/Endpoints/Search/IDestinationAuthenticationConfigurationsCache.cs new file mode 100644 index 00000000..f1f8bb18 --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/Search/IDestinationAuthenticationConfigurationsCache.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Engine.Endpoints.Search +{ + /// + /// Interface for a cache of a site's s + /// + public interface IDestinationAuthenticationConfigurationsCache + { + /// + /// Gets the list of authentication configurations on the destination site, + /// populating the cache if necessary. + /// + /// The cancellation token to obey. + /// + /// The authentication configurations, + /// or an empty list if the site does not support multiple authentication configurations. + /// + Task> GetAllAsync(CancellationToken cancel); + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/Search/IUserSavedCredentialsCache.cs b/src/Tableau.Migration/Engine/Endpoints/Search/IUserSavedCredentialsCache.cs new file mode 100644 index 00000000..f10029bc --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/Search/IUserSavedCredentialsCache.cs @@ -0,0 +1,43 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Models; + +namespace Tableau.Migration.Engine.Endpoints.Search +{ + /// + /// Interface for user saved credential cache. + /// + public interface IUserSavedCredentialsCache + { + /// + /// Add or update saved credentials for the given user ID. + /// + /// The user ID. + /// Saved credentials for the user ID. + /// The value just added to the cache. + IEmbeddedCredentialKeychainResult AddOrUpdate(Guid userId, IEmbeddedCredentialKeychainResult savedCredentials); + + /// + /// Get the saved credentials for the given user ID if present in the cache. + /// + /// The user ID. + /// The saved credentials if already cached else null. + IEmbeddedCredentialKeychainResult? Get(Guid userId); + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/Search/UserSavedCredentialsCache.cs b/src/Tableau.Migration/Engine/Endpoints/Search/UserSavedCredentialsCache.cs new file mode 100644 index 00000000..dcf876fe --- /dev/null +++ b/src/Tableau.Migration/Engine/Endpoints/Search/UserSavedCredentialsCache.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Concurrent; +using Tableau.Migration.Api.Models; + +namespace Tableau.Migration.Engine.Endpoints.Search +{ + internal class UserSavedCredentialsCache : IUserSavedCredentialsCache + { + private readonly ConcurrentDictionary _cache; + + public UserSavedCredentialsCache() + { + _cache = new(); + } + + /// + public IEmbeddedCredentialKeychainResult AddOrUpdate(Guid userId, IEmbeddedCredentialKeychainResult savedCredentials) + => _cache.AddOrUpdate(userId, (_) => savedCredentials, (_, __) => savedCredentials); + + /// + public IEmbeddedCredentialKeychainResult? Get(Guid userId) + { + _ = _cache.TryGetValue(userId, out var savedCredentials); + return savedCredentials; + } + } +} diff --git a/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs index 7cf693ad..a3f3245a 100644 --- a/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -21,7 +21,9 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Content; using Tableau.Migration.Content.Files; using Tableau.Migration.Content.Permissions; @@ -35,6 +37,8 @@ namespace Tableau.Migration.Engine.Endpoints /// public class TableauApiDestinationEndpoint : TableauApiEndpointBase, IDestinationApiEndpoint { + private readonly ISharedResourcesLocalizer _localizer; + /// /// Creates a new object. /// @@ -42,14 +46,18 @@ public class TableauApiDestinationEndpoint : TableauApiEndpointBase, IDestinatio /// The configuration options for connecting to the destination endpoint APIs. /// A destination finder factory. /// The file store to use. + /// The logger factory to use. /// A string localizer. public TableauApiDestinationEndpoint(IServiceScopeFactory serviceScopeFactory, ITableauApiEndpointConfiguration config, IDestinationContentReferenceFinderFactory finderFactory, IContentFileStore fileStore, + ILoggerFactory loggerFactory, ISharedResourcesLocalizer localizer) - : base(serviceScopeFactory, config, finderFactory, fileStore, localizer) - { } + : base(serviceScopeFactory, config, finderFactory, fileStore, loggerFactory, localizer) + { + _localizer = localizer; + } /// public async Task> PublishAsync(TPublish publishItem, CancellationToken cancel) @@ -123,5 +131,27 @@ public async Task>> S .SetCustomViewDefaultUsersAsync(id, users, cancel) .ConfigureAwait(false); } + + /// + public async Task ApplyKeychainsAsync(Guid contentItemId, IApplyKeychainOptions options, CancellationToken cancel) + where TContent : IWithEmbeddedCredentials + { + var apiClient = SiteApi.GetEmbeddedCredentialsApiClient(); + return await apiClient.EmbeddedCredentials.ApplyKeychainAsync(contentItemId, options, cancel).ConfigureAwait(false); + } + + /// + public async Task UploadUserSavedCredentialsAsync(Guid userId, IEnumerable encryptedKeychains, CancellationToken cancel) + { + return await SiteApi.Users.UploadUserSavedCredentialsAsync(userId, encryptedKeychains, cancel).ConfigureAwait(false); ; + } + + /// + public async Task DeleteAsync(Guid id, CancellationToken cancel) + where TContent : IDelible, IRestIdentifiable + { + var deleteApiClient = SiteApi.GetDeleteApiClient(); + return await deleteApiClient.DeleteAsync(id, cancel).ConfigureAwait(false); + } } } diff --git a/src/Tableau.Migration/Engine/Endpoints/TableauApiEndpointBase.cs b/src/Tableau.Migration/Engine/Endpoints/TableauApiEndpointBase.cs index 50223746..c3bfe43c 100644 --- a/src/Tableau.Migration/Engine/Endpoints/TableauApiEndpointBase.cs +++ b/src/Tableau.Migration/Engine/Endpoints/TableauApiEndpointBase.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -20,23 +20,28 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Tableau.Migration.Api; +using Tableau.Migration.Api.Models; using Tableau.Migration.Content; using Tableau.Migration.Content.Files; using Tableau.Migration.Content.Permissions; using Tableau.Migration.Content.Search; +using Tableau.Migration.Engine.Endpoints.ContentClients; using Tableau.Migration.Paging; using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Endpoints { /// - /// impelementation that uses Tableau Server/Cloud APIs. + /// implementation that uses Tableau Server/Cloud APIs. /// public abstract class TableauApiEndpointBase : IMigrationApiEndpoint { + private readonly ILoggerFactory _loggerFactory; private readonly ISharedResourcesLocalizer _localizer; private IAsyncDisposableResult? _signInResult; + private IContentClientFactory? _contentClientFactory; /// /// The per-endpoint dependency injection scope. @@ -76,11 +81,13 @@ public ISitesApiClient SiteApi /// The configuration options for connecting to the endpoint APIs. /// The content finder factory to supply to the API client. /// The file store to use. + /// The logger factory to use. /// A string localizer. public TableauApiEndpointBase(IServiceScopeFactory serviceScopeFactory, ITableauApiEndpointConfiguration config, IContentReferenceFinderFactory finderFactory, - IContentFileStore fileStore, + IContentFileStore fileStore, + ILoggerFactory loggerFactory, ISharedResourcesLocalizer localizer) { EndpointScope = serviceScopeFactory.CreateAsyncScope(); @@ -88,6 +95,7 @@ public TableauApiEndpointBase(IServiceScopeFactory serviceScopeFactory, var apiClientFactory = EndpointScope.ServiceProvider.GetRequiredService(); ServerApi = apiClientFactory.Initialize(config.SiteConnectionConfiguration, finderFactory, fileStore); + _loggerFactory = loggerFactory; _localizer = localizer; } @@ -114,6 +122,12 @@ public async ValueTask DisposeAsync() public async Task InitializeAsync(CancellationToken cancel) { _signInResult = await ServerApi.SignInAsync(cancel).ConfigureAwait(false); + + if (_signInResult.Success) + { + _contentClientFactory = new ApiContentClientFactory(SiteApi, _loggerFactory, _localizer); + } + return _signInResult; } @@ -152,6 +166,39 @@ public async Task>> ListConnectionsAsync + public IContentClient GetContentClient() + { + if (_contentClientFactory is null) + { + throw new InvalidOperationException(_localizer[SharedResourceKeys.ApiEndpointNotInitializedError]); + } + + return _contentClientFactory.GetContentClient(); + } + + #endregion + + + /// + public async Task> RetrieveKeychainsAsync( + Guid contentItemId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + where TContent : IWithEmbeddedCredentials + { + var apiClient = SiteApi.GetEmbeddedCredentialsApiClient(); + return await apiClient.EmbeddedCredentials.RetrieveKeychainAsync(contentItemId, destinationSiteInfo, cancel).ConfigureAwait(false); + } + + /// + public async Task> RetrieveUserSavedCredentialsAsync( + Guid userId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + { + return await SiteApi.Users.RetrieveUserSavedCredentialsAsync(userId, destinationSiteInfo, cancel).ConfigureAwait(false); + } } } diff --git a/src/Tableau.Migration/Engine/Endpoints/TableauApiSourceEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/TableauApiSourceEndpoint.cs index e3906ba7..1b8420fb 100644 --- a/src/Tableau.Migration/Engine/Endpoints/TableauApiSourceEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/TableauApiSourceEndpoint.cs @@ -18,6 +18,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Tableau.Migration.Content.Files; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Resources; @@ -36,20 +37,22 @@ public class TableauApiSourceEndpoint : TableauApiEndpointBase, ISourceApiEndpoi /// The configuration options for connecting to the source endpoint APIs. /// A source manifest finder factory. /// The file store to use. + /// The logger factory to use. /// A string localizer. public TableauApiSourceEndpoint(IServiceScopeFactory serviceScopeFactory, ITableauApiEndpointConfiguration config, ISourceContentReferenceFinderFactory finderFactory, IContentFileStore fileStore, + ILoggerFactory loggerFactory, ISharedResourcesLocalizer localizer) - : base(serviceScopeFactory, config, finderFactory, fileStore, localizer) + : base(serviceScopeFactory, config, finderFactory, fileStore, loggerFactory, localizer) { } /// - public async Task> PullAsync(TContent contentItem, CancellationToken cancel) - where TPublish : class + public async Task> PullAsync(TContent contentItem, CancellationToken cancel) + where TPrepare : class { - var apiClient = SiteApi.GetPullApiClient(); + var apiClient = SiteApi.GetPullApiClient(); return await apiClient.PullAsync(contentItem, cancel).ConfigureAwait(false); } } diff --git a/src/Tableau.Migration/Engine/Hooks/ActionCompleted/SubscriptionsEnabledActionCompletedHook.cs b/src/Tableau.Migration/Engine/Hooks/ActionCompleted/SubscriptionsEnabledActionCompletedHook.cs new file mode 100644 index 00000000..a0596ba1 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/ActionCompleted/SubscriptionsEnabledActionCompletedHook.cs @@ -0,0 +1,71 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Pipelines; + +namespace Tableau.Migration.Engine.Hooks.ActionCompleted +{ + /// + /// This hook checks if subscriptions are enabled in case the check did not already run. + /// + public class SubscriptionsEnabledActionCompletedHook : IMigrationActionCompletedHook + { + private IMigrationPipelineRunner _pipelineRunner; + private readonly ISubscriptionsCapabilityManager _subscriptionsCapabilityManager; + + /// + /// The default constructor. + /// + /// The pipeline runner. + /// The subscriptions capability manager. + public SubscriptionsEnabledActionCompletedHook( + IMigrationPipelineRunner pipelineRunner, + ISubscriptionsCapabilityManager subscriptionsCapabilityManager) + { + _pipelineRunner = pipelineRunner; + _subscriptionsCapabilityManager = subscriptionsCapabilityManager; + } + + /// + public async Task ExecuteAsync(IMigrationActionResult ctx, CancellationToken cancel) + { + var action = _pipelineRunner.CurrentAction; + if (action is not MigrateContentAction) + { + return ctx; + } + + if (_subscriptionsCapabilityManager.IsMigrationCapabilityDisabled()) + { + return ctx; + } + + var result = await _subscriptionsCapabilityManager.SetMigrationCapabilityAsync(cancel).ConfigureAwait(false); + + if (result.Success) + { + return ctx; + } + + return MigrationActionResult.Failed(result.Errors); + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/EmbeddedCredentialsCapabilityManager.cs b/src/Tableau.Migration/Engine/Hooks/EmbeddedCredentialsCapabilityManager.cs new file mode 100644 index 00000000..8d1ea264 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/EmbeddedCredentialsCapabilityManager.cs @@ -0,0 +1,148 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks +{ + internal class EmbeddedCredentialsCapabilityManager : MigrationCapabilityManagerBase, IEmbeddedCredentialsCapabilityManager + { + private readonly IMigration _migration; + private readonly ITableauApiEndpointConfiguration? _destinationConfig; //Null if the destination is not an API. + + public EmbeddedCredentialsCapabilityManager( + ISharedResourcesLocalizer localizer, + ILogger logger, + IMigrationCapabilitiesEditor capabilitiesEditor, + IMigration migration) : base(localizer, logger, capabilitiesEditor) + { + _migration = migration; + _destinationConfig = migration.Plan.Destination as ITableauApiEndpointConfiguration; + } + + /// + public override bool IsMigrationCapabilityDisabled() => CapabilitiesEditor.EmbeddedCredentialsDisabled; + + /// + public override async Task SetMigrationCapabilityAsync(CancellationToken cancel) + { + // Get the destination info which needs to be passed to the source for retrieving the keychain. + var destinationSiteInfoResult = await GetDestinationSiteInfoAsync(cancel).ConfigureAwait(false); + + if (!destinationSiteInfoResult.Success) + { + return Result.Failed(destinationSiteInfoResult.Errors); + } + + // Retrieve a fake keychain so we can check the error code + var retrieveKeyChainResult = await RetrieveKeychainAsync(destinationSiteInfoResult.Value, cancel).ConfigureAwait(false); + + // No error code means embedded creds work. This should never happen as we're getting a fake keychain. + if (retrieveKeyChainResult.Success) + { + CapabilitiesEditor.EmbeddedCredentialsDisabled = false; + return Result.Succeeded(); + } + + // Check the error codes for the embedded credential migration disabled code. + // + // At this point, the retrieveKeychainResult should have failed with the error code, + // either because embedded creds are not supported, or if it is supported, then the fake keychain shouldn't exist + CapabilitiesEditor.EmbeddedCredentialsDisabled = IsEmbeddedCredentialMigrationDisabled(retrieveKeyChainResult); + + if (CapabilitiesEditor.EmbeddedCredentialsDisabled) + { + LogCapabilityDisabled("Embedded Credentials", Localizer[SharedResourceKeys.EmbeddedCredsDisabledReason]); + } + + return Result.Succeeded(); + } + + private async Task> GetDestinationSiteInfoAsync(CancellationToken cancel) + { + if (_destinationConfig == null) + { + var configNullError = new InvalidOperationException(Localizer[SharedResourceKeys.DestinationEndpointNotAnApiMsg]); + + return (IResult)Result.FromErrors([configNullError]); + } + + var sessionInfoResult = await _migration + .Destination + .GetSessionAsync(cancel) + .ConfigureAwait(false); + + if (!sessionInfoResult.Success) + { + return sessionInfoResult.CastFailure(); + } + + var connectionConfig = _destinationConfig.SiteConnectionConfiguration; + + return Result.Succeeded( + new DestinationSiteInfo( + connectionConfig.SiteContentUrl, + sessionInfoResult.Value.Site.Id, + connectionConfig.ServerUrl.AbsoluteUri)); + } + + /// + /// Retrieves a fake keychain from the source endpoint. This is used to check if the embedded credential + /// migration is disabled, based on the return code. + /// + /// Destination Info. + /// Cancellation Token to obey. + /// Result of the fake RetrieveKeychain call. + private async Task> RetrieveKeychainAsync( + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + { + var retrieveKeychainResult = await _migration + .Source + .RetrieveKeychainsAsync(Guid.NewGuid(), destinationSiteInfo, cancel) + .ConfigureAwait(false); + + if (!retrieveKeychainResult.Success) + { + return retrieveKeychainResult.CastFailure(); + } + + return retrieveKeychainResult; + } + + /// + /// Checks if the embedded credential migration is disabled based on the errors returned from the keychain retrieval. + /// + /// + /// + private static bool IsEmbeddedCredentialMigrationDisabled(IResult retrieveKeyChainResult) + => retrieveKeyChainResult.Errors + .Where(e => e is RestException) + .Select(e => e as RestException) + .Any(e => RestErrorCodes.Equals(e?.Code, RestErrorCodes.FEATURE_DISABLED)); + + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs index 0810c055..db375da1 100644 --- a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs +++ b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs @@ -68,23 +68,6 @@ public ContentFilterBase( if (!Disabled) { result = unfilteredItems.Where(ShouldMigrate); - - // Log the filtered items if requested - if (Logger is not null && Localizer is not null) - { - // Don't do the work if the logger is not enabled for this level - var filteredItems = unfilteredItems.Except(result).ToList(); - if (filteredItems.Count() > 0) - { - foreach (var filteredItem in filteredItems) - { - Logger.LogDebug( - Localizer[SharedResourceKeys.ContentFilterBaseDebugMessage], - _typeName, - filteredItem.SourceItem.ToStringForLog()); - } - } - } } return Task.FromResult((IEnumerable>?)result); diff --git a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterRunner.cs b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterRunner.cs index 33123df8..1e5b98c2 100644 --- a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterRunner.cs +++ b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterRunner.cs @@ -18,26 +18,56 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Hooks.Filters { internal class ContentFilterRunner : MigrationHookRunnerBase, IContentFilterRunner { + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger _logger; + /// /// Default constructor for this class. /// /// Migration plan used to run the filters. /// Service provider context to resolve the filters used by the runner. - public ContentFilterRunner(IMigrationPlan plan, IServiceProvider services) : base(plan, services) - { } + /// String localizer. + /// Default logger. + public ContentFilterRunner( + IMigrationPlan plan, + IServiceProvider services, + ISharedResourcesLocalizer localizer, + ILogger logger) : base(plan, services) + { + _localizer = localizer; + _logger = logger; + } public async Task>> ExecuteAsync(IEnumerable> context, CancellationToken cancel) where TContent : IContentReference - => await ExecuteAsync, IEnumerable>>(context, cancel).ConfigureAwait(false); + => await ExecuteAsync, IEnumerable>>(context, AfterHookAction, cancel).ConfigureAwait(false); protected sealed override ImmutableArray GetFactoryCollection() => Plan.Filters.GetHooks(); + + protected void AfterHookAction(string hookName, IEnumerable> inContext, IEnumerable> outContext) + where TContent : IContentReference + { + var filteredItems = inContext.Except(outContext).ToList(); + foreach (var filteredItem in filteredItems) + { + filteredItem.ManifestEntry.SetSkipped(hookName); + + _logger.LogDebug( + _localizer[SharedResourceKeys.ContentFilterBaseDebugMessage], + hookName, + filteredItem.SourceItem.ToStringForLog()); + } + } } } diff --git a/src/Tableau.Migration/Engine/Hooks/IEmbeddedCredentialsCapabilityManager.cs b/src/Tableau.Migration/Engine/Hooks/IEmbeddedCredentialsCapabilityManager.cs new file mode 100644 index 00000000..6d5f4750 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/IEmbeddedCredentialsCapabilityManager.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Engine.Hooks +{ + /// + /// Manages capabilities related to embedded credentials. + /// + internal interface IEmbeddedCredentialsCapabilityManager : IMigrationCapabilityManager + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs b/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs index 9aaeee31..26c5f100 100644 --- a/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs +++ b/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs @@ -16,6 +16,7 @@ // using System; +using System.Collections.Generic; namespace Tableau.Migration.Engine.Hooks { @@ -34,6 +35,6 @@ public interface IInitializeMigrationHookResult : IResult /// /// The errors that caused the failure. /// The new object. - IInitializeMigrationHookResult ToFailure(params Exception[] errors); + IInitializeMigrationHookResult ToFailure(params IEnumerable errors); } } \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/IMigrationCapabilityManager.cs b/src/Tableau.Migration/Engine/Hooks/IMigrationCapabilityManager.cs new file mode 100644 index 00000000..f5bc45b5 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/IMigrationCapabilityManager.cs @@ -0,0 +1,42 @@ + +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; + +namespace Tableau.Migration.Engine.Hooks +{ + /// + /// Manages capabilities related to content types or post publish operations. + /// + public interface IMigrationCapabilityManager + { + /// + /// Returns whether migration capability is disabled. + /// + /// True when the migration capability is disabled. + bool IsMigrationCapabilityDisabled(); + + /// + /// Sets the migration capability. + /// + /// The cancellation token to obey. + /// Success or failure result. + Task SetMigrationCapabilityAsync(CancellationToken cancel); + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/IMigrationHookRunner.cs b/src/Tableau.Migration/Engine/Hooks/IMigrationHookRunner.cs index acd12343..9d8d27ab 100644 --- a/src/Tableau.Migration/Engine/Hooks/IMigrationHookRunner.cs +++ b/src/Tableau.Migration/Engine/Hooks/IMigrationHookRunner.cs @@ -15,6 +15,7 @@ // limitations under the License. // +using System; using System.Threading; using System.Threading.Tasks; @@ -35,5 +36,30 @@ public interface IMigrationHookRunner /// The result context returned by the last hook. Task ExecuteAsync(TContext context, CancellationToken cancel) where THook : IMigrationHook; + + /// + /// Executes all hooks for the hook type in order. + /// + /// The hook type. + /// The hook context type. + /// The context to pass to the first hook. + /// + /// Optional delegate to perform action after each hook is executed. + /// + /// + /// string: The hook name. + /// + /// + /// TContext: The original context before the hook runs. + /// + /// + /// TContext: The modified context after the hook runs. + /// + /// + /// + /// + /// The result context returned by the last hook. + Task ExecuteAsync(TContext context, Action? afterHookAction, CancellationToken cancel) + where THook : IMigrationHook; } } diff --git a/src/Tableau.Migration/Engine/Hooks/ISubscriptionsCapabilityManager.cs b/src/Tableau.Migration/Engine/Hooks/ISubscriptionsCapabilityManager.cs new file mode 100644 index 00000000..3b268537 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/ISubscriptionsCapabilityManager.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Engine.Hooks +{ + /// + /// Manages capabilities related to subscriptions. + /// + public interface ISubscriptionsCapabilityManager : IMigrationCapabilityManager + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/InitializeMigration/Default/EmbeddedCredentialsPreflightCheck.cs b/src/Tableau.Migration/Engine/Hooks/InitializeMigration/Default/EmbeddedCredentialsPreflightCheck.cs new file mode 100644 index 00000000..4ef0d333 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/InitializeMigration/Default/EmbeddedCredentialsPreflightCheck.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.InitializeMigration.Default +{ + internal class EmbeddedCredentialsPreflightCheck : InitializeMigrationCapabilityHookBase + { + private readonly IEmbeddedCredentialsCapabilityManager _capabilityManager; + + public EmbeddedCredentialsPreflightCheck( + ISharedResourcesLocalizer? localizer, + ILogger? logger, + IMigrationCapabilitiesEditor capabilities, + IEmbeddedCredentialsCapabilityManager capabilityManager) : base(localizer, logger, capabilities) + { + _capabilityManager = capabilityManager; + } + + public override async Task ExecuteCheckAsync(IInitializeMigrationHookResult ctx, CancellationToken cancel) + { + var capabilitySetResult = await _capabilityManager.SetMigrationCapabilityAsync(cancel).ConfigureAwait(false); + + if (!capabilitySetResult.Success) + { + return ctx.ToFailure(capabilitySetResult.Errors); + } + + return ctx; + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/InitializeMigration/Default/PreflightCheck.cs b/src/Tableau.Migration/Engine/Hooks/InitializeMigration/Default/PreflightCheck.cs new file mode 100644 index 00000000..e7ae405c --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/InitializeMigration/Default/PreflightCheck.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.InitializeMigration.Default +{ + internal class PreflightCheck + : InitializeMigrationCapabilityHookBase + { + public PreflightCheck( + ISharedResourcesLocalizer? localizer, + ILogger? logger, + IMigrationCapabilitiesEditor capabilities) : base(localizer, logger, capabilities) + { } + + public override Task ExecuteCheckAsync(IInitializeMigrationHookResult ctx, CancellationToken cancel) + { + Capabilities.PreflightCheckExecuted = true; + + return Task.FromResult(ctx); + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/InitializeMigration/InitializeMigrationCapabilityHookBase.cs b/src/Tableau.Migration/Engine/Hooks/InitializeMigration/InitializeMigrationCapabilityHookBase.cs new file mode 100644 index 00000000..5867c511 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/InitializeMigration/InitializeMigrationCapabilityHookBase.cs @@ -0,0 +1,112 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.InitializeMigration +{ + internal abstract class InitializeMigrationCapabilityHookBase : IInitializeMigrationHook + { + private readonly string _typeName; + private readonly PropertyInfo[] _capabilitiesProperties; + + protected readonly ISharedResourcesLocalizer? Localizer; + protected readonly ILogger? Logger; + + protected readonly IMigrationCapabilitiesEditor Capabilities; + + public InitializeMigrationCapabilityHookBase( + ISharedResourcesLocalizer? localizer, + ILogger? logger, + IMigrationCapabilitiesEditor capabilities) + { + Localizer = localizer; + Logger = logger; + Capabilities = capabilities; + + _typeName = GetType().GetFormattedName(); + _capabilitiesProperties = typeof(MigrationCapabilities).GetProperties(BindingFlags.Public | BindingFlags.Instance); + } + + /// + public async Task ExecuteAsync(IInitializeMigrationHookResult ctx, CancellationToken cancel) + { + // Deep copy the Capabilities + IMigrationCapabilities capabilitiesOriginal = Capabilities.Clone(); + + var ret = await ExecuteCheckAsync(ctx, cancel).ConfigureAwait(false); + + // Log changes + LogChangedCapabilities(capabilitiesOriginal); + + return ret; + } + + /// + /// Logs the differences between the original and current capabilities. + /// + /// + private void LogChangedCapabilities(IMigrationCapabilities original) + { + // No logger passed in, skip all this + if (Logger is null || Localizer is null) + { + return; + } + + bool anyChanges = false; + + foreach (var property in _capabilitiesProperties) + { + var originalValue = property.GetValue(original); + var currentValue = property.GetValue(Capabilities); + + if (!Equals(originalValue, currentValue)) + { + Logger.LogDebug( + Localizer[SharedResourceKeys.InitializeMigrationBaseDebugMessage], + _typeName, + property.Name, + originalValue, + currentValue); + anyChanges = true; + } + } + + if (!anyChanges) + { + Logger.LogDebug(Localizer[SharedResourceKeys.InitializeMigrationBaseNoChangesMessage], _typeName); + } + } + + /// + /// Executes a check for capabilities. + /// + /// The input context from the migration engine or previous hook. + /// The cancellation token to obey. + /// + /// A task to await containing the context, + /// potentially modified to pass on to the next hook or migration engine, + /// or null to continue passing the same context as . + /// + public abstract Task ExecuteCheckAsync(IInitializeMigrationHookResult ctx, CancellationToken cancel); + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs b/src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs index 306e7998..d5233f8f 100644 --- a/src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs +++ b/src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs @@ -16,6 +16,7 @@ // using System; +using System.Collections.Generic; using System.Linq; namespace Tableau.Migration.Engine.Hooks @@ -24,7 +25,7 @@ internal record InitializeMigrationHookResult : Result, IInitializeMigrationHook { public IServiceProvider ScopedServices { get; } - protected InitializeMigrationHookResult(bool success, IServiceProvider scopedServices, params Exception[] errors) + protected InitializeMigrationHookResult(bool success, IServiceProvider scopedServices, params IEnumerable errors) : base(success, errors) { ScopedServices = scopedServices; @@ -32,7 +33,7 @@ protected InitializeMigrationHookResult(bool success, IServiceProvider scopedSer public static InitializeMigrationHookResult Succeeded(IServiceProvider scopedServices) => new(true, scopedServices); - public IInitializeMigrationHookResult ToFailure(params Exception[] errors) - => new InitializeMigrationHookResult(false, ScopedServices, Errors.Concat(errors).ToArray()); + public IInitializeMigrationHookResult ToFailure(params IEnumerable errors) + => new InitializeMigrationHookResult(false, ScopedServices, Errors.Concat(errors)); } } \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBase.cs b/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBase.cs index b57ec5dc..f42cc24f 100644 --- a/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBase.cs +++ b/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBase.cs @@ -42,7 +42,7 @@ public ContentMappingBase( { Localizer = localizer; Logger = logger; - _typeName = GetType().Name; + _typeName = GetType().GetFormattedName(); } /// @@ -57,20 +57,7 @@ public ContentMappingBase( /// public async Task?> ExecuteAsync(ContentMappingContext ctx, CancellationToken cancel) - { - var ret = await MapAsync(ctx, cancel).ConfigureAwait(false); - - if (Logger is not null && Localizer is not null) - { - Logger.LogDebug( - Localizer[SharedResourceKeys.ContentMappingBaseDebugMessage], - _typeName, - ctx.ContentItem.ToStringForLog(), - ctx.MappedLocation); - } - - return ret; - } + => await MapAsync(ctx, cancel).ConfigureAwait(false); /// /// Executes the mapping. diff --git a/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingRunner.cs b/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingRunner.cs index c76920a9..babecdcd 100644 --- a/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingRunner.cs +++ b/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingRunner.cs @@ -19,27 +19,55 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Hooks.Mappings { internal class ContentMappingRunner : MigrationHookRunnerBase, IContentMappingRunner { + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger _logger; + /// /// Default constructor for this class. /// /// Migration plan used to run the mappings. /// Service provider context to resolve the mappings used by the runner. - public ContentMappingRunner(IMigrationPlan plan, IServiceProvider services) + /// String localizer. + /// Default logger. + public ContentMappingRunner( + IMigrationPlan plan, + IServiceProvider services, + ISharedResourcesLocalizer localizer, + ILogger logger + ) : base(plan, services) - { } + { + _localizer = localizer; + _logger = logger; + } /// public async Task> ExecuteAsync(ContentMappingContext location, CancellationToken cancel) where TContent : IContentReference - => await ExecuteAsync, ContentMappingContext>(location, cancel).ConfigureAwait(false); + => await ExecuteAsync, ContentMappingContext>(location, LogMappingAction, cancel).ConfigureAwait(false); protected sealed override ImmutableArray GetFactoryCollection() => Plan.Mappings.GetHooks(); + + protected void LogMappingAction(string hookName, ContentMappingContext inLocation, ContentMappingContext outLocation) + where TContent : IContentReference + { + if (inLocation.MappedLocation != outLocation.MappedLocation) + { + _logger.LogDebug( + _localizer[SharedResourceKeys.ContentMappingBaseDebugMessage], + hookName, + outLocation.ContentItem.ToStringForLog(), + outLocation.MappedLocation); + } + } } } diff --git a/src/Tableau.Migration/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMapping.cs b/src/Tableau.Migration/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMapping.cs index 59cad939..f3e79f21 100644 --- a/src/Tableau.Migration/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMapping.cs +++ b/src/Tableau.Migration/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMapping.cs @@ -17,8 +17,10 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Tableau.Migration.Content; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Hooks.Mappings.Default { @@ -29,14 +31,24 @@ public class AuthenticationTypeDomainMapping : AuthenticationTypeDomainMappingBase { private readonly IMigrationPlanOptionsProvider _optionsProvider; + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger? _logger; /// /// Creates a new object. /// /// The options provider. - public AuthenticationTypeDomainMapping(IMigrationPlanOptionsProvider optionsProvider) + /// A string localizer. + /// Default logger. + public AuthenticationTypeDomainMapping( + IMigrationPlanOptionsProvider optionsProvider, + ISharedResourcesLocalizer localizer, + ILogger? logger + ) { _optionsProvider = optionsProvider; + _localizer = localizer; + _logger = logger; } /// @@ -52,7 +64,22 @@ public AuthenticationTypeDomainMapping(IMigrationPlanOptionsProvider ret = context.MapTo(mappedUsername); + + if (_logger is not null && _localizer is not null && ret is not null) + { + if (context.MappedLocation != ret.MappedLocation) + { + _logger.LogDebug( + _localizer[SharedResourceKeys.ContentMappingBaseDebugMessage], + GetType().Name, + ret.ContentItem.ToStringForLog(), + ret.MappedLocation); + } + } + + return ret?.ToTask() ?? Task.FromResult?>(null); } } } diff --git a/src/Tableau.Migration/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMapping.cs b/src/Tableau.Migration/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMapping.cs index c726326b..68f9f724 100644 --- a/src/Tableau.Migration/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMapping.cs +++ b/src/Tableau.Migration/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMapping.cs @@ -18,7 +18,9 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Tableau.Migration.Content; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Hooks.Mappings.Default { @@ -29,14 +31,24 @@ public class CallbackAuthenticationTypeDomainMapping : AuthenticationTypeDomainMappingBase { private readonly Func, CancellationToken, Task> _callback; + private readonly ISharedResourcesLocalizer _localizer; + ILogger? _logger; /// /// Creates a new object. /// /// The callback to invoke. - public CallbackAuthenticationTypeDomainMapping(Func, CancellationToken, Task> callback) + /// A string localizer. + /// Default logger. + public CallbackAuthenticationTypeDomainMapping( + Func, CancellationToken, Task> callback, + ISharedResourcesLocalizer localizer, + ILogger? logger + ) { _callback = callback; + _localizer = localizer; + _logger = logger; } /// @@ -56,7 +68,23 @@ public CallbackAuthenticationTypeDomainMapping(Func ret = context.MapTo(ContentLocation.ForUsername(newDomain, context.MappedLocation.Name)); + + if (_logger is not null && _localizer is not null && ret is not null) + { + if (context.MappedLocation != ret.MappedLocation) + { + _logger.LogDebug( + _localizer[SharedResourceKeys.ContentMappingBaseDebugMessage], + GetType().Name, + ret.ContentItem.ToStringForLog(), + ret.MappedLocation); + } + } + + return ret; } } } diff --git a/src/Tableau.Migration/Engine/Hooks/Mappings/Default/TableauCloudUsernameMapping.cs b/src/Tableau.Migration/Engine/Hooks/Mappings/Default/TableauCloudUsernameMapping.cs index 0dde5cc9..7858315f 100644 --- a/src/Tableau.Migration/Engine/Hooks/Mappings/Default/TableauCloudUsernameMapping.cs +++ b/src/Tableau.Migration/Engine/Hooks/Mappings/Default/TableauCloudUsernameMapping.cs @@ -17,8 +17,10 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Tableau.Migration.Content; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Hooks.Mappings.Default { @@ -29,31 +31,59 @@ public class TableauCloudUsernameMapping : ITableauCloudUsernameMapping { private readonly string _mailDomain; private readonly bool _useExistingEmail; + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger? _logger; /// /// Creates a new object. /// /// The options provider. - public TableauCloudUsernameMapping(IMigrationPlanOptionsProvider optionsProvider) + /// A string localizer. + /// Default logger. + public TableauCloudUsernameMapping( + IMigrationPlanOptionsProvider optionsProvider, + ISharedResourcesLocalizer localizer, + ILogger? logger) { var opts = optionsProvider.Get(); _mailDomain = opts.MailDomain; _useExistingEmail = opts.UseExistingEmail; + + _localizer = localizer; + _logger = logger; } /// public Task?> ExecuteAsync(ContentMappingContext ctx, CancellationToken cancel) { + ContentMappingContext ret; + if (_useExistingEmail && !string.IsNullOrWhiteSpace(ctx.ContentItem.Email)) { var emailLocation = ctx.MappedLocation.Parent().Append(ctx.ContentItem.Email); - return ctx.MapTo(emailLocation).ToTask(); + ret = ctx.MapTo(emailLocation); + } + else + { + var generatedUsername = $"{ctx.MappedLocation.Name}@{_mailDomain}"; + var generatedLocation = ctx.MappedLocation.Parent().Append(generatedUsername); + ret = ctx.MapTo(generatedLocation); + } + + if (_logger is not null && _localizer is not null && ret is not null) + { + if (ctx.MappedLocation != ret.MappedLocation) + { + _logger.LogDebug( + _localizer[SharedResourceKeys.ContentMappingBaseDebugMessage], + GetType().Name, + ret.ContentItem.ToStringForLog(), + ret.MappedLocation); + } } - var generatedUsername = $"{ctx.MappedLocation.Name}@{_mailDomain}"; - var generatedLocation = ctx.MappedLocation.Parent().Append(generatedUsername); - return ctx.MapTo(generatedLocation).ToTask(); + return ret?.ToTask() ?? Task.FromResult?>(null); } } } diff --git a/src/Tableau.Migration/Engine/Hooks/MigrationCapabilityManagerBase.cs b/src/Tableau.Migration/Engine/Hooks/MigrationCapabilityManagerBase.cs new file mode 100644 index 00000000..8303d33c --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/MigrationCapabilityManagerBase.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks +{ + internal abstract class MigrationCapabilityManagerBase : IMigrationCapabilityManager + { + protected readonly ISharedResourcesLocalizer Localizer; + protected readonly ILogger Logger; + protected readonly IMigrationCapabilitiesEditor CapabilitiesEditor; + + public MigrationCapabilityManagerBase( + ISharedResourcesLocalizer localizer, + ILogger logger, + IMigrationCapabilitiesEditor capabilitiesEditor) + { + Localizer = localizer; + Logger = logger; + CapabilitiesEditor = capabilitiesEditor; + } + + /// + public abstract bool IsMigrationCapabilityDisabled(); + + /// + public abstract Task SetMigrationCapabilityAsync(CancellationToken cancel); + + protected void LogCapabilityDisabled(string typeName, string reason) + => Logger.LogWarning(Localizer[SharedResourceKeys.MigrationDisabledWarning], typeName, reason); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/MigrationHookRunnerBase.cs b/src/Tableau.Migration/Engine/Hooks/MigrationHookRunnerBase.cs index 8802c112..441dbbd0 100644 --- a/src/Tableau.Migration/Engine/Hooks/MigrationHookRunnerBase.cs +++ b/src/Tableau.Migration/Engine/Hooks/MigrationHookRunnerBase.cs @@ -25,7 +25,7 @@ namespace Tableau.Migration.Engine.Hooks /// /// Base implementation for /// - internal abstract class MigrationHookRunnerBase + internal abstract class MigrationHookRunnerBase : IMigrationHookRunner { protected readonly IServiceProvider Services; protected readonly IMigrationPlan Plan; @@ -33,9 +33,11 @@ internal abstract class MigrationHookRunnerBase /// /// Default constructor for this base class. /// - /// - /// - protected MigrationHookRunnerBase(IMigrationPlan plan, IServiceProvider services) + /// The migration plan. + /// The service provider. + protected MigrationHookRunnerBase( + IMigrationPlan plan, + IServiceProvider services) { Plan = plan; Services = services; @@ -43,6 +45,10 @@ protected MigrationHookRunnerBase(IMigrationPlan plan, IServiceProvider services /// public async Task ExecuteAsync(TContext context, CancellationToken cancel) where THook : IMigrationHook + => await ExecuteAsync(context, null, cancel).ConfigureAwait(false); + + /// + public async Task ExecuteAsync(TContext context, Action? afterHookAction, CancellationToken cancel) where THook : IMigrationHook { var currentContext = context; @@ -53,6 +59,8 @@ public async Task ExecuteAsync(TContext context, Canc var inputContext = currentContext; currentContext = (await hook.ExecuteAsync(inputContext, cancel).ConfigureAwait(false)) ?? inputContext; + + afterHookAction?.Invoke(hook.GetType().GetFormattedName(), inputContext, currentContext); } return currentContext; diff --git a/src/Tableau.Migration/Engine/Hooks/PostPublish/Default/EmbeddedCredentialsItemPostPublishHook.cs b/src/Tableau.Migration/Engine/Hooks/PostPublish/Default/EmbeddedCredentialsItemPostPublishHook.cs new file mode 100644 index 00000000..1ffd09cc --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/PostPublish/Default/EmbeddedCredentialsItemPostPublishHook.cs @@ -0,0 +1,348 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Engine.Endpoints.Search; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.PostPublish.Default +{ + /// + /// Embedded credential content post publish hook. + /// + /// + /// + public class EmbeddedCredentialsItemPostPublishHook + : ContentItemPostPublishHookBase + where TPublish : IRequiresEmbeddedCredentialMigration, IContentReference, IConnectionsContent + where TResult : IWithEmbeddedCredentials + { + private readonly IMigration _migration; + private readonly ITableauApiEndpointConfiguration? _destinationConfig; //Null if the destination is not an API. + private readonly ILogger _logger; + private readonly ISharedResourcesLocalizer _sharedResourcesLocalizer; + private readonly IDestinationContentReferenceFinder _userContentFinder; + private readonly IUserSavedCredentialsCache _userSavedCredentialsCache; + private readonly IMigrationCapabilities _migrationCapabilities; + + /// + /// Creates a new object. + /// + /// The current migration. + /// The destination content reference finder factory. + /// The cache for user saved credentials. + /// The logger factory. + /// The . + /// The migration capabilities. + public EmbeddedCredentialsItemPostPublishHook( + IMigration migration, + IDestinationContentReferenceFinderFactory destinationFinderFactory, + IUserSavedCredentialsCache userSavedCredentialsCache, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer sharedResourcesLocalizer, + IMigrationCapabilities migrationCapabilities) + { + _migration = migration; + _userContentFinder = destinationFinderFactory.ForDestinationContentType(); + _destinationConfig = migration.Plan.Destination as ITableauApiEndpointConfiguration; + _logger = loggerFactory.CreateLogger>(); + _sharedResourcesLocalizer = sharedResourcesLocalizer; + _userSavedCredentialsCache = userSavedCredentialsCache; + _migrationCapabilities = migrationCapabilities; + } + + + /// + public override async Task?> ExecuteAsync( + ContentItemPostPublishContext ctx, + CancellationToken cancel) + { + if (_migrationCapabilities.EmbeddedCredentialsDisabled || !ctx.PublishedItem.HasEmbeddedPassword) + { + return ctx; + } + + var destinationSiteInfo = await GetDestinationSiteInfoAsync(ctx, cancel).ConfigureAwait(false); + + if (!destinationSiteInfo.Success) + { + return ctx; + } + + var retrieveKeychainResult = await RetrieveKeychainAsync(ctx, destinationSiteInfo.Value, cancel) + .ConfigureAwait(false); + + if (!retrieveKeychainResult.Success) + { + return ctx; + } + + var retrievedKeychains = retrieveKeychainResult.Value; + + if (retrievedKeychains.EncryptedKeychains.Count == 0) + { + return ctx; + } + + var keychainUserMapping = await GetKeychainUserMappingAsync(ctx, retrievedKeychains.AssociatedUserIds, cancel) + .ConfigureAwait(false); + + if (!keychainUserMapping.Success) + { + return ctx; + } + + var keychainApplied = await ApplyKeyChainAsync(ctx, retrievedKeychains.EncryptedKeychains, keychainUserMapping.Value, cancel) + .ConfigureAwait(false); + + if (!keychainApplied.Success) + { + return ctx; + } + + foreach (var item in keychainUserMapping.Value) + { + var userSavedCredsResult = await RetrieveUserSavedCredentialsAsync(ctx, item.SourceUserId, destinationSiteInfo.Value, cancel) + .ConfigureAwait(false); + + if (!userSavedCredsResult.Success) + { + return ctx; + } + + var uploadSavedCredsResult = await UploadUserSavedCredentials(ctx, item.DestinationUserId, userSavedCredsResult.Value, cancel) + .ConfigureAwait(false); + + if (!uploadSavedCredsResult.Success) + { + return ctx; + } + } + + LogManagedOAuthCredentialMigrationWarning(ctx.PublishedItem); + + return ctx; + } + + private async Task> GetDestinationSiteInfoAsync( + ContentItemPostPublishContext ctx, + CancellationToken cancel) + { + if (_destinationConfig == null) + { + var configNullError = new InvalidOperationException( + _sharedResourcesLocalizer[SharedResourceKeys.DestinationEndpointNotAnApiMsg]); + + ctx.ManifestEntry.SetFailed(configNullError); + + return (IResult)Result.FromErrors([configNullError]); + } + + var sessionInfoResult = await _migration + .Destination + .GetSessionAsync(cancel) + .ConfigureAwait(false); + + if (!sessionInfoResult.Success) + { + ctx.ManifestEntry.SetFailed(sessionInfoResult.Errors); + return sessionInfoResult.CastFailure(); + } + + var connectionConfig = _destinationConfig.SiteConnectionConfiguration; + + return Result.Succeeded( + new DestinationSiteInfo( + connectionConfig.SiteContentUrl, + sessionInfoResult.Value.Site.Id, + connectionConfig.ServerUrl.AbsoluteUri)); + } + + private async Task> RetrieveKeychainAsync( + ContentItemPostPublishContext ctx, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + { + var retrieveKeychainResult = await _migration + .Source + .RetrieveKeychainsAsync(ctx.PublishedItem.Id, destinationSiteInfo, cancel) + .ConfigureAwait(false); + + if (!retrieveKeychainResult.Success) + { + ctx.ManifestEntry.SetFailed(retrieveKeychainResult.Errors); + return retrieveKeychainResult.CastFailure(); + } + + return retrieveKeychainResult; + } + + private async Task>> GetKeychainUserMappingAsync( + ContentItemPostPublishContext ctx, + IImmutableList associatedUserIds, + CancellationToken cancel) + { + var keychainUserMapping = new List(); + + if (!associatedUserIds.Any()) + { + return Result>.Succeeded(keychainUserMapping); + } + + var userIdsWithNoDestination = new List(); + + foreach (var sourceUserId in associatedUserIds) + { + var destinationUser = await _userContentFinder.FindBySourceIdAsync(sourceUserId, cancel) + .ConfigureAwait(false); + + if (destinationUser == null) + { + userIdsWithNoDestination.Add(sourceUserId.ToString()); + continue; + } + + keychainUserMapping.Add(new KeychainUserMapping(sourceUserId, destinationUser.Id)); + } + + LogAssociatedUserIdsWithNoDestination(ctx.PublishedItem, userIdsWithNoDestination); + + return Result>.Succeeded(keychainUserMapping); + } + + private void LogAssociatedUserIdsWithNoDestination(TPublish publishedItem, List userIdsWithNoDestination) + { + if (userIdsWithNoDestination.Count == 0) + { + return; + } + + _logger.LogWarning( + _sharedResourcesLocalizer[SharedResourceKeys.OAuthCredentialMigrationUsersNotAtDestination], + publishedItem.Name, + publishedItem.ContentUrl, + string.Join(',', userIdsWithNoDestination)); + } + + private async Task ApplyKeyChainAsync( + ContentItemPostPublishContext ctx, + IImmutableList encryptedKeychains, + List keychainUserMapping, + CancellationToken cancel) + { + var applyKeychainResult = await _migration.Destination + .ApplyKeychainsAsync( + ctx.DestinationItem.Id, + new ApplyKeychainOptions(encryptedKeychains, keychainUserMapping), + cancel) + .ConfigureAwait(false); + + if (!applyKeychainResult.Success) + { + ctx.ManifestEntry.SetFailed(applyKeychainResult.Errors); + return Result.Failed(applyKeychainResult.Errors); + } + + return Result.Succeeded(); + } + + private void LogManagedOAuthCredentialMigrationWarning(TPublish publishedItem) + { + if (!publishedItem.HasEmbeddedOAuthManagedKeychain) + { + return; + } + + var connectionIds = publishedItem.Connections.Where(c => c.UseOAuthManagedKeychain == true).Select(c => c.Id.ToString()); + + if (!connectionIds.Any()) + { + return; + } + + _logger.LogWarning( + _sharedResourcesLocalizer[SharedResourceKeys.HasManagedOAuthCredentialsWarning], + publishedItem.GetType().Name, + publishedItem.Name, + publishedItem.ContentUrl, + string.Join(',', connectionIds)); + + return; + } + + private async Task UploadUserSavedCredentials( + ContentItemPostPublishContext ctx, + Guid userId, + IEmbeddedCredentialKeychainResult userSavedCreds, + CancellationToken cancel) + { + if (userSavedCreds.EncryptedKeychains.Count == 0) + { + return Result.Succeeded(); + } + + var result = await _migration + .Destination + .UploadUserSavedCredentialsAsync(userId, userSavedCreds.EncryptedKeychains, cancel) + .ConfigureAwait(false); + + if (!result.Success) + { + ctx.ManifestEntry.SetFailed(result.Errors); + return result; + } + + return Result.Succeeded(); + } + + private async Task> RetrieveUserSavedCredentialsAsync( + ContentItemPostPublishContext ctx, + Guid userId, + IDestinationSiteInfo destinationSiteInfo, + CancellationToken cancel) + { + var cachedResult = _userSavedCredentialsCache.Get(userId); + if (cachedResult != null) + { + return Result.Succeeded(cachedResult); + } + + var result = await _migration + .Source + .RetrieveUserSavedCredentialsAsync(userId, destinationSiteInfo, cancel).ConfigureAwait(false); + + if (!result.Success) + { + ctx.ManifestEntry.SetFailed(result.Errors); + return result; + } + + _userSavedCredentialsCache.AddOrUpdate(userId, result.Value); + + return result; + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/SubscriptionsCapabilityManager.cs b/src/Tableau.Migration/Engine/Hooks/SubscriptionsCapabilityManager.cs new file mode 100644 index 00000000..ed1e3874 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/SubscriptionsCapabilityManager.cs @@ -0,0 +1,163 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks +{ + internal class SubscriptionsCapabilityManager : MigrationCapabilityManagerBase, ISubscriptionsCapabilityManager + { + private const string DUMMY_SUBSCRIPTION_NAME = "Migration SDK Dummy Subscription"; + private const string DUMMY_SUBSCRIPTION_MESSAGE = "This is a dummy subscription created by the Migration SDK. You can safely delete it."; + private const string DUMMY_SCHEDULE_START_TIME = "00:00:00"; + private const string DUMMY_SCHEDULE_END_TIME = "1:00:00"; + + private readonly IDestinationEndpoint _destinationEndpoint; + private readonly IScheduleValidator _scheduleValidator; + + public SubscriptionsCapabilityManager( + IDestinationEndpoint destinationEndpoint, + IScheduleValidator scheduleValidator, + IMigrationCapabilitiesEditor capabilities, + ISharedResourcesLocalizer localizer, + ILogger logger) : base(localizer, logger, capabilities) + { + _destinationEndpoint = destinationEndpoint; + _scheduleValidator = scheduleValidator; + } + + /// + public override bool IsMigrationCapabilityDisabled() + { + return CapabilitiesEditor.ContentTypesDisabledAtDestination.Contains(typeof(IServerSubscription)); + } + + /// + public override async Task SetMigrationCapabilityAsync(CancellationToken cancel) + { + var workbook = await GetRandomWorkbook(cancel).ConfigureAwait(false); + + if (workbook is null) + { + return Result.Succeeded(); + } + + var createResult = await CreateDummySubscription(workbook, cancel).ConfigureAwait(false); + + if (createResult.Success) + { + var deleteResult = await DeleteDummySubscription(createResult.Value.Id, cancel).ConfigureAwait(false); + + if (deleteResult.Success) + { + return Result.Succeeded(); + } + + var errors = new List() + { + new($"Could not delete dummy subscription {DUMMY_SUBSCRIPTION_NAME} in {nameof(SubscriptionsCapabilityManager)}") + }; + errors.AddRange(deleteResult.Errors); + + return Result.Failed(errors); + } + + if (SubscriptionsDisabled(createResult)) + { + CapabilitiesEditor.ContentTypesDisabledAtDestination.Add(typeof(IServerSubscription)); + LogCapabilityDisabled(typeof(IServerSubscription).GetFormattedName(), Localizer[SharedResourceKeys.SubscriptionsDisabledReason]); + return Result.Succeeded(); + } + else + { + return Result.Failed(createResult.Errors); + } + } + + private async Task GetRandomWorkbook(CancellationToken cancel) + { + var destinationPager = _destinationEndpoint.GetPager(1); + + var destinationPage = await destinationPager.NextPageAsync(cancel).ConfigureAwait(false); + + var workbook = destinationPage.Value?.FirstOrDefault(); + return workbook; + } + + private async Task DeleteDummySubscription(Guid dummySubscriptionId, CancellationToken cancel) + { + return await _destinationEndpoint + .DeleteAsync(dummySubscriptionId, cancel) + .ConfigureAwait(false); + } + + private async Task> CreateDummySubscription(IWorkbook workbook, CancellationToken cancel) + { + var dummySubscription = new CloudSubscription( + id: Guid.NewGuid(), + subject: DUMMY_SUBSCRIPTION_NAME, + attachImage: true, + attachPdf: false, + pageOrientation: null, + pageSizeOption: null, + suspended: true, + message: DUMMY_SUBSCRIPTION_MESSAGE, + content: new SubscriptionContent(workbook.Id, "Workbook", false), + user: workbook.Owner, + schedule: CreateDummySchedule()); + + return await _destinationEndpoint.PublishAsync( + dummySubscription, + cancel) + .ConfigureAwait(false); + } + + private CloudSchedule CreateDummySchedule() + { + var result = new CloudSchedule( + ScheduleFrequencies.Daily, + new FrequencyDetails( + startAt: TimeOnly.Parse(DUMMY_SCHEDULE_START_TIME), + endAt: TimeOnly.Parse(DUMMY_SCHEDULE_END_TIME), + intervals: [Interval.WithHours(24), Interval.WithWeekday(WeekDays.Tuesday)])); + + _scheduleValidator.Validate(result); + + return result; + } + + private static bool SubscriptionsDisabled(IResult createSubscriptionResult) + { + return createSubscriptionResult.Errors + .Where(e => e is RestException) + .Select(e => e as RestException) + .Any(e => RestErrorCodes.Equals(e?.Code, RestErrorCodes.GENERIC_CREATE_SUBSCRIPTION_ERROR)); + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBase.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBase.cs index 01150b08..b2e2056d 100644 --- a/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBase.cs +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBase.cs @@ -42,7 +42,7 @@ public ContentTransformerBase( { Localizer = localizer; Logger = logger; - _typeName = GetType().Name; + _typeName = GetType().GetFormattedName(); } /// @@ -54,22 +54,10 @@ public ContentTransformerBase( /// Default logger. /// protected ILogger> Logger { get; } - + /// public async Task ExecuteAsync(TPublish itemToTransform, CancellationToken cancel) - { - var ret = await TransformAsync(itemToTransform, cancel).ConfigureAwait(false); - - if (Logger is not null && Localizer is not null) - { - Logger.LogDebug( - Localizer[SharedResourceKeys.ContentTransformerBaseDebugMessage], - _typeName, - itemToTransform.ToStringForLog()); - } - - return ret; - } + => await TransformAsync(itemToTransform, cancel).ConfigureAwait(false); /// /// Executes the transformation. diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerRunner.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerRunner.cs index 2457d313..b8877831 100644 --- a/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerRunner.cs +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerRunner.cs @@ -19,19 +19,48 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Hooks.Transformers { internal class ContentTransformerRunner : MigrationHookRunnerBase, IContentTransformerRunner { - public ContentTransformerRunner(IMigrationPlan plan, IServiceProvider services) - : base(plan, services) - { } + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger _logger; + + public ContentTransformerRunner( + IMigrationPlan plan, + IServiceProvider services, + ISharedResourcesLocalizer localizer, + ILogger logger) + : base(plan, services) + { + _localizer = localizer; + _logger = logger; + } public async Task ExecuteAsync(TPublish itemToTransform, CancellationToken cancel) - => await ExecuteAsync, TPublish>(itemToTransform, cancel).ConfigureAwait(false); + => await ExecuteAsync, TPublish>(itemToTransform, LogTransformationAction, cancel).ConfigureAwait(false); protected sealed override ImmutableArray GetFactoryCollection() => Plan.Transformers.GetHooks(); + + protected void LogTransformationAction(string hookName, TPublish _, TPublish outItemTransformed) + { + if (outItemTransformed is null) + return; + + IContentReference? item = outItemTransformed as IContentReference; + + if (item is not null) + { + _logger.LogDebug( + _localizer[SharedResourceKeys.ContentTransformerBaseDebugMessage], + hookName, + item.ToStringForLog()); + } + + } } } diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/SubscriptionTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/SubscriptionTransformer.cs new file mode 100644 index 00000000..dad960e5 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/SubscriptionTransformer.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Engine.Endpoints.ContentClients; +using Tableau.Migration.Engine.Endpoints.Search; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.Transformers.Default +{ + /// + /// Transformer that updates the subscription view from the source id to the destination id + /// + internal class SubscriptionTransformer : ContentTransformerBase + { + private readonly IDestinationContentReferenceFinder _destinationContentReferenceFinder; + private readonly IViewsContentClient _sourceViewClient; + private readonly IWorkbooksContentClient _destinationWorkbookClient; + + public SubscriptionTransformer( + ISourceEndpoint sourceEndpoint, + IDestinationEndpoint destinationEndpoint, + IDestinationContentReferenceFinder destinationContentReferenceFinder, + ISharedResourcesLocalizer localizer, + ILogger logger) : base(localizer, logger) + { + _destinationContentReferenceFinder = destinationContentReferenceFinder; + _sourceViewClient = (IViewsContentClient)sourceEndpoint.GetContentClient(); + _destinationWorkbookClient = (IWorkbooksContentClient)destinationEndpoint.GetContentClient(); + } + + public override async Task TransformAsync(ICloudSubscription itemToTransform, CancellationToken cancel) + { + switch (itemToTransform.Content.Type.ToLowerInvariant()) + { + case "view": + return await TransformViewSubscriptionAsync(itemToTransform, cancel).ConfigureAwait(false); + + case "workbook": + return await TransformWorkbookSubscriptionAsync(itemToTransform, cancel).ConfigureAwait(false); + + default: + throw new NotSupportedException($"Unsupported subscription content type: {itemToTransform.Content.Type}"); + } + } + + private async Task TransformViewSubscriptionAsync(ICloudSubscription itemToTransform, CancellationToken cancel) + { + // 1. Get the workbook of the source ViewID + // 2. Find the mapped location of the source workbook + // 3. Get the views of the destination (mapped) workbook + // 4. Find the view with the same name as the source view + + var sourceView = await _sourceViewClient.GetByIdAsync(itemToTransform.Content.Id, cancel).ConfigureAwait(false); + if (!sourceView.Success) + { + throw new Exception($"Unable to find source view with id {itemToTransform.Content.Id}."); + } + + // Find the destination reference for the source workbook + var destinationWorkbookReference = await _destinationContentReferenceFinder.FindBySourceIdAsync(sourceView.Value.ParentWorkbook.Id, cancel).ConfigureAwait(false) + ?? throw new Exception($"Unable to find source workbook content reference for workbook id {sourceView.Value.ParentWorkbook.Id}."); + + var destinationViews = await _destinationWorkbookClient.GetViewsForWorkbookIdAsync(destinationWorkbookReference.Id, cancel).ConfigureAwait(false); + if (!destinationViews.Success) + { + throw new Exception($"Unable to get views for destination workbook {destinationWorkbookReference.Location}"); + } + + var destinationView = destinationViews.Value.FirstOrDefault(v => v.Name == sourceView.Value.Name) + ?? throw new Exception($"Unable to find destination view with name {sourceView.Value.Name} in destination workbook {destinationWorkbookReference.Location}."); + + itemToTransform.Content.Id = destinationView.Id; + + return itemToTransform; + } + + private async Task TransformWorkbookSubscriptionAsync(ICloudSubscription itemToTransform, CancellationToken cancel) + { + var destinationWorkbookReference = await _destinationContentReferenceFinder.FindBySourceIdAsync(itemToTransform.Content.Id, cancel).ConfigureAwait(false) + ?? throw new Exception($"Unable to find destination workbook content reference for workbook id {itemToTransform.Content.Id}."); + + itemToTransform.Content.Id = destinationWorkbookReference.Id; + + return itemToTransform; + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/UserAuthenticationTypeTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/UserAuthenticationTypeTransformer.cs index d3d53df8..96f0b44d 100644 --- a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/UserAuthenticationTypeTransformer.cs +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/UserAuthenticationTypeTransformer.cs @@ -15,12 +15,16 @@ // limitations under the License. // +using System; +using System.Collections.Immutable; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Rest.Models.Types; using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Options; -using Microsoft.Extensions.Logging; using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Hooks.Transformers.Default @@ -28,32 +32,77 @@ namespace Tableau.Migration.Engine.Hooks.Transformers.Default /// /// Transformer that provides an authentication type for users, /// defaulting to . - /// See Tableau API Reference for details. + /// See Tableau API Reference for details. /// public class UserAuthenticationTypeTransformer : ContentTransformerBase { + private readonly IDestinationAuthenticationConfigurationsCache _destinationAuthConfigCache; private readonly string _authenticationType; /// /// Creates a new object. /// /// The options provider. + /// The destination authentication configuration cache. /// A string localizer. /// The default logger. public UserAuthenticationTypeTransformer( IMigrationPlanOptionsProvider optionsProvider, + IDestinationAuthenticationConfigurationsCache destinationAuthConfigCache, ISharedResourcesLocalizer localizer, ILogger logger) : base(localizer, logger) { + _destinationAuthConfigCache = destinationAuthConfigCache; _authenticationType = optionsProvider.Get().AuthenticationType; } /// - public override Task TransformAsync(IUser itemToTransform, CancellationToken cancel) + public override async Task TransformAsync(IUser itemToTransform, CancellationToken cancel) { - itemToTransform.AuthenticationType = _authenticationType; - return Task.FromResult(itemToTransform); + var configs = await _destinationAuthConfigCache.GetAllAsync(cancel).ConfigureAwait(false); + if(!configs.Any()) + { + /* + * If the site has no authentication configurations it does not support multiple authentication types. + * We directly set the auth type moniker in this case. + */ + itemToTransform.Authentication = UserAuthenticationType.ForAuthenticationType(_authenticationType); + return itemToTransform; + } + + /* + * If the site has multiple authentication configurations it supports multiple authentication types. + * We need to determine the IdP configuration ID to assign to the user. + * We match based on the UI display name first, but fall back to matching on the authSetting + * if the migration user relied on a plan build method and forgot to give us the IdP name. + */ + var configsByName = configs + .Where(c => string.Equals(_authenticationType, c.IdpConfigurationName, ContentBase.NameComparison)) + .ToImmutableArray(); + + if(configsByName.Length == 1) + { + itemToTransform.Authentication = UserAuthenticationType.ForConfigurationId(configsByName.Single().Id); + return itemToTransform; + } + + var configByAuthSetting = configs + .Where(c => string.Equals(_authenticationType, c.AuthSetting, ContentBase.NameComparison)) + .ToImmutableArray(); + + if(configByAuthSetting.Length == 1) + { + itemToTransform.Authentication = UserAuthenticationType.ForConfigurationId(configByAuthSetting.Single().Id); + return itemToTransform; + } + + if(configsByName.Length > 1 || configByAuthSetting.Length > 1) + { + throw new ArgumentException($"Multiple authentication configurations were found with name or auth setting \"{_authenticationType}\"."); + } + + throw new ArgumentException($"No authentication configurations were found with name or auth setting \"{_authenticationType}\"."); } } } diff --git a/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs index 85305c4e..83e6fcc7 100644 --- a/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -17,13 +17,24 @@ using Microsoft.Extensions.DependencyInjection; using Tableau.Migration.Api; +using Tableau.Migration.Content; using Tableau.Migration.Content.Files; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.ContentConverters.Schedules; using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Conversion; +using Tableau.Migration.Engine.Conversion.ExtractRefreshTasks; +using Tableau.Migration.Engine.Conversion.Schedules; +using Tableau.Migration.Engine.Conversion.Subscriptions; using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Hooks.ActionCompleted; using Tableau.Migration.Engine.Hooks.Filters; using Tableau.Migration.Engine.Hooks.Filters.Default; +using Tableau.Migration.Engine.Hooks.InitializeMigration.Default; using Tableau.Migration.Engine.Hooks.Mappings; using Tableau.Migration.Engine.Hooks.Mappings.Default; using Tableau.Migration.Engine.Hooks.PostPublish.Default; @@ -44,122 +55,166 @@ namespace Tableau.Migration.Engine internal static class IServiceCollectionExtensions { /// - /// Registers migration engine services. + /// Registers migration engine. /// /// The service collection to register services with. /// The same service collection for fluent API calls. - internal static IServiceCollection AddMigrationEngine(this IServiceCollection services) - { - services.AddSingleton(); - - //Bootstrap and scope state tracking services. - services.AddScoped(); - services.AddScoped(p => p.GetRequiredService()); - services.AddScoped(p => p.GetRequiredService()); - - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(p => p.GetRequiredService().Source); - services.AddScoped(p => p.GetRequiredService().Destination); - services.AddScoped(p => p.GetRequiredService().Plan); - services.AddScoped(p => p.GetRequiredService().Manifest); - services.AddScoped(p => p.GetRequiredService().Pipeline); - services.AddScoped(p => p.GetRequiredService()); - - services.AddScoped(typeof(IMigrationPlanOptionsProvider<>), typeof(MigrationPlanOptionsProvider<>)); - - //Hooks infrastructure. - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - //Plan building. - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - - //Migrators + internal static IServiceCollection AddMigrationEngine(this IServiceCollection services) => services + .AddSingleton() + .AddBoostrapServices() + .AddStateTrackingServices() + .AddScoped(typeof(IMigrationPlanOptionsProvider<>), typeof(MigrationPlanOptionsProvider<>)) + .AddHooksInfraServices() + .AddPlanBuildingServices() + .AddMigratorServices() + .AddConversionServices() + .AddSingleton() // Serializer + .AddCacheServices() + .AddContentFinderServices() + .AddPipelineServices() + .AddMigrationActionServices() + .AddSingleton() //Top-level interface . + .AddMigrationCapabilityServices() + .AddDefaultPreflightHookServices() + .AddDefaultFilterServices() + .AddDefaultMappingServices() + .AddDefaultTransformerServices() + .AddDefaultActionCompletedHookServices() + .AddDefaultPostPublishHookServices() + .AddFileStoreServices(); //Migration engine file store. + + private static IServiceCollection AddBoostrapServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped(p => p.GetRequiredService()) + .AddScoped(p => p.GetRequiredService()) + .AddScoped(); + + private static IServiceCollection AddStateTrackingServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped(p => p.GetRequiredService().Source) + .AddScoped(p => p.GetRequiredService().Destination) + .AddScoped(p => p.GetRequiredService().Plan) + .AddScoped(p => p.GetRequiredService().Manifest) + .AddScoped(p => p.GetRequiredService().Pipeline) + .AddScoped(p => p.GetRequiredService()); + + private static IServiceCollection AddHooksInfraServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddPlanBuildingServices(this IServiceCollection services) => services + .AddSingleton() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient(); + + private static IServiceCollection AddMigratorServices(this IServiceCollection services) => services + //Register concrete types so that the easy way to get interface types is through IMigrationPipeline. + .AddScoped(typeof(EndpointContentItemPreparer<,,>)) + .AddScoped(typeof(ExtractRefreshTaskServerToCloudPreparer)) + .AddScoped(typeof(SourceContentItemPreparer<>)) + .AddScoped(typeof(SourceContentItemPreparer<,>)) + .AddScoped(typeof(BulkPublishContentBatchMigrator<>)) + .AddScoped(typeof(BulkPublishContentBatchMigrator<,,>)) + .AddScoped(typeof(ItemPublishContentBatchMigrator<>)) + .AddScoped(typeof(ItemPublishContentBatchMigrator<,>)) + .AddScoped(typeof(ItemPublishContentBatchMigrator<,,>)) + .AddScoped(typeof(ItemPublishContentBatchMigrator<,,,>)) + .AddScoped(typeof(ContentMigrator<>)); + + private static IServiceCollection AddConversionServices(this IServiceCollection services) => services //Register concrete types so that the easy way to get interface types is through IMigrationPipeline. - services.AddScoped(typeof(EndpointContentItemPreparer<,>)); - services.AddScoped(typeof(ExtractRefreshTaskServerToCloudPreparer)); - services.AddScoped(typeof(SourceContentItemPreparer<>)); - services.AddScoped(typeof(BulkPublishContentBatchMigrator<>)); - services.AddScoped(typeof(BulkPublishContentBatchMigrator<,>)); - services.AddScoped(typeof(ItemPublishContentBatchMigrator<>)); - services.AddScoped(typeof(ItemPublishContentBatchMigrator<,>)); - services.AddScoped(typeof(ItemPublishContentBatchMigrator<,,>)); - services.AddScoped(typeof(ContentMigrator<>)); - - //Serializer - services.AddSingleton(); - - //Caches/Content Finders + .AddSingleton(typeof(DirectContentItemConverter<,>)) + // Schedule validators and converters + .AddSingleton, ServerScheduleValidator>() + .AddSingleton, CloudScheduleValidator>() + .AddSingleton, ServerToCloudScheduleConverter>() + .AddSingleton, ServerToCloudExtractRefreshTaskConverter>() + .AddSingleton, ServerToCloudSubscriptionConverter>(); + + private static IServiceCollection AddCacheServices(this IServiceCollection services) => services //Register concrete types so that the easy way to get interface types is through IMigrationPipeline. - services.AddScoped(typeof(BulkSourceCache<>)); - services.AddScoped(typeof(ISourceContentReferenceFinder<>), typeof(ManifestSourceContentReferenceFinder<>)); - services.AddScoped(); - - services.AddScoped(typeof(BulkDestinationCache<>)); - services.AddScoped(); - services.AddScoped(typeof(IDestinationContentReferenceFinder<>), typeof(ManifestDestinationContentReferenceFinder<>)); - services.AddScoped(); - - //Pipelines. - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - //Migration actions. - services.AddScoped(); - services.AddScoped(typeof(MigrateContentAction<>)); - - //Top-level interface services. - services.AddSingleton(); - - //Standard/default hooks. - services.AddScoped(typeof(PreviouslyMigratedFilter<>)); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(typeof(SystemOwnershipFilter<>)); - - services.AddScoped(); - services.AddScoped(); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(typeof(OwnershipTransformer<>)); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(typeof(EncryptExtractTransformer<>)); - - services.AddScoped(); - services.AddScoped(); - services.AddScoped(typeof(WorkbookReferenceTransformer<>)); - services.AddScoped(); - - services.AddScoped(typeof(OwnerItemPostPublishHook<,>)); - services.AddScoped(typeof(PermissionsItemPostPublishHook<,>)); - services.AddScoped(typeof(TagItemPostPublishHook<,>)); - services.AddScoped(); - services.AddScoped(typeof(ChildItemsPermissionsPostPublishHook<,>)); - services.AddScoped(); - - //Migration engine file store. - services.AddScoped(); - services.AddScoped(s => + .AddScoped(typeof(BulkSourceCache<>)) + .AddScoped(typeof(BulkDestinationCache<>)) + .AddScoped() + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddContentFinderServices(this IServiceCollection services) => services + .AddScoped(typeof(ISourceContentReferenceFinder<>), typeof(ManifestSourceContentReferenceFinder<>)) + .AddScoped() + .AddScoped(typeof(IDestinationContentReferenceFinder<>), typeof(ManifestDestinationContentReferenceFinder<>)) + .AddScoped(); + + private static IServiceCollection AddPipelineServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddMigrationActionServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped(typeof(MigrateContentAction<>)); + + private static IServiceCollection AddMigrationCapabilityServices(this IServiceCollection services) => services + .AddSingleton() + .AddSingleton(p => p.GetRequiredService()) + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddDefaultPreflightHookServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddDefaultFilterServices(this IServiceCollection services) => services + .AddScoped(typeof(PreviouslyMigratedFilter<>)) + .AddScoped() + .AddScoped() + .AddScoped(typeof(SystemOwnershipFilter<>)); + + private static IServiceCollection AddDefaultMappingServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddDefaultTransformerServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(typeof(OwnershipTransformer<>)) + .AddScoped() + .AddScoped() + .AddScoped(typeof(EncryptExtractTransformer<>)) + .AddScoped() + .AddScoped() + .AddScoped(typeof(WorkbookReferenceTransformer<>)) + .AddScoped() + .AddScoped(); + + private static IServiceCollection AddDefaultActionCompletedHookServices(this IServiceCollection services) => services + .AddScoped(); + + private static IServiceCollection AddDefaultPostPublishHookServices(this IServiceCollection services) => services + .AddScoped(typeof(OwnerItemPostPublishHook<,>)) + .AddScoped(typeof(PermissionsItemPostPublishHook<,>)) + .AddScoped(typeof(TagItemPostPublishHook<,>)) + .AddScoped() + .AddScoped(typeof(ChildItemsPermissionsPostPublishHook<,>)) + .AddScoped() + .AddScoped(typeof(EmbeddedCredentialsItemPostPublishHook<,>)); + + private static IServiceCollection AddFileStoreServices(this IServiceCollection services) => services + .AddScoped() + .AddScoped(s => { - /* Since this IContentFileStore factory is registerd after AddMigrationApiClient, + /* Since this IContentFileStore factory is registered after AddMigrationApiClient, * the factory might be running in the main migration DI scope * (which should create a single scoped file store for the migration), * or in one of the endpoint API client DI scopes - * (which should mimic AddMigrationApiClient factory's behavior of using the ApiClientInput overriden value). + * (which should mimic AddMigrationApiClient factory's behavior of using the ApiClientInput overridden value). * * We look at IApiClientInputInitializer.IsInitialized to determine what scope we are in. */ @@ -171,8 +226,5 @@ internal static IServiceCollection AddMigrationEngine(this IServiceCollection se return new EncryptedFileStore(s, s.GetRequiredService()); }); - - return services; - } } } diff --git a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEditor.cs b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEditor.cs index 49916bac..321a1c60 100644 --- a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEditor.cs +++ b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEditor.cs @@ -36,7 +36,7 @@ public interface IMigrationManifestEditor : IMigrationManifest /// /// The results to add errors from. /// This manifest editor, for fluent API usage. - public IMigrationManifestEditor AddErrors(IEnumerable results) + public IMigrationManifestEditor AddErrors(params IEnumerable results) { foreach (var result in results) { @@ -58,13 +58,13 @@ public IMigrationManifestEditor AddErrors(IEnumerable results) /// /// The errors to add. /// This manifest editor, for fluent API usage. - IMigrationManifestEditor AddErrors(IEnumerable errors); + IMigrationManifestEditor AddErrors(params IEnumerable errors); /// /// Adds top-level errors that are not related to any Tableau content item. /// /// The errors to add. /// This manifest editor, for fluent API usage. - IMigrationManifestEditor AddErrors(params Exception[] errors); + IMigrationManifestEditor AddErrors(params Exception[] errors); //Overload for Python interop. } } diff --git a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntry.cs b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntry.cs index 0941a220..0fa2bf32 100644 --- a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntry.cs +++ b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntry.cs @@ -59,5 +59,10 @@ public interface IMigrationManifestEntry : IEquatable /// Gets errors that occurred while migrating the content item. /// IReadOnlyList Errors { get; } + + /// + /// Gets the reason why the content item was skipped, if applicable. + /// + string SkippedReason { get; } } } diff --git a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs index 263f3502..6e838d19 100644 --- a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs +++ b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs @@ -51,22 +51,16 @@ public interface IMigrationManifestEntryEditor : IMigrationManifestEntry /// /// Sets the entry to skipped status. /// + /// Reason this item was skipped. Generally the skipped filter name. /// The current entry editor, for fluent API usage. - IMigrationManifestEntryEditor SetSkipped(); + IMigrationManifestEntryEditor SetSkipped(string? skippedReason); /// /// Sets the entry to failed status and adding errors to the entry. /// /// The errors to add to the entry. /// The current entry editor, for fluent API usage. - IMigrationManifestEntryEditor SetFailed(IEnumerable errors); - - /// - /// Sets the entry to failed status and adding errors to the entry. - /// - /// The errors to add to the entry. - /// The current entry editor, for fluent API usage. - IMigrationManifestEntryEditor SetFailed(params Exception[] errors); + IMigrationManifestEntryEditor SetFailed(params IEnumerable errors); /// /// Sets the entry to canceled status. diff --git a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestFactory.cs b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestFactory.cs index 57f484f8..de53467a 100644 --- a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestFactory.cs +++ b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestFactory.cs @@ -37,7 +37,8 @@ public interface IMigrationManifestFactory /// /// The unique ID of the that the migration is running. /// The unique ID of the to include in the manifest. + /// The pipeline profile to use for the migration. /// The created object. - IMigrationManifestEditor Create(Guid planId, Guid migrationId); + IMigrationManifestEditor Create(Guid planId, Guid migrationId, PipelineProfile pipelineProfile); } } diff --git a/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifest.cs b/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifest.cs new file mode 100644 index 00000000..50f5a108 --- /dev/null +++ b/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifest.cs @@ -0,0 +1,93 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Manifest.Logging +{ + /// + /// Migration manifest that writes log entries as the manifest is manipulated. + /// + public class LoggingMigrationManifest : MigrationManifest + { + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger _logger; + + /// + /// Creates a new object. + /// + /// The localizer. + /// The logger factory. + /// + /// + /// + /// An optional manifest to copy entries from. + /// Null will initialize the manifest with an empty set of entries. + /// Top-level information is not copied. + /// + public LoggingMigrationManifest( + ISharedResourcesLocalizer localizer, + ILoggerFactory loggerFactory, + Guid planId, + Guid migrationId, + IMigrationManifest copyEntriesManifest) + : base(planId, migrationId, copyEntriesManifest.PipelineProfile, copyEntriesManifest) + { + _localizer = localizer; + _logger = loggerFactory.CreateLogger(); + } + + /// + /// Creates a new object. + /// + /// The localizer. + /// The logger factory. + /// + /// + /// + public LoggingMigrationManifest( + ISharedResourcesLocalizer localizer, + ILoggerFactory loggerFactory, + Guid planId, + Guid migrationId, + PipelineProfile pipelineProfile + ) + : base(planId, migrationId, pipelineProfile) + { + _localizer = localizer; + _logger = loggerFactory.CreateLogger(); + } + + /// + public override IMigrationManifestEditor AddErrors(params IEnumerable errors) + { + foreach (var error in errors) + { + _logger.LogError(_localizer[SharedResourceKeys.MigrationErrorLogMessage], error); + } + + return base.AddErrors(errors); + } + + /// + public override IMigrationManifestEditor AddErrors(params Exception[] errors) + => AddErrors((IEnumerable)errors); //Overload for Python interop. + } +} diff --git a/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifestContentTypePartition.cs b/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifestContentTypePartition.cs new file mode 100644 index 00000000..9f0f330e --- /dev/null +++ b/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifestContentTypePartition.cs @@ -0,0 +1,54 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Manifest.Logging +{ + /// + /// that writes log entries as the manifest is manipulated. + /// + public class LoggingMigrationManifestContentTypePartition : MigrationManifestContentTypePartition + { + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger _logger; + + /// + /// Creates a new object. + /// + /// The content type the partition holds manifest entries for. + /// The localizer. + /// The logger. + public LoggingMigrationManifestContentTypePartition(Type type, ISharedResourcesLocalizer localizer, ILogger logger) + : base(type) + { + _localizer = localizer; + _logger = logger; + } + + /// + public override void MigrationFailed(IMigrationManifestEntryEditor entry) + { + foreach (var error in entry.Errors) + { + _logger.LogError(_localizer[SharedResourceKeys.MigrationItemErrorLogMessage], ContentType, entry.Source.Location, error, error.Data.GetContentsAsString()); + } + } + } +} diff --git a/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifestEntryCollection.cs b/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifestEntryCollection.cs new file mode 100644 index 00000000..3ac7b58d --- /dev/null +++ b/src/Tableau.Migration/Engine/Manifest/Logging/LoggingMigrationManifestEntryCollection.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Manifest.Logging +{ + /// + /// that writes log entries as the manifest is manipulated. + /// + public class LoggingMigrationManifestEntryCollection : MigrationManifestEntryCollection + { + private readonly ISharedResourcesLocalizer _localizer; + private readonly ILogger _partitionLogger; + + /// + /// Creates a new object. + /// + /// The localizer. + /// The logger factory. + /// An optional collection to deep copy entries from. + public LoggingMigrationManifestEntryCollection(ISharedResourcesLocalizer localizer, ILoggerFactory loggerFactory, IMigrationManifestEntryCollection? copy = null) + : base(copy) + { + _localizer = localizer; + _partitionLogger = loggerFactory.CreateLogger(); + } + + /// + protected override MigrationManifestContentTypePartition CreateParition(Type contentType) + => new LoggingMigrationManifestContentTypePartition(contentType, _localizer, _partitionLogger); + } +} diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs index 359e3127..34545f3a 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -18,8 +18,6 @@ using System; using System.Collections.Generic; using System.Linq; -using Microsoft.Extensions.Logging; -using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Manifest { @@ -28,10 +26,6 @@ namespace Tableau.Migration.Engine.Manifest /// public class MigrationManifest : IMigrationManifestEditor { - private readonly ISharedResourcesLocalizer _localizer; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; - private readonly MigrationManifestEntryCollection _entries; private readonly List _errors; @@ -47,28 +41,52 @@ public class MigrationManifest : IMigrationManifestEditor /// /// Creates a new object. /// - /// A localizer. - /// A logger factory; + /// + /// An optional manifest to copy entries from. + /// Null will initialize the manifest with an empty set of entries. + /// Top-level information is not copied. + /// + public MigrationManifest(IMigrationManifest copyEntriesManifest) + : this(Guid.NewGuid(), Guid.NewGuid(), copyEntriesManifest.PipelineProfile, copyEntriesManifest) + { } + + /// + /// Creates a new object. + /// + /// + public MigrationManifest(PipelineProfile pipelineProfile) + : this(Guid.NewGuid(), Guid.NewGuid(), pipelineProfile) + { } + + /// + /// Creates a new object. + /// /// /// + /// /// /// An optional manifest to copy entries from. /// Null will initialize the manifest with an empty set of entries. /// Top-level information is not copied. /// - public MigrationManifest(ISharedResourcesLocalizer localizer, ILoggerFactory loggerFactory, - Guid planId, Guid migrationId, IMigrationManifest? copyEntriesManifest = null) + public MigrationManifest(Guid planId, Guid migrationId, PipelineProfile pipelineProfile, IMigrationManifest? copyEntriesManifest = null) { _errors = new(); - _localizer = localizer; - _loggerFactory = loggerFactory; - _logger = loggerFactory.CreateLogger(); - PlanId = planId; MigrationId = migrationId; - _entries = new MigrationManifestEntryCollection(_localizer, _loggerFactory, copyEntriesManifest?.Entries); + if (copyEntriesManifest != null) + { + if (copyEntriesManifest.PipelineProfile != pipelineProfile) + { + throw new ArgumentException($"PipelineProfile must match the {nameof(copyEntriesManifest)}'s PipelineProfile"); + } + } + + PipelineProfile = pipelineProfile; + + _entries = new(copyEntriesManifest?.Entries); } #region - IMigrationManifest Implementation - @@ -79,6 +97,9 @@ public MigrationManifest(ISharedResourcesLocalizer localizer, ILoggerFactory log /// public Guid MigrationId { get; } + /// + public PipelineProfile PipelineProfile { get; } + /// public virtual uint ManifestVersion => LatestManifestVersion; @@ -100,6 +121,7 @@ public bool Equals(IMigrationManifest? other) var equal = PlanId.Equals(other.PlanId) && MigrationId.Equals(other.MigrationId) && + PipelineProfile.Equals(other.PipelineProfile) && ManifestVersion.Equals(other.ManifestVersion) && Entries.Equals(other.Entries) && Errors.SequenceEqual(other.Errors, new ExceptionComparer()); @@ -154,21 +176,15 @@ public override int GetHashCode() public virtual IMigrationManifestEntryCollectionEditor Entries => _entries; /// - public IMigrationManifestEditor AddErrors(IEnumerable errors) + public virtual IMigrationManifestEditor AddErrors(params IEnumerable errors) { _errors.AddRange(errors); - - foreach (var error in errors) - { - _logger.LogError(_localizer[SharedResourceKeys.MigrationErrorLogMessage], error); - } - return this; } /// - public IMigrationManifestEditor AddErrors(params Exception[] errors) - => AddErrors((IEnumerable)errors); + public virtual IMigrationManifestEditor AddErrors(params Exception[] errors) + => AddErrors((IEnumerable)errors); //Overload for Python interop. #endregion } diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs index d01ee7ef..a8258d98 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs @@ -23,10 +23,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Tableau.Migration.Content; using Tableau.Migration.Engine.Hooks.Mappings; -using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Manifest { @@ -35,9 +33,6 @@ namespace Tableau.Migration.Engine.Manifest /// public class MigrationManifestContentTypePartition : IMigrationManifestContentTypePartitionEditor, IMigrationManifestEntryBuilder { - private readonly ISharedResourcesLocalizer _localizer; - private readonly ILogger _logger; - private readonly Dictionary _entriesBySourceLocation = new(); private readonly Dictionary _entriesBySourceId = new(); private readonly Dictionary _entriesBySourceContentUrl = new(); @@ -53,16 +48,10 @@ public class MigrationManifestContentTypePartition : IMigrationManifestContentTy /// Creates a new object. /// /// The content type the partition holds manifest entries for. - /// A localizer. - /// A logger. - public MigrationManifestContentTypePartition(Type type, - ISharedResourcesLocalizer localizer, ILogger logger) + public MigrationManifestContentTypePartition(Type type) { ContentType = type; - _localizer = localizer; - _logger = logger; - foreach(var status in Enum.GetValues()) { _statusTotals[status] = 0; @@ -223,13 +212,7 @@ public void StatusUpdated(IMigrationManifestEntryEditor entry, MigrationManifest } /// - public void MigrationFailed(IMigrationManifestEntryEditor entry) - { - foreach (var error in entry.Errors) - { - _logger.LogError(_localizer[SharedResourceKeys.MigrationItemErrorLogMessage], ContentType, entry.Source.Location, error, error.Data.GetContentsAsString()); - } - } + public virtual void MigrationFailed(IMigrationManifestEntryEditor entry) { } #endregion diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs index 9ed17392..0ce478e4 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs @@ -57,6 +57,7 @@ public MigrationManifestEntry(IMigrationManifestEntryBuilder entryBuilder, Source = copy.Source; MappedLocation = copy.MappedLocation; _status = copy.Status; + _skippedReason = copy.SkippedReason; Destination = copy.Destination; HasMigrated = copy.HasMigrated; _errors = copy.Errors.ToImmutableArray(); @@ -106,7 +107,7 @@ public virtual MigrationManifestEntryStatus Status var oldStatus = _status; _status = value; - if(oldStatus != _status) + if (oldStatus != _status) { _entryBuilder.StatusUpdated(this, oldStatus); } @@ -114,6 +115,23 @@ public virtual MigrationManifestEntryStatus Status } private MigrationManifestEntryStatus _status; + /// + public virtual string SkippedReason + { + get => _skippedReason; + set + { + var oldStatus = _status; + _skippedReason = value; + + if (oldStatus != _status) + { + _entryBuilder.StatusUpdated(this, oldStatus); + } + } + } + private string _skippedReason = string.Empty; + /// public virtual bool HasMigrated { get; private set; } @@ -132,6 +150,7 @@ public static bool Equals(IMigrationManifestEntry entry, IMigrationManifestEntry if (!entry.Source.Equals(other.Source) || !entry.MappedLocation.Equals(other.MappedLocation) || !entry.Status.Equals(other.Status) || + !entry.SkippedReason.Equals(other.SkippedReason) || !entry.Errors.SequenceEqual(other.Errors, new ExceptionComparer())) return false; @@ -188,6 +207,7 @@ public virtual IMigrationManifestEntryEditor ResetStatus() _errors = ImmutableArray.Empty; Status = MigrationManifestEntryStatus.Pending; + SkippedReason = string.Empty; return this; } @@ -213,31 +233,35 @@ public virtual IMigrationManifestEntryEditor DestinationFound(IContentReference } /// - public virtual IMigrationManifestEntryEditor SetSkipped() + public virtual IMigrationManifestEntryEditor SetSkipped(string? skippedReason) { Status = MigrationManifestEntryStatus.Skipped; + if (!string.IsNullOrWhiteSpace(skippedReason)) + { + SkippedReason = skippedReason; + } + return this; } /// - public virtual IMigrationManifestEntryEditor SetFailed(IEnumerable errors) + public virtual IMigrationManifestEntryEditor SetFailed(params IEnumerable errors) { _errors = errors.ToImmutableArray(); Status = MigrationManifestEntryStatus.Error; + SkippedReason = string.Empty; _entryBuilder.MigrationFailed(this); return this; } - /// - public virtual IMigrationManifestEntryEditor SetFailed(params Exception[] errors) - => SetFailed((IEnumerable)errors); - /// public virtual IMigrationManifestEntryEditor SetCanceled() { Status = MigrationManifestEntryStatus.Canceled; + SkippedReason = string.Empty; + return this; } @@ -245,7 +269,9 @@ public virtual IMigrationManifestEntryEditor SetCanceled() public virtual IMigrationManifestEntryEditor SetMigrated() { Status = MigrationManifestEntryStatus.Migrated; + SkippedReason = string.Empty; HasMigrated = true; + return this; } diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs index 6e5f4ecf..450dac8c 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs @@ -19,8 +19,6 @@ using System.Collections; using System.Collections.Generic; using System.Linq; -using Microsoft.Extensions.Logging; -using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Manifest { @@ -29,23 +27,14 @@ namespace Tableau.Migration.Engine.Manifest /// public class MigrationManifestEntryCollection : IMigrationManifestEntryCollection, IMigrationManifestEntryCollectionEditor { - private readonly ISharedResourcesLocalizer _localizer; - private readonly ILogger _partitionLogger; - private readonly List _partitions = new(); /// /// Creates a new object. /// - /// A localizer. - /// A logger factory. /// An optional collection to deep copy entries from. - public MigrationManifestEntryCollection(ISharedResourcesLocalizer localizer, ILoggerFactory loggerFactory, - IMigrationManifestEntryCollection? copy = null) + public MigrationManifestEntryCollection(IMigrationManifestEntryCollection? copy = null) { - _localizer = localizer; - _partitionLogger = loggerFactory.CreateLogger(); - if(copy is not null) { copy.CopyTo(this); @@ -60,6 +49,14 @@ public MigrationManifestEntryCollection(ISharedResourcesLocalizer localizer, ILo } } + /// + /// Creates a new partition. + /// + /// The content type for the partition. + /// The newly created partition. + protected virtual MigrationManifestContentTypePartition CreateParition(Type contentType) + => new MigrationManifestContentTypePartition(contentType); + #region - IMigrationManifestEntryCollection Implementation - /// @@ -177,7 +174,7 @@ public IMigrationManifestContentTypePartitionEditor GetOrCreatePartition(Type co } } - var newPartition = new MigrationManifestContentTypePartition(contentType, _localizer, _partitionLogger); + var newPartition = CreateParition(contentType); _partitions.Add(newPartition); return newPartition; diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestFactory.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestFactory.cs index 6d302818..2063aa77 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestFactory.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestFactory.cs @@ -17,6 +17,7 @@ using System; using Microsoft.Extensions.Logging; +using Tableau.Migration.Engine.Manifest.Logging; using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Manifest @@ -43,13 +44,23 @@ public MigrationManifestFactory(ISharedResourcesLocalizer localizer, ILoggerFact /// public IMigrationManifestEditor Create(IMigrationInput input, Guid migrationId) { - return new MigrationManifest(_localizer, _loggerFactory, input.Plan.PlanId, migrationId, input.PreviousManifest); + if (input.PreviousManifest is not null) + { + if (input.Plan.PipelineProfile != input.PreviousManifest.PipelineProfile) + { + throw new ArgumentException($"Plan and previous manifest must have the same pipeline profile"); + } + + return new LoggingMigrationManifest(_localizer, _loggerFactory, input.Plan.PlanId, migrationId, input.PreviousManifest); + } + + return new LoggingMigrationManifest(_localizer, _loggerFactory, input.Plan.PlanId, migrationId, input.Plan.PipelineProfile); } /// - public IMigrationManifestEditor Create(Guid planId, Guid migrationId) + public IMigrationManifestEditor Create(Guid planId, Guid migrationId, PipelineProfile pipelineProfile) { - return new MigrationManifest(_localizer, _loggerFactory, planId, migrationId, null); + return new LoggingMigrationManifest(_localizer, _loggerFactory, planId, migrationId, pipelineProfile); } } } diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestSerializer.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestSerializer.cs index 47795aed..ea724dc2 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestSerializer.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestSerializer.cs @@ -23,11 +23,8 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Tableau.Migration.JsonConverters; using Tableau.Migration.JsonConverters.SerializableObjects; -using Tableau.Migration.Resources; - namespace Tableau.Migration.Engine.Manifest { @@ -37,19 +34,15 @@ namespace Tableau.Migration.Engine.Manifest public class MigrationManifestSerializer { private readonly IFileSystem _fileSystem; - private readonly ISharedResourcesLocalizer _localizer; - private readonly ILoggerFactory _loggerFactory; private readonly ImmutableArray _converters; /// /// Initializes a new instance of the class. /// - public MigrationManifestSerializer(IFileSystem fileSystem, ISharedResourcesLocalizer localizer, ILoggerFactory loggerFactory) + public MigrationManifestSerializer(IFileSystem fileSystem) { _fileSystem = fileSystem; - _localizer = localizer; - _loggerFactory = loggerFactory; _converters = CreateConverters(); } @@ -69,6 +62,7 @@ internal static ImmutableArray CreateConverters() { return new JsonConverter[] { + new JsonStringEnumConverter(), new PythonExceptionConverter(), new SerializedExceptionJsonConverter(), new BuildResponseExceptionJsonConverter(), @@ -76,6 +70,7 @@ internal static ImmutableArray CreateConverters() new TimeoutJobExceptionJsonConverter(), new RestExceptionJsonConverter(), new FailedJobExceptionJsonConverter(), + new TableauInstanceTypeNotSupportedExceptionJsonConverter(), new ExceptionJsonConverterFactory(), // This needs to be at the end. This list is ordered. }.ToImmutableArray(); } @@ -102,25 +97,52 @@ private JsonSerializerOptions MergeJsonOptions(JsonSerializerOptions? jsonOption /// Optional JSON options to use. public async Task SaveAsync(IMigrationManifest manifest, string path, JsonSerializerOptions? jsonOptions = null) { - jsonOptions = MergeJsonOptions(jsonOptions); + await SaveAsync(manifest, path, default, jsonOptions).ConfigureAwait(false); + } + /// + /// Saves a manifest in JSON format. + /// + /// This async function does not take a cancellation token. This is because the saving should happen, + /// no matter what the status of the cancellation token is. Otherwise the manifest is not saved if the migration is cancelled. + /// The manifest to save. + /// The file path to save the manifest to. + /// The cancellation token to obey. + /// Optional JSON options to use. + public async Task SaveAsync(IMigrationManifest manifest, string path, CancellationToken cancel, JsonSerializerOptions? jsonOptions = null) + { var dir = Path.GetDirectoryName(path); if (dir is not null && !_fileSystem.Directory.Exists(dir)) { _fileSystem.Directory.CreateDirectory(dir); } - var serializableManifest = new SerializableMigrationManifest(manifest); - var file = _fileSystem.File.Create(path); await using (file.ConfigureAwait(false)) { - // If cancellation was requested, we still need to save the file, so use the default token. - await JsonSerializer.SerializeAsync(file, serializableManifest, jsonOptions, default) - .ConfigureAwait(false); + await SaveAsync(manifest, file, cancel, jsonOptions).ConfigureAwait(false); } } + /// + /// Saves a manifest in JSON format. + /// + /// This async function does not take a cancellation token. This is because the saving should happen, + /// no matter what the status of the cancellation token is. Otherwise the manifest is not saved if the migration is cancelled. + /// The manifest to save. + /// The stream to save the manifest to. + /// The cancellation token to obey. + /// Optional JSON options to use. + public async Task SaveAsync(IMigrationManifest manifest, Stream stream, CancellationToken cancel, JsonSerializerOptions? jsonOptions = null) + { + jsonOptions = MergeJsonOptions(jsonOptions); + var serializableManifest = new SerializableMigrationManifest(manifest); + + // If cancellation was requested, we still need to save the file, so use the default token. + await JsonSerializer.SerializeAsync(stream, serializableManifest, jsonOptions, cancel) + .ConfigureAwait(false); + } + /// /// Loads a manifest from JSON format. /// @@ -135,24 +157,36 @@ await JsonSerializer.SerializeAsync(file, serializableManifest, jsonOptions, def return null; } - jsonOptions = MergeJsonOptions(jsonOptions); - var file = _fileSystem.File.OpenRead(path); await using (file.ConfigureAwait(false)) { - var manifest = await JsonSerializer.DeserializeAsync(file, jsonOptions, cancel) - .ConfigureAwait(false); + return await LoadAsync(file, cancel, jsonOptions).ConfigureAwait(false); + } + } + + /// + /// Loads a manifest from JSON format. + /// + /// The stream to load the manifest from. + /// The cancellation token to obey. + /// Optional JSON options to use. + /// The loaded , or null if the manifest could not be loaded. + public async Task LoadAsync(Stream stream, CancellationToken cancel, JsonSerializerOptions? jsonOptions = null) + { + jsonOptions = MergeJsonOptions(jsonOptions); - if (manifest is not null) - { - if (manifest.ManifestVersion is not SupportedManifestVersion) - throw new NotSupportedException($"This {nameof(MigrationManifestSerializer)} only supports Manifest version {SupportedManifestVersion}. The manifest being loaded is version {manifest.ManifestVersion}"); + var manifest = await JsonSerializer.DeserializeAsync(stream, jsonOptions, cancel) + .ConfigureAwait(false); - return manifest.ToMigrationManifest(_localizer, _loggerFactory) as MigrationManifest; - } + if (manifest is not null) + { + if (manifest.ManifestVersion is not SupportedManifestVersion) + throw new NotSupportedException($"This {nameof(MigrationManifestSerializer)} only supports Manifest version {SupportedManifestVersion}. The manifest being loaded is version {manifest.ManifestVersion}"); - return null; + return manifest.ToMigrationManifest() as MigrationManifest; } + + return null; } } } diff --git a/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs b/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs index ae20d3aa..fde9fec6 100644 --- a/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs +++ b/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs @@ -21,13 +21,16 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Simulation; using Tableau.Migration.Content; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Hooks.ActionCompleted; using Tableau.Migration.Engine.Hooks.Filters; using Tableau.Migration.Engine.Hooks.Filters.Default; +using Tableau.Migration.Engine.Hooks.InitializeMigration.Default; using Tableau.Migration.Engine.Hooks.Mappings; using Tableau.Migration.Engine.Hooks.PostPublish.Default; using Tableau.Migration.Engine.Hooks.Transformers; @@ -44,6 +47,7 @@ namespace Tableau.Migration.Engine public class MigrationPlanBuilder : IMigrationPlanBuilder { private readonly ISharedResourcesLocalizer _localizer; + private readonly ILoggerFactory _loggerFactory; private readonly ITableauApiSimulatorFactory _simulatorFactory; private Func? _pipelineFactoryOverride; @@ -58,6 +62,7 @@ public class MigrationPlanBuilder : IMigrationPlanBuilder /// Creates a new object. /// /// The string localizer. + /// The logger factory. /// A simulator factory. /// A new/fresh options builder. /// A new/fresh hook builder. @@ -66,6 +71,7 @@ public class MigrationPlanBuilder : IMigrationPlanBuilder /// A new/fresh transformer builder. public MigrationPlanBuilder( ISharedResourcesLocalizer localizer, + ILoggerFactory loggerFactory, ITableauApiSimulatorFactory simulatorFactory, IMigrationPlanOptionsBuilder options, IMigrationHookBuilder hooks, @@ -74,6 +80,7 @@ public MigrationPlanBuilder( IContentTransformerBuilder transformers) { _localizer = localizer; + _loggerFactory = loggerFactory; _simulatorFactory = simulatorFactory; _source = TableauApiEndpointConfiguration.Empty; @@ -102,7 +109,7 @@ public IServerToCloudMigrationPlanBuilder ForServerToCloud() SetPipelineProfile(PipelineProfile.ServerToCloud, ServerToCloudMigrationPipeline.ContentTypes); _pipelineFactoryOverride = null; - _serverToCloudBuilder ??= new(_localizer, this); + _serverToCloudBuilder ??= new(_localizer, _loggerFactory, this); ClearExtensions(); @@ -111,11 +118,7 @@ public IServerToCloudMigrationPlanBuilder ForServerToCloud() } /// - public IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, params MigrationPipelineContentType[] supportedContentTypes) - => ForCustomPipelineFactory(pipelineFactoryOverride, (IEnumerable)supportedContentTypes); - - /// - public IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, IEnumerable supportedContentTypes) + public IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, params IEnumerable supportedContentTypes) { SetPipelineProfile(PipelineProfile.Custom, supportedContentTypes); @@ -129,54 +132,68 @@ public IMigrationPlanBuilder ForCustomPipelineFactory(Func - public IMigrationPlanBuilder ForCustomPipelineFactory(params MigrationPipelineContentType[] supportedContentTypes) - where T : IMigrationPipelineFactory - => ForCustomPipelineFactory((IEnumerable)supportedContentTypes); - - /// - public IMigrationPlanBuilder ForCustomPipelineFactory(IEnumerable supportedContentTypes) + public IMigrationPlanBuilder ForCustomPipelineFactory(params IEnumerable supportedContentTypes) where T : IMigrationPipelineFactory => ForCustomPipelineFactory(s => s.GetRequiredService(), supportedContentTypes); /// - public IMigrationPlanBuilder ForCustomPipeline(params MigrationPipelineContentType[] supportedContentTypes) - where T : IMigrationPipeline - => ForCustomPipeline((IEnumerable)supportedContentTypes); - - /// - public IMigrationPlanBuilder ForCustomPipeline(IEnumerable supportedContentTypes) + public IMigrationPlanBuilder ForCustomPipeline(params IEnumerable supportedContentTypes) where T : IMigrationPipeline => ForCustomPipelineFactory>(supportedContentTypes); /// public IMigrationPlanBuilder AppendDefaultExtensions() { - //Add standard hooks, filters, etc. for all migrations here. - - //Standard migration filters. - Filters.Add(typeof(PreviouslyMigratedFilter<>), GetAllContentTypes()); - Filters.Add(); - Filters.Add(typeof(SystemOwnershipFilter<>), GetContentTypesByInterface()); - - //Standard migration transformers. - Transformers.Add(); - Transformers.Add(); - Transformers.Add(typeof(OwnershipTransformer<>), GetPublishTypesByInterface()); - Transformers.Add(); - Transformers.Add(); - Transformers.Add(typeof(WorkbookReferenceTransformer<>), GetPublishTypesByInterface()); - Transformers.Add(); - Transformers.Add(typeof(EncryptExtractTransformer<>), GetPublishTypesByInterface()); - - // Post-publish hooks. - Hooks.Add(typeof(OwnerItemPostPublishHook<,>), GetPostPublishTypesByInterface()); - Hooks.Add(typeof(PermissionsItemPostPublishHook<,>), GetPostPublishTypesByInterface()); - Hooks.Add(typeof(ChildItemsPermissionsPostPublishHook<,>), GetPostPublishTypesByInterface()); - Hooks.Add(typeof(TagItemPostPublishHook<,>), GetPostPublishTypesByInterface()); - Hooks.Add(); - Hooks.Add(); + // Add standard hooks, filters, etc. for all migrations here. + AppendDefaultPreflightHooks(); + AppendDefaultFilters(); + AppendDefaultTransformers(); + AppendDefaultPostPublishHooks(); + AppendDefaultActionCompletedHooks(); return this; + + void AppendDefaultPreflightHooks() + { + Hooks.Add(); + Hooks.Add(); + } + + void AppendDefaultFilters() + { + Filters.Add(typeof(PreviouslyMigratedFilter<>), GetAllContentTypes()); + Filters.Add(); + Filters.Add(typeof(SystemOwnershipFilter<>), GetContentTypesByInterface()); + } + + void AppendDefaultPostPublishHooks() + { + Hooks.Add(typeof(OwnerItemPostPublishHook<,>), GetPostPublishTypesByInterface()); + Hooks.Add(typeof(PermissionsItemPostPublishHook<,>), GetPostPublishTypesByInterface()); + Hooks.Add(typeof(ChildItemsPermissionsPostPublishHook<,>), GetPostPublishTypesByInterface()); + Hooks.Add(typeof(TagItemPostPublishHook<,>), GetPostPublishTypesByInterface()); + Hooks.Add(); + Hooks.Add(); + Hooks.Add(typeof(EmbeddedCredentialsItemPostPublishHook<,>), GetPostPublishTypesByInterface()); + } + + void AppendDefaultTransformers() + { + Transformers.Add(); + Transformers.Add(); + Transformers.Add(typeof(OwnershipTransformer<>), GetPublishTypesByInterface()); + Transformers.Add(); + Transformers.Add(); + Transformers.Add(typeof(WorkbookReferenceTransformer<>), GetPublishTypesByInterface()); + Transformers.Add(); + Transformers.Add(typeof(EncryptExtractTransformer<>), GetPublishTypesByInterface()); + Transformers.Add(); + } + + void AppendDefaultActionCompletedHooks() + { + Hooks.Add(); + } } /// @@ -230,6 +247,9 @@ public IMigrationPlanBuilder ToDestinationTableauCloud(Uri podUrl, string siteCo /// public IContentTransformerBuilder Transformers { get; } + /// + public PipelineProfile PipelineProfile => _pipelineProfile; + private void SetPipelineProfile(PipelineProfile pipelineProfile, IEnumerable supportedContentTypes) { _pipelineProfile = pipelineProfile; diff --git a/src/Tableau.Migration/Engine/Migrators/Batch/BulkPublishContentBatchMigrator.cs b/src/Tableau.Migration/Engine/Migrators/Batch/BulkPublishContentBatchMigrator.cs index 26fbd55d..203c99a0 100644 --- a/src/Tableau.Migration/Engine/Migrators/Batch/BulkPublishContentBatchMigrator.cs +++ b/src/Tableau.Migration/Engine/Migrators/Batch/BulkPublishContentBatchMigrator.cs @@ -28,17 +28,19 @@ namespace Tableau.Migration.Engine.Migrators.Batch /// /// implementation that publishes the entire batch after all items have been prepared. /// - /// The content type. - /// The publish type. - public class BulkPublishContentBatchMigrator : ParallelContentBatchMigratorBatchBase + /// + /// + /// + public class BulkPublishContentBatchMigrator : ParallelContentBatchMigratorBatchBase where TContent : class, IContentReference + where TPrepare : class where TPublish : class { private readonly IMigration _migration; private readonly IMigrationHookRunner _hookRunner; /// - /// Creates a new object. + /// Creates a new object. /// /// The current migration. /// The pipeline to use to get the item preparer. @@ -97,7 +99,7 @@ protected override async Task MigrateBatchAsync(ContentMigrationBatch implementation that publishes the entire batch after all items have been prepared. /// /// The content type. - public class BulkPublishContentBatchMigrator : BulkPublishContentBatchMigrator + public class BulkPublishContentBatchMigrator : BulkPublishContentBatchMigrator where TContent : class, IContentReference { /// diff --git a/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigrationResult.cs b/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigrationResult.cs index 2cd5bee7..acf065ab 100644 --- a/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigrationResult.cs +++ b/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigrationResult.cs @@ -33,7 +33,7 @@ internal record ContentBatchMigrationResult : Result, IContentBatchMig /// public IImmutableList> ItemResults { get; } - protected ContentBatchMigrationResult(bool success, bool performNextBatch, IImmutableList> itemResults, IEnumerable errors) + protected ContentBatchMigrationResult(bool success, bool performNextBatch, IImmutableList> itemResults, params IEnumerable errors) : base(success, errors) { PerformNextBatch = performNextBatch; @@ -44,10 +44,6 @@ protected ContentBatchMigrationResult(IResult baseResult, bool performNextBatch, : this(baseResult.Success, performNextBatch, itemResults, baseResult.Errors) { } - protected ContentBatchMigrationResult(bool success, bool performNextBatch, IImmutableList> itemResults, params Exception[] errors) - : this(success, performNextBatch, itemResults, (IEnumerable)errors) - { } - /// /// Creates a new instance for successful operations. /// diff --git a/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigratorBase.cs b/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigratorBase.cs index 211fe1de..137b4de3 100644 --- a/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigratorBase.cs +++ b/src/Tableau.Migration/Engine/Migrators/Batch/ContentBatchMigratorBase.cs @@ -29,20 +29,22 @@ namespace Tableau.Migration.Engine.Migrators.Batch /// Abstract base class for implementations. /// /// The content type. + /// The pulled type to prepare. /// The publish type. - public abstract class ContentBatchMigratorBase : IContentBatchMigrator + public abstract class ContentBatchMigratorBase : IContentBatchMigrator where TContent : class, IContentReference + where TPrepare : class where TPublish : class { private readonly IContentItemPreparer _itemPreparer; /// - /// Creates a new object. + /// Creates a new object. /// /// The migration pipeline. public ContentBatchMigratorBase(IMigrationPipeline pipeline) { - _itemPreparer = pipeline.GetItemPreparer(); + _itemPreparer = pipeline.GetItemPreparer(); } /// @@ -96,11 +98,11 @@ protected virtual async Task MigrateBatchItemAsync(ContentMigrationItem.Canceled(item.ManifestEntry, new[] { ex }, false); + itemResult = ContentItemMigrationResult.Canceled(item.ManifestEntry, [ex], false); } catch (Exception ex) when (!ex.IsCancellationException()) { - itemResult = ContentItemMigrationResult.Failed(item.ManifestEntry, new[] { ex }); + itemResult = ContentItemMigrationResult.Failed(item.ManifestEntry, [ex]); } batch.ItemResults.Enqueue(itemResult); @@ -151,15 +153,14 @@ public async Task> MigrateAsync(Immutable /// Abstract base class for implementations. /// /// The content type. - public abstract class ContentBatchMigratorBase : ContentBatchMigratorBase + public abstract class ContentBatchMigratorBase : ContentBatchMigratorBase where TContent : class, IContentReference { /// /// Creates a new object. /// /// The migration pipeline. - public ContentBatchMigratorBase( - IMigrationPipeline pipeline) + public ContentBatchMigratorBase(IMigrationPipeline pipeline) : base(pipeline) { } } diff --git a/src/Tableau.Migration/Engine/Migrators/Batch/ItemPublishContentBatchMigrator.cs b/src/Tableau.Migration/Engine/Migrators/Batch/ItemPublishContentBatchMigrator.cs index 0e6dab84..a0c1675d 100644 --- a/src/Tableau.Migration/Engine/Migrators/Batch/ItemPublishContentBatchMigrator.cs +++ b/src/Tableau.Migration/Engine/Migrators/Batch/ItemPublishContentBatchMigrator.cs @@ -27,11 +27,13 @@ namespace Tableau.Migration.Engine.Migrators.Batch /// /// implementation that publishes items one-by-one. /// - /// The content type. - /// The publish type. + /// + /// + /// /// The post-publish result type. - public class ItemPublishContentBatchMigrator : ParallelContentBatchMigratorBatchBase + public class ItemPublishContentBatchMigrator : ParallelContentBatchMigratorBatchBase where TContent : class, IContentReference + where TPrepare : class where TPublish : class where TResult : class, IContentReference { @@ -39,7 +41,7 @@ public class ItemPublishContentBatchMigrator : Para private readonly IMigrationHookRunner _hookRunner; /// - /// Creates a new object. + /// Creates a new object. /// /// The current migration. /// The pipeline to use to get the item preparer. @@ -81,9 +83,32 @@ protected override async Task MigratePreparedItemAsync(ContentMigration ///
/// /// - public class ItemPublishContentBatchMigrator : ItemPublishContentBatchMigrator + /// + public class ItemPublishContentBatchMigrator : ItemPublishContentBatchMigrator where TContent : class, IContentReference where TPublish : class + where TResult : class, IContentReference + { + /// + /// Creates a new object. + /// + /// The current migration. + /// The pipeline to use to get the item preparer. + /// The configuration reader. + /// The hook runner. + public ItemPublishContentBatchMigrator(IMigration migration, IMigrationPipeline pipeline, IConfigReader configReader, IMigrationHookRunner hookRunner) + : base(migration, pipeline, configReader, hookRunner) + { } + } + + /// + /// implementation that publishes items one-by-one. + /// + /// + /// + public class ItemPublishContentBatchMigrator : ItemPublishContentBatchMigrator + where TContent : class, IContentReference + where TPrepare : class { /// /// Creates a new object. @@ -100,8 +125,8 @@ public ItemPublishContentBatchMigrator(IMigration migration, IMigrationPipeline /// /// implementation that publishes items one-by-one. /// - /// The content type. - public class ItemPublishContentBatchMigrator : ItemPublishContentBatchMigrator + /// + public class ItemPublishContentBatchMigrator : ItemPublishContentBatchMigrator where TContent : class, IContentReference { /// diff --git a/src/Tableau.Migration/Engine/Migrators/Batch/ParallelContentBatchMigratorBase.cs b/src/Tableau.Migration/Engine/Migrators/Batch/ParallelContentBatchMigratorBase.cs index 25f4c100..e0e7ef51 100644 --- a/src/Tableau.Migration/Engine/Migrators/Batch/ParallelContentBatchMigratorBase.cs +++ b/src/Tableau.Migration/Engine/Migrators/Batch/ParallelContentBatchMigratorBase.cs @@ -24,16 +24,18 @@ namespace Tableau.Migration.Engine.Migrators.Batch /// /// Abstract base class for implementations that migrates items in parallel. /// - /// The content type. - /// The publish type. - public abstract class ParallelContentBatchMigratorBatchBase : ContentBatchMigratorBase + /// + /// + /// + public abstract class ParallelContentBatchMigratorBatchBase : ContentBatchMigratorBase where TContent : class, IContentReference + where TPrepare : class where TPublish : class { private readonly IConfigReader _configReader; /// - /// Creates a new object. + /// Creates a new object. /// /// The pipeline to use to get the item preparer. /// The configuration reader. @@ -65,7 +67,7 @@ await Parallel.ForEachAsync(batch.Items, opts, async (item, itemCancel) => /// Abstract base class for implementations that migrates items in parallel. /// /// The content type. - public abstract class ParallelContentBatchMigratorBatchBase : ParallelContentBatchMigratorBatchBase + public abstract class ParallelContentBatchMigratorBatchBase : ParallelContentBatchMigratorBatchBase where TContent : class, IContentReference { /// diff --git a/src/Tableau.Migration/Engine/Migrators/ContentItemMigrationResult.cs b/src/Tableau.Migration/Engine/Migrators/ContentItemMigrationResult.cs index b987c66f..8c7b08f6 100644 --- a/src/Tableau.Migration/Engine/Migrators/ContentItemMigrationResult.cs +++ b/src/Tableau.Migration/Engine/Migrators/ContentItemMigrationResult.cs @@ -35,7 +35,7 @@ internal record ContentItemMigrationResult : Result, IContentItemMigra /// public IMigrationManifestEntry ManifestEntry { get; } - protected ContentItemMigrationResult(bool success, bool continueBatch, IMigrationManifestEntry manifestEntry, bool isCanceled, IEnumerable errors) + protected ContentItemMigrationResult(bool success, bool continueBatch, IMigrationManifestEntry manifestEntry, bool isCanceled, params IEnumerable errors) : base(success, errors) { ContinueBatch = continueBatch; @@ -47,10 +47,6 @@ protected ContentItemMigrationResult(IResult baseResult, bool continueBatch, IMi : this(baseResult.Success, continueBatch, manifestEntry, isCanceled, baseResult.Errors) { } - protected ContentItemMigrationResult(bool success, bool continueBatch, IMigrationManifestEntry manifestEntry, bool isCanceled, params Exception[] errors) - : this(success, continueBatch, manifestEntry, isCanceled, (IEnumerable)errors) - { } - /// /// Creates a new instance for successful operations. /// diff --git a/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs b/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs index 79e9413c..c0cfba78 100644 --- a/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs +++ b/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs @@ -16,7 +16,6 @@ // using System.Collections.Immutable; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Tableau.Migration.Config; @@ -111,12 +110,6 @@ public async Task MigrateAsync(CancellationToken cancel) //Apply filters. var filteredItems = (await _filterRunner.ExecuteAsync(batchItems, cancel).ConfigureAwait(false)).ToImmutableArray(); - var skippedItems = batchItems.Except(filteredItems).ToImmutableArray(); - foreach (var skippedItem in skippedItems) - { - skippedItem.ManifestEntry.SetSkipped(); - } - cancel.ThrowIfCancellationRequested(); //Migrate the batch. diff --git a/src/Tableau.Migration/Engine/Migrators/Migrator.cs b/src/Tableau.Migration/Engine/Migrators/Migrator.cs index c984baa2..0721de6c 100644 --- a/src/Tableau.Migration/Engine/Migrators/Migrator.cs +++ b/src/Tableau.Migration/Engine/Migrators/Migrator.cs @@ -94,7 +94,7 @@ public async Task ExecuteAsync(IMigrationPlan plan, IMigrationM migration.Manifest.AddErrors(endpointInitResults); //Extra error log entry to clarify why we didn't start any migration. - _log.LogError(_localizer[SharedResourceKeys.EndpointInitializationError]); + _log.LogCritical(_localizer[SharedResourceKeys.EndpointInitializationError]); } return new(completionStatus, migration.Manifest); @@ -116,7 +116,7 @@ public async Task ExecuteAsync(IMigrationPlan plan, IMigrationM { var completionStatus = ex.IsCancellationException() ? MigrationCompletionStatus.Canceled : MigrationCompletionStatus.FatalError; - var manifest = migration?.Manifest ?? _manifestFactory.Create(plan.PlanId, Guid.NewGuid()); + var manifest = migration?.Manifest ?? _manifestFactory.Create(plan.PlanId, Guid.NewGuid(), plan.PipelineProfile); if (completionStatus != MigrationCompletionStatus.Canceled) //Don't dirty the end result with op/task canceled exceptions. { manifest.AddErrors(ex); diff --git a/src/Tableau.Migration/Engine/Pipelines/CloudToCloudMigrationPipeline.cs b/src/Tableau.Migration/Engine/Pipelines/CloudToCloudMigrationPipeline.cs new file mode 100644 index 00000000..07b8e1b6 --- /dev/null +++ b/src/Tableau.Migration/Engine/Pipelines/CloudToCloudMigrationPipeline.cs @@ -0,0 +1,96 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; +using Tableau.Migration.Config; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Migrators.Batch; + +namespace Tableau.Migration.Engine.Pipelines +{ + /// + /// implementation to perform migrations from Tableau Cloud to Tableau Cloud. + /// + public class CloudToCloudMigrationPipeline : MigrationPipelineBase + { + /// + /// Content types that are supported for migrations. + /// + public static readonly ImmutableArray ContentTypes = + [ + MigrationPipelineContentType.Users, + MigrationPipelineContentType.Groups, + MigrationPipelineContentType.Projects, + MigrationPipelineContentType.DataSources, + MigrationPipelineContentType.Workbooks, + MigrationPipelineContentType.CloudToCloudExtractRefreshTasks, + MigrationPipelineContentType.CustomViews, + MigrationPipelineContentType.CloudToCloudSubscriptions + ]; + + /// + /// Creates a new object. + /// + /// + /// A config reader to get the REST API configuration. + public CloudToCloudMigrationPipeline(IServiceProvider services, + IConfigReader configReader) + : base(services, configReader) + { } + + + /// + protected override IEnumerable BuildPipeline() + { + yield return CreateAction(); + + //Migrate users and groups first since many content types depend on them, + //We migrate users before groups because group membership must use + //per-user or per-group requests, and we assume in most cases + //there will be less groups than users. + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + } + + /// + public override IContentBatchMigrator GetBatchMigrator() + { + switch (typeof(TContent)) + { + case Type extractRefreshTask when extractRefreshTask == typeof(ICloudExtractRefreshTask): + return Services.GetRequiredService>(); + + case Type subscription when subscription == typeof(ICloudSubscription): + return Services.GetRequiredService>(); + + default: + return base.GetBatchMigrator(); + } + } + } +} diff --git a/src/Tableau.Migration/Engine/Pipelines/IMigrationPipeline.cs b/src/Tableau.Migration/Engine/Pipelines/IMigrationPipeline.cs index 3ef14221..fb5a4f63 100644 --- a/src/Tableau.Migration/Engine/Pipelines/IMigrationPipeline.cs +++ b/src/Tableau.Migration/Engine/Pipelines/IMigrationPipeline.cs @@ -18,6 +18,7 @@ using System.Collections.Immutable; using Tableau.Migration.Content.Search; using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Conversion; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Migrators; using Tableau.Migration.Engine.Migrators.Batch; @@ -55,10 +56,22 @@ IContentBatchMigrator GetBatchMigrator() /// Gets a content item preparer for the given content and publish types. /// /// The content type. + /// The type being prepared. /// The publish type. /// The content preparer. - IContentItemPreparer GetItemPreparer() + IContentItemPreparer GetItemPreparer() where TContent : class + where TPrepare : class + where TPublish : class; + + /// + /// Gets a content item converter for the given prepare and publish types. + /// + /// The type being prepared. + /// The publish type. + /// + IContentItemConverter GetItemConverter() + where TPrepare : class where TPublish : class; /// diff --git a/src/Tableau.Migration/Engine/Pipelines/IMigrationPipelineRunner.cs b/src/Tableau.Migration/Engine/Pipelines/IMigrationPipelineRunner.cs index fbc26ae7..b9c122c3 100644 --- a/src/Tableau.Migration/Engine/Pipelines/IMigrationPipelineRunner.cs +++ b/src/Tableau.Migration/Engine/Pipelines/IMigrationPipelineRunner.cs @@ -17,6 +17,7 @@ using System.Threading; using System.Threading.Tasks; +using Tableau.Migration.Engine.Actions; namespace Tableau.Migration.Engine.Pipelines { @@ -25,6 +26,11 @@ namespace Tableau.Migration.Engine.Pipelines /// public interface IMigrationPipelineRunner { + /// + /// The current action being executed. Null if no action is current being performed. + /// + IMigrationAction? CurrentAction { get; } + /// /// Executes all pipeline actions. /// diff --git a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineBase.cs b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineBase.cs index 0c5f31b3..558534e6 100644 --- a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineBase.cs +++ b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineBase.cs @@ -19,12 +19,13 @@ using System.Collections.Generic; using System.Collections.Immutable; using Microsoft.Extensions.DependencyInjection; +using Tableau.Migration.Config; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Content.Search; using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Conversion; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Migrators; using Tableau.Migration.Engine.Migrators.Batch; @@ -42,13 +43,22 @@ public abstract class MigrationPipelineBase : IMigrationPipeline /// protected IServiceProvider Services { get; } + /// + /// Gets the config reader. + /// + protected readonly IConfigReader ConfigReader; + /// /// Creates a new object. /// /// A DI service provider to create actions with. - protected MigrationPipelineBase(IServiceProvider services) + /// The config reader. + protected MigrationPipelineBase( + IServiceProvider services, + IConfigReader configReader) { Services = services; + ConfigReader = configReader; } #region - Protected Methods - @@ -95,25 +105,60 @@ public virtual IContentMigrator GetMigrator() public virtual IContentBatchMigrator GetBatchMigrator() where TContent : class, IContentReference { - return Services.GetRequiredService>(); + switch (typeof(TContent)) + { + case Type user when user == typeof(IUser): + if (ConfigReader.Get().BatchPublishingEnabled) + { + return Services.GetRequiredService>(); + } + return Services.GetRequiredService>(); + + case Type group when group == typeof(IGroup): + return Services.GetRequiredService>(); + + case Type project when project == typeof(IProject): + return Services.GetRequiredService>(); + + case Type dataSource when dataSource == typeof(IDataSource): + return Services.GetRequiredService>(); + + case Type workbook when workbook == typeof(IWorkbook): + return Services.GetRequiredService>(); + + case Type customView when customView == typeof(ICustomView): + return Services.GetRequiredService>(); + + default: + return Services.GetRequiredService>(); + } } /// - public virtual IContentItemPreparer GetItemPreparer() + public virtual IContentItemPreparer GetItemPreparer() where TContent : class + where TPrepare : class where TPublish : class { switch (typeof(TContent)) { - case Type source when source == typeof(TPublish): + case Type source when source == typeof(TPrepare) && source == typeof(TPublish): return (IContentItemPreparer)Services.GetRequiredService>(); + case Type source when source == typeof(TContent) && source == typeof(TPrepare): + return Services.GetRequiredService>(); case Type source when source == typeof(IServerExtractRefreshTask) && typeof(TPublish) == typeof(ICloudExtractRefreshTask): return (IContentItemPreparer)Services.GetRequiredService(); default: - return Services.GetRequiredService>(); + return Services.GetRequiredService>(); } } + /// + public virtual IContentItemConverter GetItemConverter() + where TPrepare : class + where TPublish : class + => Services.GetRequiredService>(); + /// public virtual IContentReferenceCache CreateSourceCache() where TContent : class, IContentReference diff --git a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs index 8b68cee9..ade4e613 100644 --- a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs +++ b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -16,8 +16,10 @@ // using System; +using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using System.Reflection; using System.Text; using Tableau.Migration.Content; using Tableau.Migration.Content.Schedules.Cloud; @@ -29,7 +31,7 @@ namespace Tableau.Migration.Engine.Pipelines /// Object that represents a definition of a content type /// that a pipeline migrates. ///
- /// The content type. + /// The content type. Content type is returned from list step, pre-pull. public record MigrationPipelineContentType(Type ContentType) { /// @@ -41,6 +43,7 @@ public record MigrationPipelineContentType(Type ContentType) /// Gets the groups . /// public static readonly MigrationPipelineContentType Groups = new MigrationPipelineContentType() + .WithPrepareType() .WithPublishType(); /// @@ -52,6 +55,7 @@ public record MigrationPipelineContentType(Type ContentType) /// Gets the data sources . /// public static readonly MigrationPipelineContentType DataSources = new MigrationPipelineContentType() + .WithPrepareType() .WithPublishType() .WithResultType(); @@ -59,6 +63,7 @@ public record MigrationPipelineContentType(Type ContentType) /// Gets the workbooks . ///
public static readonly MigrationPipelineContentType Workbooks = new MigrationPipelineContentType() + .WithPrepareType() .WithPublishType() .WithResultType(); @@ -67,55 +72,106 @@ public record MigrationPipelineContentType(Type ContentType) ///
public static readonly MigrationPipelineContentType Views = new MigrationPipelineContentType(); + /// + /// Gets the Server to Server extract refresh tasks . + /// + public static readonly MigrationPipelineContentType ServerToServerExtractRefreshTasks = new MigrationPipelineContentType(); + /// /// Gets the Server to Cloud extract refresh tasks . /// public static readonly MigrationPipelineContentType ServerToCloudExtractRefreshTasks = new MigrationPipelineContentType() .WithPublishType(); + /// + /// Gets the Cloud to Cloud extract refresh tasks . + /// + public static readonly MigrationPipelineContentType CloudToCloudExtractRefreshTasks = new MigrationPipelineContentType(); + /// /// Gets the custom views . /// public static readonly MigrationPipelineContentType CustomViews = new MigrationPipelineContentType() + .WithPrepareType() .WithPublishType(); /// - /// Gets the publish type. + /// Gets the Server to Server subscriptions . + /// + public static readonly MigrationPipelineContentType ServerToServerSubscriptions = new MigrationPipelineContentType(); + + /// + /// Gets the Server to Cloud subscriptions . + /// + public static readonly MigrationPipelineContentType ServerToCloudSubscriptions = new MigrationPipelineContentType() + .WithPublishType(); + + /// + /// Gets the Cloud to Cloud subscriptions . + /// + public static readonly MigrationPipelineContentType CloudToCloudSubscriptions = new MigrationPipelineContentType(); + + /// + /// Gets the preparation type that is pulled and converted for publishing. The Prepare type is the post-pull, pre-conversion type. + /// + public Type PrepareType { get; private init; } = ContentType; + + /// + /// Gets the publish type. The publish type is post-conversion, ready to publish. /// public Type PublishType { get; private init; } = ContentType; /// - /// Gets the result type. + /// Gets the result type returned by publishing. /// public Type ResultType { get; private init; } = ContentType; /// /// Gets the types for this instance. /// - public IImmutableList Types => new[] { ContentType, PublishType, ResultType }.Distinct().ToImmutableArray(); + public IImmutableList Types => new[] { ContentType, PrepareType, PublishType, ResultType }.Distinct().ToImmutableArray(); + + /// + /// Creates a new instance with the specified preparation type. + /// Preperation type is post-pull, pre-conversion. + /// + /// The preparation type. + public MigrationPipelineContentType WithPrepareType(Type prepareType) + => new(ContentType) { PrepareType = prepareType, PublishType = PublishType, ResultType = ResultType }; + + /// + /// Creates a new instance with the specified preparation type. + /// Preperation type is post-pull, pre-conversion. + /// + public MigrationPipelineContentType WithPrepareType() + => WithPrepareType(typeof(TPrepare)); /// /// Creates a new instance with the specified publish type. + /// Publish type is post-conversion, ready to publish. /// /// The publish type. public MigrationPipelineContentType WithPublishType(Type publishType) - => new(ContentType) { PublishType = publishType, ResultType = ResultType }; + => new(ContentType) { PrepareType = PrepareType, PublishType = publishType, ResultType = ResultType }; /// /// Creates a new instance with the specified publish type. + /// Publish type is post-conversion, ready to publish. /// public MigrationPipelineContentType WithPublishType() => WithPublishType(typeof(TPublish)); /// /// Creates a new instance with the specified result type. + /// Result type is post-publish. /// /// The result type. public MigrationPipelineContentType WithResultType(Type resultType) - => new(ContentType) { PublishType = PublishType, ResultType = resultType }; + => new(ContentType) { PrepareType = PrepareType, PublishType = PublishType, ResultType = resultType }; /// /// Creates a new instance with the specified result type. + /// Result type is post-publish. /// public MigrationPipelineContentType WithResultType() => WithResultType(typeof(TResult)); @@ -176,8 +232,78 @@ public static string GetConfigKeyForType(Type contentType) return convertedName.ToString(); } + /// + /// Gets the friendly display name for a content type. + /// + /// The content type. + /// Whether the display name should be in plural form. + /// The display name string. + public static string GetDisplayNameForType(Type contentType, bool plural = false) + { + var configKey = GetConfigKeyForType(contentType); + + if (configKey.Length < 2) + { + return configKey; + } + + var sb = new StringBuilder(); + for (var i = 0; i < configKey.Length; i++) + { + if (i != 0 && char.IsUpper(configKey[i])) + { + sb.Append(' '); + } + + sb.Append(configKey[i]); + } + + if (plural) + { + sb.Append('s'); + } + + return sb.ToString(); + } + private static bool HasInterface(Type t, Type @interface) => t.GetInterfaces().Contains(@interface); + + /// + /// Gets the content types for a given profile. + /// + /// Profile to get the types for. + /// Array of content types supported by the given pipeline profile. + public static ImmutableArray GetMigrationPipelineContentTypes(PipelineProfile profile) + { + switch (profile) + { + case PipelineProfile.ServerToServer: + return ServerToServerMigrationPipeline.ContentTypes; + + case PipelineProfile.ServerToCloud: + return ServerToCloudMigrationPipeline.ContentTypes; + + case PipelineProfile.CloudToCloud: + return CloudToCloudMigrationPipeline.ContentTypes; + + default: + throw new ArgumentException($"Cannot get content types for profile {profile}"); + } + } + + /// + /// Gets all static instances of . + /// + public static IEnumerable GetAllMigrationPipelineContentTypes() + { + return typeof(MigrationPipelineContentType) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(field => field.FieldType == typeof(MigrationPipelineContentType)) + .Select(field => (MigrationPipelineContentType?)field.GetValue(null)) + .Where(instance => instance != null) + .Select(instance => instance!); + } } /// diff --git a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs index 814c1d5a..d140aa47 100644 --- a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs +++ b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs @@ -46,6 +46,13 @@ public virtual IMigrationPipeline Create(IMigrationPlan plan) { case PipelineProfile.ServerToCloud: return Services.GetRequiredService(); + + case PipelineProfile.ServerToServer: + return Services.GetRequiredService(); + + case PipelineProfile.CloudToCloud: + return Services.GetRequiredService(); + default: throw new ArgumentException($"Cannot create a migration pipeline for profile {plan.PipelineProfile}"); } diff --git a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs index 6573e023..a5d46f00 100644 --- a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs +++ b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs @@ -29,15 +29,13 @@ public class MigrationPipelineRunner : IMigrationPipelineRunner { private readonly IMigrationHookRunner _hooks; - /// - /// The current action being executed. Null if no action is current being performed. - /// + /// public IMigrationAction? CurrentAction { get; private set; } /// /// Creates a new object. /// - /// The hook runner. + /// The hook runner. public MigrationPipelineRunner(IMigrationHookRunner hooks) { _hooks = hooks; @@ -51,6 +49,7 @@ public async Task ExecuteAsync(IMigrationPipeline pipeline, Cancellatio foreach (var action in pipeline.BuildActions()) { CurrentAction = action; + var actionResult = await action.ExecuteAsync(cancel).ConfigureAwait(false); actionResult = await _hooks.ExecuteAsync(actionResult, cancel).ConfigureAwait(false); diff --git a/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs b/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs index 92d69dfd..eb0c7734 100644 --- a/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs +++ b/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs @@ -24,6 +24,9 @@ using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Conversion; +using Tableau.Migration.Engine.Conversion.ExtractRefreshTasks; +using Tableau.Migration.Engine.Conversion.Subscriptions; using Tableau.Migration.Engine.Migrators.Batch; namespace Tableau.Migration.Engine.Pipelines @@ -44,10 +47,11 @@ public class ServerToCloudMigrationPipeline : MigrationPipelineBase MigrationPipelineContentType.DataSources, MigrationPipelineContentType.Workbooks, MigrationPipelineContentType.ServerToCloudExtractRefreshTasks, - MigrationPipelineContentType.CustomViews + MigrationPipelineContentType.CustomViews, + MigrationPipelineContentType.ServerToCloudSubscriptions ]; - private readonly IConfigReader _configReader; + /// /// Creates a new object. @@ -56,10 +60,8 @@ public class ServerToCloudMigrationPipeline : MigrationPipelineBase /// A config reader to get the REST API configuration. public ServerToCloudMigrationPipeline(IServiceProvider services, IConfigReader configReader) - : base(services) - { - _configReader = configReader; - } + : base(services, configReader) + { } /// @@ -78,6 +80,7 @@ protected override IEnumerable BuildPipeline() yield return CreateMigrateContentAction(); yield return CreateMigrateContentAction(); yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); } /// @@ -85,27 +88,31 @@ public override IContentBatchMigrator GetBatchMigrator() { switch (typeof(TContent)) { - case Type user when user == typeof(IUser): - if (_configReader.Get().BatchPublishingEnabled) - { - return Services.GetRequiredService>(); - } - return Services.GetRequiredService>(); - case Type group when group == typeof(IGroup): - return Services.GetRequiredService>(); - case Type project when project == typeof(IProject): - return Services.GetRequiredService>(); - case Type dataSource when dataSource == typeof(IDataSource): - return Services.GetRequiredService>(); - case Type workbook when workbook == typeof(IWorkbook): - return Services.GetRequiredService>(); case Type extractRefreshTask when extractRefreshTask == typeof(IServerExtractRefreshTask): - return Services.GetRequiredService>(); - case Type customView when customView == typeof(ICustomView): - return Services.GetRequiredService>(); + return Services.GetRequiredService>(); + + case Type subscription when subscription == typeof(IServerSubscription): + return Services.GetRequiredService>(); + default: return base.GetBatchMigrator(); } } + + /// + public override IContentItemConverter GetItemConverter() + { + switch (typeof(TPrepare)) + { + case Type serverExtractRefreshTask when serverExtractRefreshTask == typeof(IServerExtractRefreshTask): + return Services.GetRequiredService>(); + + case Type serverSubscription when serverSubscription == typeof(IServerSubscription): + return Services.GetRequiredService>(); + + default: + return base.GetItemConverter(); + } + } } } diff --git a/src/Tableau.Migration/Engine/Pipelines/ServerToServerMigrationPipeline.cs b/src/Tableau.Migration/Engine/Pipelines/ServerToServerMigrationPipeline.cs new file mode 100644 index 00000000..d8751b41 --- /dev/null +++ b/src/Tableau.Migration/Engine/Pipelines/ServerToServerMigrationPipeline.cs @@ -0,0 +1,96 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; +using Tableau.Migration.Config; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Migrators.Batch; + +namespace Tableau.Migration.Engine.Pipelines +{ + /// + /// implementation to perform migrations from Tableau Server to Tableau Server. + /// + public class ServerToServerMigrationPipeline : MigrationPipelineBase + { + /// + /// Content types that are supported for migrations. + /// + public static readonly ImmutableArray ContentTypes = + [ + MigrationPipelineContentType.Users, + MigrationPipelineContentType.Groups, + MigrationPipelineContentType.Projects, + MigrationPipelineContentType.DataSources, + MigrationPipelineContentType.Workbooks, + MigrationPipelineContentType.ServerToServerExtractRefreshTasks, + MigrationPipelineContentType.CustomViews, + MigrationPipelineContentType.ServerToServerSubscriptions + ]; + + /// + /// Creates a new object. + /// + /// + /// A config reader to get the REST API configuration. + public ServerToServerMigrationPipeline(IServiceProvider services, + IConfigReader configReader) + : base(services, configReader) + { } + + + /// + protected override IEnumerable BuildPipeline() + { + yield return CreateAction(); + + //Migrate users and groups first since many content types depend on them, + //We migrate users before groups because group membership must use + //per-user or per-group requests, and we assume in most cases + //there will be less groups than users. + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); + } + + /// + public override IContentBatchMigrator GetBatchMigrator() + { + switch (typeof(TContent)) + { + case Type extractRefreshTask when extractRefreshTask == typeof(IServerExtractRefreshTask): + return Services.GetRequiredService>(); + + case Type subscription when subscription == typeof(IServerSubscription): + return Services.GetRequiredService>(); + + default: + return base.GetBatchMigrator(); + } + } + } +} diff --git a/src/Tableau.Migration/Engine/Preparation/ContentItemPreparerBase.cs b/src/Tableau.Migration/Engine/Preparation/ContentItemPreparerBase.cs index 27eda772..57ab8bbb 100644 --- a/src/Tableau.Migration/Engine/Preparation/ContentItemPreparerBase.cs +++ b/src/Tableau.Migration/Engine/Preparation/ContentItemPreparerBase.cs @@ -20,8 +20,11 @@ using System.Threading.Tasks; using Tableau.Migration.Content; using Tableau.Migration.Content.Files; +using Tableau.Migration.Engine.Conversion; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers; +using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Preparation { @@ -29,33 +32,42 @@ namespace Tableau.Migration.Engine.Preparation /// Abstract base class for implementations that defines standard features. /// /// The content type. + /// The pull return type. /// The publish type. - public abstract class ContentItemPreparerBase : IContentItemPreparer + public abstract class ContentItemPreparerBase : IContentItemPreparer + where TPrepare : class where TPublish : class { + private readonly IContentItemConverter _converter; private readonly IContentTransformerRunner _transformerRunner; private readonly IDestinationContentReferenceFinderFactory _destinationFinderFactory; + private readonly ISharedResourcesLocalizer _localizer; /// - /// Creates a new object. + /// Creates a new object. /// + /// The migration pipeline. /// A transformer runner. /// The destination finder factory. - public ContentItemPreparerBase( + /// A localizer. + public ContentItemPreparerBase(IMigrationPipeline pipeline, IContentTransformerRunner transformerRunner, - IDestinationContentReferenceFinderFactory destinationFinderFactory) + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) { + _converter = pipeline.GetItemConverter(); _transformerRunner = transformerRunner; _destinationFinderFactory = destinationFinderFactory; + _localizer = localizer; } /// - /// Pulls any additional information needed to prepare/publish a content item. + /// Pulls any additional information needed to prepare a content item for conversion and/or publishing. /// /// The content item to use to pull additional information. /// The cancellation token to obey. - /// The item to use for publishing. - protected abstract Task> PullAsync(ContentMigrationItem item, CancellationToken cancel); + /// The item to use for conversion and/or publishing. + protected abstract Task> PullAsync(ContentMigrationItem item, CancellationToken cancel); /// /// Performs pre-publishing modifications on a publish item. @@ -95,29 +107,50 @@ protected virtual async Task ApplyMappingAsync(TPublish publishItem, ContentLoca if (publishItem is IMappableContainerContent containerContent) { + /* Item belongs to a mappable container, i.e. projects and personal spaces. + * We update the container reference based on the mapped container location so publishing has the right information. + * Some content types (projects) this container is optional, + * while others (e.g. data sources and workbooks) require a container.*/ + IContentReference? newParent; var mappedParentLocation = mappedLocation.Parent(); - + if (mappedParentLocation.IsEmpty) { + //Item is explicitly mapped to the top-level, so should not have a parent container. newParent = null; } - else if(mappedParentLocation != containerContent.Container?.Location) + else if (mappedParentLocation != containerContent.Container?.Location) { - //If the mapping set a new parent, find based on the destination location. + /* Item was pre-mapped for us, but assigned a new parent container. + * Find the destination project reference to assign without double-mapping.*/ var destinationFinder = _destinationFinderFactory.ForDestinationContentType(); newParent = await destinationFinder.FindByMappedLocationAsync(mappedLocation.Parent(), cancel) .ConfigureAwait(false); + + //Without a valid destination container we cannot publish. + if (newParent is null) + { + throw new Exception(string.Format(_localizer[SharedResourceKeys.ContainerParentNotFound], mappedLocation.Parent(), mappedLocation)); + } } - else if(containerContent.Container is not null) + else if (containerContent.Container is not null) { - //If the mapping uses the same parent, find where that parent mapped to. + /* Item was mapped without any change to the container path. + * We apply project mapping to find where the container was mapped to.*/ var destinationFinder = _destinationFinderFactory.ForDestinationContentType(); newParent = await destinationFinder.FindBySourceLocationAsync(containerContent.Container.Location, cancel) .ConfigureAwait(false); + + //Without a valid destination container we cannot publish. + if (newParent is null) + { + throw new Exception(string.Format(_localizer[SharedResourceKeys.ContainerParentNotFound], containerContent.Container.Location)); + } } else { + //Item has a null container. newParent = null; } @@ -177,7 +210,6 @@ await ApplyMappingAsync(publishItem, item.ManifestEntry.MappedLocation, cancel) { item.ManifestEntry.SetFailed(transformResult.Errors); return transformResult; - } publishItem = transformResult.Value; @@ -217,20 +249,31 @@ public async Task> PrepareAsync(ContentMigrationItem if (!pullResult.Success) { item.ManifestEntry.SetFailed(pullResult.Errors); - return pullResult; + return pullResult.CastFailure(); } - var publishItem = pullResult.Value; + var prepareItem = pullResult.Value; /* If we throw (even from cancellation) before we can return the publish item, * make sure the item is disposed as there may be files using disk space. * We clean up orphaned files at the end of the DI scope, but we don't want to * bloat disk usage when we're processing future pages of items.*/ - var prepareResult = await publishItem.DisposeOnThrowAsync( - async () => await PreparePublishItemAsync(item, publishItem, cancel).ConfigureAwait(false) + var prepareResult = await prepareItem.DisposeOnThrowAsync(async () => + { + /* Convert step: Convert the pulled item to a publishable item, + * which may be for different endpoint flavors. + * For example, a Tableau Server schedule needs conversion to a Tableau Cloud schedule. */ + var publishItem = await _converter.ConvertAsync(prepareItem, cancel).ConfigureAwait(false); + + /* Map and transform the publishable item. + * Disposing if any exception prevents our return. */ + return await publishItem.DisposeOnThrowAsync(async () => + await PreparePublishItemAsync(item, publishItem, cancel).ConfigureAwait(false) ).ConfigureAwait(false); - return prepareResult; + }).ConfigureAwait(false); + + return prepareResult; } } } diff --git a/src/Tableau.Migration/Engine/Preparation/EndpointContentItemPreparer.cs b/src/Tableau.Migration/Engine/Preparation/EndpointContentItemPreparer.cs index 8230be29..12770f6e 100644 --- a/src/Tableau.Migration/Engine/Preparation/EndpointContentItemPreparer.cs +++ b/src/Tableau.Migration/Engine/Preparation/EndpointContentItemPreparer.cs @@ -20,6 +20,8 @@ using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers; +using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Preparation { @@ -28,31 +30,37 @@ namespace Tableau.Migration.Engine.Preparation /// the publish item from the source endpoint. /// /// + /// /// - public class EndpointContentItemPreparer : ContentItemPreparerBase + public class EndpointContentItemPreparer : ContentItemPreparerBase + where TPrepare : class where TPublish : class { private readonly ISourceEndpoint _source; /// - /// Creates a new object. + /// Creates a new object. /// /// The source endpoint. + /// /// /// + /// public EndpointContentItemPreparer( ISourceEndpoint source, - IContentTransformerRunner transformerRunner, - IDestinationContentReferenceFinderFactory destinationFinderFactory) - : base(transformerRunner, destinationFinderFactory) + IMigrationPipeline pipeline, + IContentTransformerRunner transformerRunner, + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) + : base(pipeline, transformerRunner, destinationFinderFactory, localizer) { _source = source; } /// - protected override async Task> PullAsync(ContentMigrationItem item, CancellationToken cancel) + protected override async Task> PullAsync(ContentMigrationItem item, CancellationToken cancel) { - return await _source.PullAsync(item.SourceItem, cancel).ConfigureAwait(false); + return await _source.PullAsync(item.SourceItem, cancel).ConfigureAwait(false); } } } diff --git a/src/Tableau.Migration/Engine/Preparation/ExtractRefreshTaskServerToCloudPreparer.cs b/src/Tableau.Migration/Engine/Preparation/ExtractRefreshTaskServerToCloudPreparer.cs index 69e1e437..9fac37de 100644 --- a/src/Tableau.Migration/Engine/Preparation/ExtractRefreshTaskServerToCloudPreparer.cs +++ b/src/Tableau.Migration/Engine/Preparation/ExtractRefreshTaskServerToCloudPreparer.cs @@ -27,6 +27,8 @@ using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers; +using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Preparation { @@ -35,7 +37,7 @@ namespace Tableau.Migration.Engine.Preparation /// the publish item from the source endpoint. /// public class ExtractRefreshTaskServerToCloudPreparer - : EndpointContentItemPreparer + : SourceContentItemPreparer { private readonly IDestinationApiEndpoint? _destinationApi; private readonly IConfigReader _configReader; @@ -44,21 +46,20 @@ public class ExtractRefreshTaskServerToCloudPreparer /// /// Creates a new object. /// - /// The source endpoint. /// The destination endpoint. + /// /// /// + /// /// A config reader. public ExtractRefreshTaskServerToCloudPreparer( - ISourceEndpoint source, IDestinationEndpoint destination, + IMigrationPipeline pipeline, IContentTransformerRunner transformerRunner, IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer, IConfigReader configReader) - : base( - source, - transformerRunner, - destinationFinderFactory) + : base(pipeline, transformerRunner, destinationFinderFactory, localizer) { if (destination is IDestinationApiEndpoint destinationApi) { diff --git a/src/Tableau.Migration/Engine/Preparation/SourceContentItemPreparer.cs b/src/Tableau.Migration/Engine/Preparation/SourceContentItemPreparer.cs index 3d3fa51a..00923a0c 100644 --- a/src/Tableau.Migration/Engine/Preparation/SourceContentItemPreparer.cs +++ b/src/Tableau.Migration/Engine/Preparation/SourceContentItemPreparer.cs @@ -19,6 +19,8 @@ using System.Threading.Tasks; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers; +using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Preparation { @@ -26,19 +28,25 @@ namespace Tableau.Migration.Engine.Preparation /// implementation that publishes the source item as-is /// and does not require extra pulled information. ///
- /// The content type. - public class SourceContentItemPreparer : ContentItemPreparerBase + /// + /// + public class SourceContentItemPreparer : ContentItemPreparerBase where TContent : class + where TPublish : class { /// - /// Creates a new . + /// Creates a new . /// + /// /// /// + /// public SourceContentItemPreparer( + IMigrationPipeline pipeline, IContentTransformerRunner transformerRunner, - IDestinationContentReferenceFinderFactory destinationFinderFactory) - : base(transformerRunner, destinationFinderFactory) + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) + : base(pipeline, transformerRunner, destinationFinderFactory, localizer) { } /// @@ -48,4 +56,28 @@ protected override Task> PullAsync(ContentMigrationItem>(result); } } + + /// + /// implementation that publishes the source item as-is + /// and does not require extra pulled information. + /// + /// + public class SourceContentItemPreparer : SourceContentItemPreparer + where TContent : class + { + /// + /// Creates a new . + /// + /// + /// + /// + /// + public SourceContentItemPreparer( + IMigrationPipeline pipeline, + IContentTransformerRunner transformerRunner, + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) + : base(pipeline, transformerRunner, destinationFinderFactory, localizer) + { } + } } diff --git a/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs b/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs index abb05da9..9384ee0b 100644 --- a/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs +++ b/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs @@ -20,6 +20,7 @@ using System.ComponentModel.DataAnnotations; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Rest.Models.Types; using Tableau.Migration.Content; using Tableau.Migration.Engine.Endpoints; @@ -42,6 +43,7 @@ namespace Tableau.Migration.Engine public class ServerToCloudMigrationPlanBuilder : IServerToCloudMigrationPlanBuilder { private readonly ISharedResourcesLocalizer _localizer; + private readonly ILoggerFactory _loggerFactory; private readonly IMigrationPlanBuilder _innerBuilder; private bool _authTypeMappingAdded; @@ -51,10 +53,15 @@ public class ServerToCloudMigrationPlanBuilder : IServerToCloudMigrationPlanBuil /// Creates a new object. ///
/// The string localizer. + /// The logger factory to create new logger types. /// A general plan builder to wrap. - public ServerToCloudMigrationPlanBuilder(ISharedResourcesLocalizer localizer, IMigrationPlanBuilder innerBuilder) + public ServerToCloudMigrationPlanBuilder( + ISharedResourcesLocalizer localizer, + ILoggerFactory loggerFactory, + IMigrationPlanBuilder innerBuilder) { _localizer = localizer; + _loggerFactory = loggerFactory; _innerBuilder = innerBuilder; } @@ -70,6 +77,8 @@ public ServerToCloudMigrationPlanBuilder(ISharedResourcesLocalizer localizer, IM IContentTransformerBuilder IMigrationPlanBuilder.Transformers => _innerBuilder.Transformers; + PipelineProfile IMigrationPlanBuilder.PipelineProfile => _innerBuilder.PipelineProfile; + IMigrationPlan IMigrationPlanBuilder.Build() => _innerBuilder.Build(); @@ -82,22 +91,13 @@ IMigrationPlanBuilder IMigrationPlanBuilder.AppendDefaultExtensions() IServerToCloudMigrationPlanBuilder IMigrationPlanBuilder.ForServerToCloud() => _innerBuilder.ForServerToCloud(); - IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(Func pipelineFactoryOverride, params MigrationPipelineContentType[] supportedContentTypes) - => _innerBuilder.ForCustomPipelineFactory(pipelineFactoryOverride, supportedContentTypes); - - IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(Func pipelineFactoryOverride, IEnumerable supportedContentTypes) + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(Func pipelineFactoryOverride, params IEnumerable supportedContentTypes) => _innerBuilder.ForCustomPipelineFactory(pipelineFactoryOverride, supportedContentTypes); - IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(params MigrationPipelineContentType[] supportedContentTypes) - => _innerBuilder.ForCustomPipelineFactory(supportedContentTypes); - - IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(IEnumerable supportedContentTypes) + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(params IEnumerable supportedContentTypes) => _innerBuilder.ForCustomPipelineFactory(supportedContentTypes); - IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipeline(params MigrationPipelineContentType[] supportedContentTypes) - => _innerBuilder.ForCustomPipeline(supportedContentTypes); - - IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipeline(IEnumerable supportedContentTypes) + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipeline(params IEnumerable supportedContentTypes) => _innerBuilder.ForCustomPipeline(supportedContentTypes); IMigrationPlanBuilder IMigrationPlanBuilder.FromSource(IMigrationPlanEndpointConfiguration config) @@ -137,66 +137,60 @@ public IServerToCloudMigrationPlanBuilder AppendDefaultServerToCloudExtensions() #region - WithAuthenticationType - /// - public IServerToCloudMigrationPlanBuilder WithSamlAuthenticationType(string domain) - => WithAuthenticationType(AuthenticationTypes.Saml, domain, Constants.LocalDomain); + public IServerToCloudMigrationPlanBuilder WithSamlAuthenticationType(string domain, string? idpConfigurationName = null) + => WithAuthenticationType(idpConfigurationName ?? AuthenticationTypes.Saml, domain, Constants.LocalDomain); /// - public IServerToCloudMigrationPlanBuilder WithTableauIdAuthenticationType(bool mfa = true) + public IServerToCloudMigrationPlanBuilder WithTableauIdAuthenticationType(bool mfa = true, string? idpConfigurationName = null) { if (mfa) { - return WithAuthenticationType(AuthenticationTypes.TableauIdWithMfa, Constants.TableauIdWithMfaDomain, Constants.LocalDomain); + return WithAuthenticationType(idpConfigurationName ?? AuthenticationTypes.TableauIdWithMfa, Constants.TableauIdWithMfaDomain, Constants.LocalDomain); } else { - return WithAuthenticationType(AuthenticationTypes.OpenId, Constants.ExternalDomain, Constants.LocalDomain); + return WithAuthenticationType(idpConfigurationName ?? AuthenticationTypes.OpenId, Constants.ExternalDomain, Constants.LocalDomain); } } /// - public IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authType, string userDomain, string groupDomain) - { - //Register a default mapper for user/group domains based on the authentication type. - _innerBuilder.Mappings.Add(); - _innerBuilder.Mappings.Add(); - _innerBuilder.Options.Configure(new AuthenticationTypeDomainMappingOptions + public IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, string userDomain, string groupDomain) + => WithAuthenticationType(authenticationType, () => { - UserDomain = userDomain, - GroupDomain = groupDomain + _innerBuilder.Mappings.Add(); + _innerBuilder.Mappings.Add(); + _innerBuilder.Options.Configure(new AuthenticationTypeDomainMappingOptions + { + UserDomain = userDomain, + GroupDomain = groupDomain + }); }); - //Configure the default registered auth type transformer to match the user-supplied auth type. - _innerBuilder.Options.Configure(new UserAuthenticationTypeTransformerOptions - { - AuthenticationType = authType - }); - - _authTypeMappingAdded = true; - return this; - } - /// public IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, IAuthenticationTypeDomainMapping authenticationTypeMapping) - { - _innerBuilder.Mappings.Add(authenticationTypeMapping); - _innerBuilder.Mappings.Add(authenticationTypeMapping); - - //Configure the default registered auth type transformer to match the user-supplied auth type. - _innerBuilder.Options.Configure(new UserAuthenticationTypeTransformerOptions + => WithAuthenticationType(authenticationType, () => { - AuthenticationType = authenticationType + _innerBuilder.Mappings.Add(authenticationTypeMapping); + _innerBuilder.Mappings.Add(authenticationTypeMapping); }); - _authTypeMappingAdded = true; - return this; - } - /// public IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, Func? authenticationTypeMappingFactory = null) where TMapping : IAuthenticationTypeDomainMapping + => WithAuthenticationType(authenticationType, () => + { + _innerBuilder.Mappings.Add(authenticationTypeMappingFactory); + _innerBuilder.Mappings.Add(authenticationTypeMappingFactory); + }); + + /// + public IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, Func, CancellationToken, Task> callback) + => WithAuthenticationType(authenticationType, new CallbackAuthenticationTypeDomainMapping(callback, _localizer, _loggerFactory.CreateLogger())); + + private ServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, Action registerMappings) { - _innerBuilder.Mappings.Add(authenticationTypeMappingFactory); - _innerBuilder.Mappings.Add(authenticationTypeMappingFactory); + //Register a default mapper for user/group domains based on the authentication type. + registerMappings(); //Configure the default registered auth type transformer to match the user-supplied auth type. _innerBuilder.Options.Configure(new UserAuthenticationTypeTransformerOptions @@ -208,10 +202,6 @@ public IServerToCloudMigrationPlanBuilder WithAuthenticationType(strin return this; } - /// - public IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, Func, CancellationToken, Task> callback) - => WithAuthenticationType(authenticationType, new CallbackAuthenticationTypeDomainMapping(callback)); - #endregion #region - WithTableauCloudUsernames - diff --git a/src/Tableau.Migration/EquatableException.cs b/src/Tableau.Migration/EquatableException.cs index 5292ea6a..f03ab1b2 100644 --- a/src/Tableau.Migration/EquatableException.cs +++ b/src/Tableau.Migration/EquatableException.cs @@ -50,24 +50,7 @@ public override bool Equals(object? obj) } /// - public bool Equals(T? other) - { - if (other is null) return false; - if (ReferenceEquals(this, other)) return true; - - return GetType() == other.GetType() && Message == other.Message && EqualsCore(other); - } - - /// - /// Determines whether the specified exception is equal to the current exception. - /// Derived classes can override this method to add additional comparison logic. - /// - /// The exception to compare with the current exception. - /// true if the specified exception is equal to the current exception; otherwise, false. - protected virtual bool EqualsCore(T other) - { - return true; // Default implementation, can be overridden by derived classes - } + public virtual bool Equals(T? other) => this.BaseExceptionEquals(other) ?? true; /// public override int GetHashCode() diff --git a/src/Tableau.Migration/ExceptionComparer.cs b/src/Tableau.Migration/ExceptionComparer.cs index 2eef34e2..0243eeb7 100644 --- a/src/Tableau.Migration/ExceptionComparer.cs +++ b/src/Tableau.Migration/ExceptionComparer.cs @@ -16,7 +16,6 @@ // using System; -using System.Collections; using System.Collections.Generic; using System.Reflection; diff --git a/src/Tableau.Migration/ExceptionExtensions.cs b/src/Tableau.Migration/ExceptionExtensions.cs index d7217026..191fab26 100644 --- a/src/Tableau.Migration/ExceptionExtensions.cs +++ b/src/Tableau.Migration/ExceptionExtensions.cs @@ -42,5 +42,25 @@ public static bool IsCancellationException(this Exception exception) return false; } + + internal static bool? BaseExceptionEquals(this Exception a, Exception? b) + { + if (b is null) + { + return false; + } + + if (ReferenceEquals(a, b)) + { + return true; + } + + if (a.GetType() != b.GetType() || !string.Equals(a.Message, b.Message, StringComparison.Ordinal)) + { + return false; + } + + return null; + } } } diff --git a/src/Tableau.Migration/IMigrationCapabilities.cs b/src/Tableau.Migration/IMigrationCapabilities.cs new file mode 100644 index 00000000..15f98d47 --- /dev/null +++ b/src/Tableau.Migration/IMigrationCapabilities.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; + +namespace Tableau.Migration +{ + /// + /// Represents the readonly capabilities of a migration. + /// + public interface IMigrationCapabilities : ICloneable + { + /// + /// Gets a value indicating whether the preflight check has been executed. + /// + bool PreflightCheckExecuted { get; } + + /// + /// Gets the unique list of items that are disabled at the destination. + /// + HashSet ContentTypesDisabledAtDestination { get; } + + /// + /// Gets whether Embedded Credential migration is disabled. + /// + bool EmbeddedCredentialsDisabled { get; } + + /// + /// Creates a new instance of that is a copy of the current instance. + /// + /// A new object that is a copy of this instance. + new IMigrationCapabilities Clone(); + } +} diff --git a/src/Tableau.Migration/IMigrationCapabilitiesEditor.cs b/src/Tableau.Migration/IMigrationCapabilitiesEditor.cs new file mode 100644 index 00000000..f67c9701 --- /dev/null +++ b/src/Tableau.Migration/IMigrationCapabilitiesEditor.cs @@ -0,0 +1,49 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; + +namespace Tableau.Migration +{ + /// + /// Represents the capabilities of a migration. + /// + public interface IMigrationCapabilitiesEditor : IMigrationCapabilities + { + /// + /// Gets or sets a value indicating whether the preflight check has been executed. + /// + new bool PreflightCheckExecuted { get; set; } + + /// + /// Gets or sets whether Embedded Credential migration is disabled. + /// + new bool EmbeddedCredentialsDisabled { get; set; } + + /// + /// Gets or sets the unique list of items that are disabled at the destination. + /// + new HashSet ContentTypesDisabledAtDestination { get; set; } + + /// + /// Creates a new instance of that is a copy of the current instance. + /// + /// A new object that is a copy of this instance. + new IMigrationCapabilities Clone(); + } +} diff --git a/src/Tableau.Migration/IMigrationManifest.cs b/src/Tableau.Migration/IMigrationManifest.cs index ebf1ea76..24dba181 100644 --- a/src/Tableau.Migration/IMigrationManifest.cs +++ b/src/Tableau.Migration/IMigrationManifest.cs @@ -36,6 +36,11 @@ public interface IMigrationManifest : IEquatable ///
Guid MigrationId { get; } + /// + /// Gets the profile of the pipeline that was executed to produce this manifest. + /// + PipelineProfile PipelineProfile { get; } + /// /// Gets top-level errors that are not related to any Tableau content item but occurred during the migration. /// diff --git a/src/Tableau.Migration/IMigrationPlanBuilder.cs b/src/Tableau.Migration/IMigrationPlanBuilder.cs index ecd843e8..befee7d5 100644 --- a/src/Tableau.Migration/IMigrationPlanBuilder.cs +++ b/src/Tableau.Migration/IMigrationPlanBuilder.cs @@ -44,30 +44,14 @@ public interface IMigrationPlanBuilder /// An initializer function to build the pipeline factory. /// The supported content types of the custom pipeline. /// The same plan builder object for fluent API calls. - IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, params MigrationPipelineContentType[] supportedContentTypes); - - /// - /// Initializes the plan to perform a custom migration pipeline using the given pipeline factory. - /// - /// An initializer function to build the pipeline factory. - /// The supported content types of the custom pipeline. - /// The same plan builder object for fluent API calls. - IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, IEnumerable supportedContentTypes); - - /// - /// Initializes the plan to perform a custom migration pipeline using the given pipeline factory. - /// - /// The supported content types of the custom pipeline. - /// The same plan builder object for fluent API calls. - IMigrationPlanBuilder ForCustomPipelineFactory(params MigrationPipelineContentType[] supportedContentTypes) - where T : IMigrationPipelineFactory; + IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, params IEnumerable supportedContentTypes); /// /// Initializes the plan to perform a custom migration pipeline using the given pipeline factory. /// /// The supported content types of the custom pipeline. /// The same plan builder object for fluent API calls. - IMigrationPlanBuilder ForCustomPipelineFactory(IEnumerable supportedContentTypes) + IMigrationPlanBuilder ForCustomPipelineFactory(params IEnumerable supportedContentTypes) where T : IMigrationPipelineFactory; /// @@ -75,15 +59,7 @@ IMigrationPlanBuilder ForCustomPipelineFactory(IEnumerable /// The supported content types of the custom pipeline. /// The same plan builder object for fluent API calls. - IMigrationPlanBuilder ForCustomPipeline(params MigrationPipelineContentType[] supportedContentTypes) - where T : IMigrationPipeline; - - /// - /// Initializes the plan to perform a custom migration pipeline. - /// - /// The supported content types of the custom pipeline. - /// The same plan builder object for fluent API calls. - IMigrationPlanBuilder ForCustomPipeline(IEnumerable supportedContentTypes) + IMigrationPlanBuilder ForCustomPipeline(params IEnumerable supportedContentTypes) where T : IMigrationPipeline; /// @@ -165,6 +141,11 @@ IMigrationPlanBuilder ForCustomPipeline(IEnumerable IContentTransformerBuilder Transformers { get; } + /// + /// Gets the pipeline profile to execute. + /// + PipelineProfile PipelineProfile { get; } + /// /// Finalizes the based on the current state. /// diff --git a/src/Tableau.Migration/IServerToCloudMigrationPlanBuilder.cs b/src/Tableau.Migration/IServerToCloudMigrationPlanBuilder.cs index 09aa72c4..e26ab0bb 100644 --- a/src/Tableau.Migration/IServerToCloudMigrationPlanBuilder.cs +++ b/src/Tableau.Migration/IServerToCloudMigrationPlanBuilder.cs @@ -45,22 +45,38 @@ public interface IServerToCloudMigrationPlanBuilder : IMigrationPlanBuilder /// on the SAML authentication type. /// /// The domain to map users and groups to. + /// + /// The IdP configuration name for the authentication type to assign to users. + /// Should be null when the destination site is Tableau Server or has a single authentication configuration. + /// Should be non-null when the destination site is Tableau Cloud and has multiple authentication configurations. + /// /// The same plan builder object for fluent API calls. - IServerToCloudMigrationPlanBuilder WithSamlAuthenticationType(string domain); + IServerToCloudMigrationPlanBuilder WithSamlAuthenticationType(string domain, string? idpConfigurationName = null); /// /// Adds an object to map user and group domains based /// on the Tableau ID authentication type. /// /// Whether or not MFA is used, defaults to true. + /// + /// The IdP configuration name for the authentication type to assign to users. + /// Should be null when the destination site is Tableau Server or has a single authentication configuration. + /// Should be non-null when the destination site is Tableau Cloud and has multiple authentication configurations. + /// /// The same plan builder object for fluent API calls. - IServerToCloudMigrationPlanBuilder WithTableauIdAuthenticationType(bool mfa = true); + IServerToCloudMigrationPlanBuilder WithTableauIdAuthenticationType(bool mfa = true, string? idpConfigurationName = null); /// /// Adds an object to map user and group domains based /// on the destination authentication type. + /// Use when the destination site is Tableau Server or has a single authentication configuration. /// - /// The authentication type to assign to users. + /// + /// The authentication type to assign to users. + /// For sites without multiple authentication types an authSetting value from the + /// Tableau API should be used. + /// If the site has multiple authentication types the IdP configuration name shown in the authentication configuration list should be used. + /// /// The domain to map users to. /// The domain to map groups to. /// The same plan builder object for fluent API calls. @@ -69,8 +85,14 @@ public interface IServerToCloudMigrationPlanBuilder : IMigrationPlanBuilder /// /// Adds an object to map user and group domains based /// on the destination authentication type. + /// Use when the destination site is Tableau Server or has a single authentication configuration. /// - /// An authentication type to assign to users. + /// + /// The authentication type to assign to users. + /// For sites without multiple authentication types an authSetting value from the + /// Tableau API should be used. + /// If the site has multiple authentication types the IdP configuration name shown in the authentication configuration list should be used. + /// /// The mapping to execute. /// The same plan builder object for fluent API calls. IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, IAuthenticationTypeDomainMapping authenticationTypeMapping); @@ -78,9 +100,15 @@ public interface IServerToCloudMigrationPlanBuilder : IMigrationPlanBuilder /// /// Adds an object to map user and group domains based /// on the destination authentication type. + /// Use when the destination site is Tableau Server or has a single authentication configuration. /// /// The mapping type. - /// An authentication type to assign to users. + /// + /// The authentication type to assign to users. + /// For sites without multiple authentication types an authSetting value from the + /// Tableau API should be used. + /// If the site has multiple authentication types the IdP configuration name shown in the authentication configuration list should be used. + /// /// An initializer function to create the object from, potentially from the migration-scoped dependency injection container. /// The same plan builder object for fluent API calls. IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, Func? authenticationTypeMappingFactory = null) @@ -89,12 +117,17 @@ IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authe /// /// Adds an object to map user and group domains based /// on the destination authentication type. + /// Use when the destination site is Tableau Server or has a single authentication configuration. /// - /// An authentication type to assign to users. + /// + /// The authentication type to assign to users. + /// For sites without multiple authentication types an authSetting value from the + /// Tableau API should be used. + /// If the site has multiple authentication types the IdP configuration name shown in the authentication configuration list should be used. + /// /// A callback to call for the mapping. /// The same plan builder object for fluent API calls. - IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, - Func, CancellationToken, Task> callback); + IServerToCloudMigrationPlanBuilder WithAuthenticationType(string authenticationType, Func, CancellationToken, Task> callback); #endregion diff --git a/src/Tableau.Migration/JsonConverters/ExceptionJsonConverter.cs b/src/Tableau.Migration/JsonConverters/ExceptionJsonConverter.cs index 5903d685..213fa037 100644 --- a/src/Tableau.Migration/JsonConverters/ExceptionJsonConverter.cs +++ b/src/Tableau.Migration/JsonConverters/ExceptionJsonConverter.cs @@ -37,9 +37,21 @@ public override void Write(Utf8JsonWriter writer, TException value, JsonSerializ { writer.WriteStartObject(); JsonWriterUtils.WriteExceptionProperties(ref writer, value); + + WriteExtraExceptionProperties(writer, value, options); + writer.WriteEndObject(); } + /// + /// Serializes non-base exception properties. + /// + /// The to write to. + /// The object to serialize. + /// The to use for serialization. + protected virtual void WriteExtraExceptionProperties(Utf8JsonWriter writer, TException value, JsonSerializerOptions options) + { } + /// /// Reads the JSON representation of an Exception object. /// diff --git a/src/Tableau.Migration/JsonConverters/Exceptions/UnknownException.cs b/src/Tableau.Migration/JsonConverters/Exceptions/UnknownException.cs new file mode 100644 index 00000000..5b5db7bc --- /dev/null +++ b/src/Tableau.Migration/JsonConverters/Exceptions/UnknownException.cs @@ -0,0 +1,57 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Content.Schedules; + +namespace Tableau.Migration.JsonConverters.Exceptions +{ + /// + /// The exception that is thrown when a that was serialized and then deserialized was not able to create an exception of the correct type. + /// + /// This means that the either the serializer or deserializer has a bug. + public class UnknownException : EquatableException + { + /// + /// The exception that is thrown when a that was serialized and then deserialized was not able to create an exception of the correct type. + /// + public UnknownException(string message) : base(message) + { } + + /// + /// The exception that is thrown when a that was serialized and then deserialized was not able to create an exception of the correct type. + /// + /// The type of the exception that was expected. + /// The message that describes the error. + public UnknownException(string originalExceptionType, string message) : base(message) + { + Data.Add("OriginalExceptionType", originalExceptionType); + } + + /// + /// The exception that is thrown when a that was serialized and then deserialized was not able to create an exception of the correct type. + /// + /// The type of the exception that was expected. + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public UnknownException(string originalExceptionType, string message, Exception innerException) + : base(message, innerException) + { + Data.Add("OriginalExceptionType", originalExceptionType); + } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/JsonConverters/JsonReaderUtils.cs b/src/Tableau.Migration/JsonConverters/JsonReaderUtils.cs index 56aa25b6..c1e6dd1a 100644 --- a/src/Tableau.Migration/JsonConverters/JsonReaderUtils.cs +++ b/src/Tableau.Migration/JsonConverters/JsonReaderUtils.cs @@ -16,11 +16,7 @@ // using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Text.Json; -using System.Threading.Tasks; namespace Tableau.Migration.JsonConverters { diff --git a/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs b/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs index e1ba76f2..c3014d78 100644 --- a/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs +++ b/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs @@ -48,6 +48,7 @@ public override RestException Read(ref Utf8JsonReader reader, Type typeToConvert string? detail = null; string? summary = null; string? exceptionMessage = null; + string? fullStackTrace = null; while (reader.Read()) { @@ -93,6 +94,10 @@ public override RestException Read(ref Utf8JsonReader reader, Type typeToConvert case nameof(RestException.Message): exceptionMessage = reader.GetString(); break; + + case nameof(RestException.FullStackTrace): + fullStackTrace = reader.GetString(); + break; } } else if (reader.TokenType == JsonTokenType.EndObject) @@ -104,7 +109,7 @@ public override RestException Read(ref Utf8JsonReader reader, Type typeToConvert Guard.AgainstNull(exceptionMessage, nameof(exceptionMessage)); // Use the internal constructor for deserialization - return new RestException(httpMethod, requestUri, correlationId, new Error { Code = code, Detail = detail, Summary = summary }, exceptionMessage); + return new RestException(httpMethod, requestUri, correlationId, new Error { Code = code, Detail = detail, Summary = summary }, fullStackTrace, exceptionMessage); } /// @@ -147,6 +152,11 @@ public override void Write(Utf8JsonWriter writer, RestException value, JsonSeria writer.WriteString(nameof(RestException.Summary), value.Summary); } + if (value.FullStackTrace != null) + { + writer.WriteString(nameof(RestException.FullStackTrace), value.FullStackTrace.ToString()); + } + JsonWriterUtils.WriteExceptionProperties(ref writer, value); writer.WriteEndObject(); diff --git a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableEntryCollection.cs b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableEntryCollection.cs index 79756a7a..86a32ab5 100644 --- a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableEntryCollection.cs +++ b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableEntryCollection.cs @@ -15,12 +15,7 @@ // limitations under the License. // -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using Tableau.Migration.Engine.Manifest; namespace Tableau.Migration.JsonConverters.SerializableObjects { diff --git a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs index bec8eac4..f0f34eb2 100644 --- a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs +++ b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs @@ -50,6 +50,11 @@ public class SerializableManifestEntry : IMigrationManifestEntry /// public string? Status { get; set; } + /// + /// Gets or sets the reason why the content was skipped, if applicable. + /// + public string? SkippedReason { get; set; } + /// /// Gets or sets a value indicating whether the content has been migrated. /// @@ -75,6 +80,7 @@ internal SerializableManifestEntry(IMigrationManifestEntry entry) MappedLocation = new SerializableContentLocation(entry.MappedLocation); Destination = entry.Destination == null ? null : new SerializableContentReference(entry.Destination); Status = entry.Status.ToString(); + SkippedReason = entry.SkippedReason; HasMigrated = entry.HasMigrated; Errors = entry.Errors.Select(e => new SerializableException(e)).ToList(); @@ -88,6 +94,8 @@ internal SerializableManifestEntry(IMigrationManifestEntry entry) MigrationManifestEntryStatus IMigrationManifestEntry.Status => (MigrationManifestEntryStatus)Enum.Parse(typeof(MigrationManifestEntryStatus), Status!); + string IMigrationManifestEntry.SkippedReason => SkippedReason ?? string.Empty; + bool IMigrationManifestEntry.HasMigrated => HasMigrated; IReadOnlyList IMigrationManifestEntry.Errors @@ -126,6 +134,8 @@ public void VerifyDeseralization() Source.VerifyDeserialization(); MappedLocation.VerifyDeseralization(); + + Destination?.VerifyDeserialization(); } /// diff --git a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableMigrationManifest.cs b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableMigrationManifest.cs index 6fe922c7..eb350e51 100644 --- a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableMigrationManifest.cs +++ b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableMigrationManifest.cs @@ -19,9 +19,8 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using Microsoft.Extensions.Logging; +using System.Reflection; using Tableau.Migration.Engine.Manifest; -using Tableau.Migration.Resources; namespace Tableau.Migration.JsonConverters.SerializableObjects { @@ -40,6 +39,12 @@ public class SerializableMigrationManifest /// public Guid? MigrationId { get; set; } + /// + /// Gets or sets the profile of the pipeline that will be built and + /// + /// Defaults to ServerToCloud for backward compatiblity. + public PipelineProfile? PipelineProfile { get; set; } = Migration.PipelineProfile.ServerToCloud; + /// /// Gets or sets the list of errors encountered during the migration process. /// @@ -68,6 +73,7 @@ public SerializableMigrationManifest(IMigrationManifest manifest) { PlanId = manifest.PlanId; MigrationId = manifest.MigrationId; + PipelineProfile = manifest.PipelineProfile; ManifestVersion = manifest.ManifestVersion; Errors = manifest.Errors.Select(e => new SerializableException(e)).ToList(); @@ -84,25 +90,33 @@ public SerializableMigrationManifest(IMigrationManifest manifest) /// /// Converts the serializable migration manifest back into an instance. /// - /// The shared resources localizer. - /// The logger factory. /// An instance of . - public IMigrationManifest ToMigrationManifest(ISharedResourcesLocalizer localizer, ILoggerFactory loggerFactory) + public IMigrationManifest ToMigrationManifest() { VerifyDeserialization(); // Create the manifest to return - var manifest = new MigrationManifest(localizer, loggerFactory, PlanId!.Value, MigrationId!.Value); + var manifest = new MigrationManifest(PlanId!.Value, MigrationId!.Value, PipelineProfile!.Value); // Get the Tableau.Migration assembly to get the type from later - var loadedAssemblies = AppDomain.CurrentDomain.GetAssemblies(); - var tableauMigrationAssembly = loadedAssemblies.Where(a => a.ManifestModule.Name == "Tableau.Migration.dll").First(); + var tableauMigrationAssembly = Assembly.GetExecutingAssembly(); // Copy the entries to the manifest foreach (var partitionTypeStr in Entries!.Keys) { var partitionType = tableauMigrationAssembly.GetType(partitionTypeStr); - Guard.AgainstNull(partitionType, nameof(partitionType)); + + if (partitionType is null) + { + // This means the manifest has a partition type that is unknown. + // This usually happens when the manifest is newer then the current version of the application. + // + // This should not happen during normal migration-sdk usage, but may happen if tools like + // Manifest Analyzer or Manifest Explorer have not been updated. + // + // In these cases, we'll just skip the partition. + continue; + } var partition = manifest.Entries.GetOrCreatePartition(partitionType); @@ -121,6 +135,7 @@ internal void VerifyDeserialization() { Guard.AgainstNull(PlanId, nameof(PlanId)); Guard.AgainstNull(MigrationId, nameof(MigrationId)); + Guard.AgainstNull(PipelineProfile, nameof(PipelineProfile)); Guard.AgainstNull(Errors, nameof(Errors)); Guard.AgainstNull(Entries, nameof(Entries)); Guard.AgainstNull(ManifestVersion, nameof(ManifestVersion)); diff --git a/src/Tableau.Migration/JsonConverters/SerializedExceptionJsonConverter.cs b/src/Tableau.Migration/JsonConverters/SerializedExceptionJsonConverter.cs index ec34ee00..db1d82b4 100644 --- a/src/Tableau.Migration/JsonConverters/SerializedExceptionJsonConverter.cs +++ b/src/Tableau.Migration/JsonConverters/SerializedExceptionJsonConverter.cs @@ -16,12 +16,14 @@ // using System; +using System.Linq; +using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; using Tableau.Migration.Api.Models; +using Tableau.Migration.JsonConverters.Exceptions; using Tableau.Migration.JsonConverters.SerializableObjects; - namespace Tableau.Migration.JsonConverters { /// @@ -77,13 +79,17 @@ internal static string GetNamespace(string fullTypeName) string exceptionNamespace = GetNamespace(exceptionTypeStr); Type? exceptionType = null; - if (exceptionNamespace == "System") - { - exceptionType = Type.GetType($"{exceptionTypeStr}"); - } - else + Type? originalExceptionType = null; + if(exceptionNamespace.StartsWith("System")) { - exceptionType = Type.GetType($"{exceptionTypeStr}, {exceptionNamespace}"); + if (exceptionNamespace == "System") + { + exceptionType = Type.GetType(exceptionTypeStr); + } + else + { + exceptionType = Type.GetType($"{exceptionTypeStr}, {exceptionNamespace}"); + } } // Check if this is a built-in exception type @@ -104,6 +110,16 @@ internal static string GetNamespace(string fullTypeName) } } + else // This is a built in exception type + { + // Check if the exception type has a constructor that takes a single string parameter + if (!HasStringConstructor(exceptionType)) + { + // If the exceptionType can't be created via a single message string, then use the base Exception type + originalExceptionType = exceptionType; + exceptionType = typeof(UnknownException); + } + } // Make sure the next property is the Exception JsonReaderUtils.ReadAndAssertPropertyName(ref reader, Constants.EXCEPTION); @@ -114,6 +130,12 @@ internal static string GetNamespace(string fullTypeName) Guard.AgainstNull(ex, nameof(ex)); + // If this is an unknown exception type, add the original exception type to the Data dictionary + if (exceptionType == typeof(UnknownException)) + { + ex.Data.Add("OriginalExceptionType", originalExceptionType); + } + ret = new SerializableException(ex); JsonReaderUtils.AssertEndObject(ref reader); @@ -148,5 +170,23 @@ public override void Write(Utf8JsonWriter writer, SerializableException value, J // End of serialized exception object writer.WriteEndObject(); } + + /// + /// Checks if the specified type has a constructor that takes a single string parameter. + /// + /// The type to check for a string constructor. + /// true if the type has a constructor with a single string parameter; otherwise, false. + private bool HasStringConstructor(Type type) + { + // Get all constructors of the type + ConstructorInfo[] constructors = type.GetConstructors(); + + // Check if any constructor has a single parameter of type string + return constructors.Any(c => + { + ParameterInfo[] parameters = c.GetParameters(); + return parameters.Length == 1 && parameters[0].ParameterType == typeof(string); + }); + } } } diff --git a/src/Tableau.Migration/JsonConverters/TableauInstanceTypeNotSupportedExceptionJsonConverter.cs b/src/Tableau.Migration/JsonConverters/TableauInstanceTypeNotSupportedExceptionJsonConverter.cs new file mode 100644 index 00000000..d720e224 --- /dev/null +++ b/src/Tableau.Migration/JsonConverters/TableauInstanceTypeNotSupportedExceptionJsonConverter.cs @@ -0,0 +1,65 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Text.Json; + +namespace Tableau.Migration.JsonConverters +{ + internal sealed class TableauInstanceTypeNotSupportedExceptionJsonConverter : ExceptionJsonConverter + { + /// + protected override void WriteExtraExceptionProperties(Utf8JsonWriter writer, TableauInstanceTypeNotSupportedException value, JsonSerializerOptions options) + { + writer.WritePropertyName(nameof(TableauInstanceTypeNotSupportedException.UnsupportedInstanceType)); + JsonSerializer.Serialize(writer, value.UnsupportedInstanceType, options); + } + + public override TableauInstanceTypeNotSupportedException? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + TableauInstanceType? unsupportedInstanceType = null; + string? message = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.PropertyName) + { + var propertyName = reader.GetString(); + reader.Read(); // Move to the property value. + + if (propertyName == nameof(TableauInstanceTypeNotSupportedException.UnsupportedInstanceType)) + { + unsupportedInstanceType = JsonSerializer.Deserialize(ref reader, options); + } + else if (propertyName == nameof(Exception.Message)) + { + message = reader.GetString(); + } + } + else if (reader.TokenType == JsonTokenType.EndObject) + { + break; // End of the object. + } + } + + Guard.AgainstNull(unsupportedInstanceType, nameof(unsupportedInstanceType)); + Guard.AgainstNull(message, nameof(message)); // Message could be an empty string, so just check null + + return new TableauInstanceTypeNotSupportedException(unsupportedInstanceType.Value, message); + } + } +} diff --git a/src/Tableau.Migration/JsonConverters/TimeoutJobExceptionJsonConverter.cs b/src/Tableau.Migration/JsonConverters/TimeoutJobExceptionJsonConverter.cs index b49f3a27..4b1b8d10 100644 --- a/src/Tableau.Migration/JsonConverters/TimeoutJobExceptionJsonConverter.cs +++ b/src/Tableau.Migration/JsonConverters/TimeoutJobExceptionJsonConverter.cs @@ -25,11 +25,8 @@ namespace Tableau.Migration.JsonConverters /// /// JsonConverter that serializes a . It does not support reading exceptions back in. /// - internal class TimeoutJobExceptionJsonConverter : JsonConverter + internal sealed class TimeoutJobExceptionJsonConverter : JsonConverter { - public TimeoutJobExceptionJsonConverter() - { } - public override void Write(Utf8JsonWriter writer, TimeoutJobException value, JsonSerializerOptions options) { writer.WriteStartObject(); @@ -77,7 +74,5 @@ public override void Write(Utf8JsonWriter writer, TimeoutJobException value, Jso return new TimeoutJobException(job, message); } - - } } diff --git a/src/Tableau.Migration/MigrationCapabilities.cs b/src/Tableau.Migration/MigrationCapabilities.cs new file mode 100644 index 00000000..55049106 --- /dev/null +++ b/src/Tableau.Migration/MigrationCapabilities.cs @@ -0,0 +1,54 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; + +namespace Tableau.Migration +{ + /// + /// Represents the capabilities of a migration and are set via s. + /// + internal class MigrationCapabilities : IMigrationCapabilitiesEditor + { + /// + public bool PreflightCheckExecuted { get; set; } = false; + + /// + public bool EmbeddedCredentialsDisabled { get; set; } = false; + + /// + public HashSet ContentTypesDisabledAtDestination { get; set; } = []; + + /// + public IMigrationCapabilities Clone() + { + return new MigrationCapabilities + { + PreflightCheckExecuted = PreflightCheckExecuted, + EmbeddedCredentialsDisabled = EmbeddedCredentialsDisabled, + ContentTypesDisabledAtDestination = ContentTypesDisabledAtDestination + }; + } + + /// + object ICloneable.Clone() + { + return Clone(); + } + } +} diff --git a/src/Tableau.Migration/Net/Handlers/AuthenticationHandler.cs b/src/Tableau.Migration/Net/Handlers/AuthenticationHttpHandler.cs similarity index 91% rename from src/Tableau.Migration/Net/Handlers/AuthenticationHandler.cs rename to src/Tableau.Migration/Net/Handlers/AuthenticationHttpHandler.cs index 3cb9b9c0..e0d64ab5 100644 --- a/src/Tableau.Migration/Net/Handlers/AuthenticationHandler.cs +++ b/src/Tableau.Migration/Net/Handlers/AuthenticationHttpHandler.cs @@ -24,11 +24,11 @@ namespace Tableau.Migration.Net.Handlers { - internal class AuthenticationHandler : DelegatingHandler + internal class AuthenticationHttpHandler : DelegatingHandler { private readonly IAuthenticationTokenProvider _tokenProvider; - public AuthenticationHandler(IAuthenticationTokenProvider tokenProvider) + public AuthenticationHttpHandler(IAuthenticationTokenProvider tokenProvider) { _tokenProvider = tokenProvider; } @@ -40,11 +40,13 @@ protected override async Task SendAsync(HttpRequestMessage { return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } - + // Use the current token. var requestAuthToken = await _tokenProvider.GetAsync(cancellationToken).ConfigureAwait(false); if (requestAuthToken is not null) + { request.SetRestAuthenticationToken(requestAuthToken); + } var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -59,10 +61,11 @@ protected override async Task SendAsync(HttpRequestMessage // Set the new token for the retry. var refreshedAuthToken = await _tokenProvider.GetAsync(cancellationToken).ConfigureAwait(false); if (refreshedAuthToken is not null) + { request.SetRestAuthenticationToken(refreshedAuthToken); - + } // Re-send a single time, and rely on other resilience to retry more than that. - return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/Tableau.Migration/Net/Handlers/LoggingHandler.cs b/src/Tableau.Migration/Net/Handlers/LoggingHttpHandler.cs similarity index 58% rename from src/Tableau.Migration/Net/Handlers/LoggingHandler.cs rename to src/Tableau.Migration/Net/Handlers/LoggingHttpHandler.cs index f98f1fb1..8a58d5bd 100644 --- a/src/Tableau.Migration/Net/Handlers/LoggingHandler.cs +++ b/src/Tableau.Migration/Net/Handlers/LoggingHttpHandler.cs @@ -22,42 +22,28 @@ namespace Tableau.Migration.Net.Handlers { - internal class LoggingHandler : DelegatingHandler + internal class LoggingHttpHandler : DelegatingHandler { private readonly INetworkTraceLogger _traceLogger; - public LoggingHandler(INetworkTraceLogger traceLogger) + public LoggingHttpHandler(INetworkTraceLogger traceLogger) { _traceLogger = traceLogger; } - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { try { - var response = await base - .SendAsync(request, cancellationToken) - .ConfigureAwait(false); + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - await _traceLogger - .WriteNetworkLogsAsync( - request, - response, - cancellationToken) - .ConfigureAwait(false); + await _traceLogger.WriteNetworkLogsAsync(request, response, cancellationToken).ConfigureAwait(false); return response; } catch (Exception ex) { - await _traceLogger - .WriteNetworkExceptionLogsAsync( - request, - ex, - cancellationToken) - .ConfigureAwait(false); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, ex, cancellationToken).ConfigureAwait(false); throw; } diff --git a/src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHandler.cs b/src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHeaderHttpHandler.cs similarity index 96% rename from src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHandler.cs rename to src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHeaderHttpHandler.cs index 85dfeb92..f8af91ac 100644 --- a/src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHandler.cs +++ b/src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHeaderHttpHandler.cs @@ -25,7 +25,7 @@ /// /// A handler that adds a unique request ID to the HTTP request and response headers. /// -public class RequestCorrelationIdHandler : DelegatingHandler +public class RequestCorrelationIdHeaderHttpHandler : DelegatingHandler { /// /// Sends an HTTP request with a unique request ID and adds the same ID to the response headers. diff --git a/src/Tableau.Migration/Net/Handlers/UserAgentHttpMessageHandler.cs b/src/Tableau.Migration/Net/Handlers/UserAgentHeaderHttpHandler.cs similarity index 90% rename from src/Tableau.Migration/Net/Handlers/UserAgentHttpMessageHandler.cs rename to src/Tableau.Migration/Net/Handlers/UserAgentHeaderHttpHandler.cs index 8b4a8e9a..741db8b1 100644 --- a/src/Tableau.Migration/Net/Handlers/UserAgentHttpMessageHandler.cs +++ b/src/Tableau.Migration/Net/Handlers/UserAgentHeaderHttpHandler.cs @@ -24,11 +24,11 @@ namespace Tableau.Migration.Net.Handlers /// /// Handler that will add the SDK user agent to all requests /// - internal class UserAgentHttpMessageHandler : DelegatingHandler + internal class UserAgentHeaderHttpHandler : DelegatingHandler { private readonly IUserAgentProvider _userAgentProvider; - public UserAgentHttpMessageHandler(IUserAgentProvider userAgentProvider) + public UserAgentHeaderHttpHandler(IUserAgentProvider userAgentProvider) { _userAgentProvider = userAgentProvider; } diff --git a/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs index 0be81e29..ef0bbf99 100644 --- a/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs @@ -56,11 +56,11 @@ internal static IServiceCollection AddHttpServices(this IServiceCollection servi .AddSingleton() .AddSingleton() .AddTransient() - .AddTransient() - .AddTransient() - .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() .AddTransient() - .AddTransient() + .AddTransient() // Keeping a single HttpClient instance alive for a long duration is a common pattern used before the inception // of IHttpClientFactory. This pattern becomes unnecessary after migrating to IHttpClientFactory. // Source: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-7.0#httpclient-and-lifetime-management @@ -69,8 +69,7 @@ internal static IServiceCollection AddHttpServices(this IServiceCollection servi var clientFactory = provider.GetRequiredService(); return new DefaultHttpClient( - clientFactory.CreateClient( - nameof(DefaultHttpClient)), + clientFactory.CreateClient(nameof(DefaultHttpClient)), provider.GetRequiredService()); }) // Resilience strategy builders - the order here is important for dependency injection. @@ -85,10 +84,11 @@ internal static IServiceCollection AddHttpServices(this IServiceCollection servi // The default handler lifetime is two minutes. The default value can be overridden on a per named client basis .AddScopedHttpClient(nameof(DefaultHttpClient)) // From microsoft documentation: + // From Microsoft documentation: // https://learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests?view=aspnetcore-7.0#outgoing-request-middleware // Multiple handlers can be registered in the order that they should execute. // Each handler wraps the next handler until the final HttpClientHandler executes the request. - .AddHttpMessageHandler(); + .AddHttpMessageHandler(); httpClientBuilder.AddResilienceHandler(Constants.USER_AGENT_PREFIX, static (pipelineBuilder, ctx) => { @@ -111,9 +111,9 @@ internal static IServiceCollection AddHttpServices(this IServiceCollection servi }); httpClientBuilder - .AddHttpMessageHandler() - .AddHttpMessageHandler() - .AddHttpMessageHandler() + .AddHttpMessageHandler() + .AddHttpMessageHandler() + .AddHttpMessageHandler() .AddHttpMessageHandler(); //Must be last for simulation to function. //Bootstrap and scope state tracking services. diff --git a/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs b/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs index 720b09fa..4836d1d4 100644 --- a/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs +++ b/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs @@ -44,9 +44,9 @@ internal static class MediaTypeHeaderValueExtensions }, StringComparer.OrdinalIgnoreCase); - internal static bool IsUtf8(this MediaTypeHeaderValue header) + internal static bool IsUtf8(this MediaTypeHeaderValue? header) { - if (header.CharSet is not null) + if (header?.CharSet is not null) { try { @@ -64,16 +64,18 @@ internal static bool IsUtf8(this MediaTypeHeaderValue header) return false; } - internal static bool IsHtml(this MediaTypeHeaderValue header) => IsMediaType(header, MediaTypeNames.Text.Html); + internal static bool IsHtml(this MediaTypeHeaderValue? header) => IsMediaType(header, MediaTypeNames.Text.Html); - internal static bool IsJson(this MediaTypeHeaderValue header) => IsMediaType(header, MediaTypeNames.Application.Json); + internal static bool IsJson(this MediaTypeHeaderValue? header) => IsMediaType(header, MediaTypeNames.Application.Json); - internal static bool IsXml(this MediaTypeHeaderValue header) => IsMediaType(header, MediaTypeNames.Application.Xml); + internal static bool IsXml(this MediaTypeHeaderValue? header) => IsMediaType(header, MediaTypeNames.Application.Xml); - internal static bool IsText(this MediaTypeHeaderValue header) + internal static bool IsOctetStream(this MediaTypeHeaderValue? header) => IsMediaType(header, MediaTypeNames.Application.Octet); + + internal static bool IsText(this MediaTypeHeaderValue? header) { // Media type cannot be null - if (header.MediaType!.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) + if (header?.MediaType?.StartsWith("text/", StringComparison.OrdinalIgnoreCase) ?? false) { return true; } @@ -81,7 +83,7 @@ internal static bool IsText(this MediaTypeHeaderValue header) return false; } - internal static bool LogsAsText(this MediaTypeHeaderValue header) + internal static bool LogsAsText(this MediaTypeHeaderValue? header) { if (header.IsText()) { @@ -89,7 +91,7 @@ internal static bool LogsAsText(this MediaTypeHeaderValue header) } // Media type cannot be null - if (_loggingOtherTextMediaTypes.Contains(header.MediaType!)) + if (_loggingOtherTextMediaTypes.Contains(header?.MediaType ?? string.Empty)) { return true; } @@ -97,9 +99,9 @@ internal static bool LogsAsText(this MediaTypeHeaderValue header) return false; } - private static bool IsMediaType(MediaTypeHeaderValue header, string mediaType) + private static bool IsMediaType(MediaTypeHeaderValue? header, string mediaType) { - return string.Equals(header.MediaType, mediaType, StringComparison.OrdinalIgnoreCase); + return string.Equals(header?.MediaType, mediaType, StringComparison.OrdinalIgnoreCase); } } } diff --git a/src/Tableau.Migration/Net/NetworkTraceLogger.cs b/src/Tableau.Migration/Net/NetworkTraceLogger.cs index 36e3231b..f0a41447 100644 --- a/src/Tableau.Migration/Net/NetworkTraceLogger.cs +++ b/src/Tableau.Migration/Net/NetworkTraceLogger.cs @@ -30,8 +30,7 @@ namespace Tableau.Migration.Net { - internal class NetworkTraceLogger - : INetworkTraceLogger + internal class NetworkTraceLogger : INetworkTraceLogger { /// /// List of headers to log. @@ -70,24 +69,13 @@ public NetworkTraceLogger( _traceRedactor = traceRedactor; } - public async Task WriteNetworkLogsAsync( - HttpRequestMessage request, - HttpResponseMessage response, - CancellationToken cancel) + public async Task WriteNetworkLogsAsync(HttpRequestMessage request, HttpResponseMessage response, CancellationToken cancel) { var detailsBuilder = new StringBuilder(); - AddHttpHeaderDetails( - detailsBuilder, - request, - response); + AddHttpHeaderDetails(detailsBuilder, request, response); - await AddHttpContentDetailsAsync( - detailsBuilder, - request, - response, - cancel) - .ConfigureAwait(false); + await AddHttpContentDetailsAsync(detailsBuilder, request, response, cancel).ConfigureAwait(false); var correlationId = response.Headers.GetCorrelationId(); @@ -97,25 +85,16 @@ await AddHttpContentDetailsAsync( request.RequestUri, response.StatusCode, correlationId, - detailsBuilder.ToString() - ); + detailsBuilder.ToString()); } - public async Task WriteNetworkExceptionLogsAsync( - HttpRequestMessage request, - Exception exception, - CancellationToken cancel) + public async Task WriteNetworkExceptionLogsAsync(HttpRequestMessage request, Exception exception, CancellationToken cancel) { var detailsBuilder = new StringBuilder(); AddHttpHeaderDetails(detailsBuilder, request); - await AddHttpContentDetailsAsync( - detailsBuilder, - request, - null, - cancel) - .ConfigureAwait(false); + await AddHttpContentDetailsAsync(detailsBuilder, request, null, cancel).ConfigureAwait(false); AddHttpExceptionDetails(detailsBuilder, exception); @@ -130,10 +109,7 @@ await AddHttpContentDetailsAsync( detailsBuilder.ToString()); } - private void AddHttpHeaderDetails( - StringBuilder detailsBuilder, - HttpRequestMessage request, - HttpResponseMessage? response = null) + private void AddHttpHeaderDetails(StringBuilder detailsBuilder, HttpRequestMessage request, HttpResponseMessage? response = null) { var headersLogging = _configReader.Get().Network.HeadersLoggingEnabled; @@ -142,57 +118,37 @@ private void AddHttpHeaderDetails( return; } - var requestSectionWritten = AppendHttpHeader( - detailsBuilder, - request.Headers, - SharedResourceKeys.SectionRequestHeaders); - AppendHttpHeader( - detailsBuilder, - request.Content?.Headers, - SharedResourceKeys.SectionRequestHeaders, - requestSectionWritten); - - var responseSectionWritten = AppendHttpHeader( - detailsBuilder, - response?.Headers, - SharedResourceKeys.SectionResponseHeaders); - AppendHttpHeader( - detailsBuilder, - response?.Content?.Headers, - SharedResourceKeys.SectionResponseHeaders, - responseSectionWritten); + var requestSectionWritten = AppendHttpHeader(detailsBuilder, request.Headers, SharedResourceKeys.SectionRequestHeaders); + + AppendHttpHeader(detailsBuilder, request.Content?.Headers, SharedResourceKeys.SectionRequestHeaders, requestSectionWritten); + + var responseSectionWritten = AppendHttpHeader(detailsBuilder, response?.Headers, SharedResourceKeys.SectionResponseHeaders); + AppendHttpHeader(detailsBuilder, response?.Content?.Headers, SharedResourceKeys.SectionResponseHeaders, responseSectionWritten); } - private async Task AddHttpContentDetailsAsync( - StringBuilder detailsBuilder, - HttpRequestMessage request, - HttpResponseMessage? response, - CancellationToken cancellation) + private async Task AddHttpContentDetailsAsync(StringBuilder detailsBuilder, HttpRequestMessage request, HttpResponseMessage? response, CancellationToken cancellation) { var contentLogging = _configReader.Get().Network.ContentLoggingEnabled; + var workbookContentLogging = _configReader.Get().Network.WorkbookContentLoggingEnabled; if (!contentLogging) { return; } - await AppendHttpContentAsync( - detailsBuilder, - request.Content, - SharedResourceKeys.SectionRequestContent, - cancellation) + // Don't log the content of the request if it is a workbook download request and it's disabled. + if (IsWorkbookDownloadRequest(request) && !workbookContentLogging) + { + return; + } + + await AppendHttpContentAsync(detailsBuilder, request.Content, SharedResourceKeys.SectionRequestContent, cancellation) .ConfigureAwait(false); - await AppendHttpContentAsync( - detailsBuilder, - response?.Content, - SharedResourceKeys.SectionResponseContent, - cancellation) + await AppendHttpContentAsync(detailsBuilder, response?.Content, SharedResourceKeys.SectionResponseContent, cancellation) .ConfigureAwait(false); } - private void AddHttpExceptionDetails( - StringBuilder detailsBuilder, - Exception exception) + private void AddHttpExceptionDetails(StringBuilder detailsBuilder, Exception exception) { var exceptionLogging = _configReader.Get().Network.ExceptionsLoggingEnabled; @@ -201,31 +157,23 @@ private void AddHttpExceptionDetails( return; } - detailsBuilder.AppendLine( - _localizer[SharedResourceKeys.SectionException]); + detailsBuilder.AppendLine(_localizer[SharedResourceKeys.SectionException]); detailsBuilder.AppendLine(exception.StackTrace); } - private bool AppendHttpHeader( - StringBuilder detailsBuilder, - HttpHeaders? headers, - string? localizedSectionName = null, - bool sectionWritten = false) + private bool AppendHttpHeader(StringBuilder detailsBuilder, HttpHeaders? headers, string? localizedSectionName = null, bool sectionWritten = false) { if (headers is null) { return sectionWritten; } - foreach (var headerValue in headers.Where(header => - _logAllowedHeaders.Contains(header.Key))) + foreach (var headerValue in headers.Where(header => _logAllowedHeaders.Contains(header.Key))) { - if (!string.IsNullOrWhiteSpace(localizedSectionName) && - !sectionWritten) + if (!string.IsNullOrWhiteSpace(localizedSectionName) && !sectionWritten) { - detailsBuilder.AppendLine( - _localizer[localizedSectionName]); + detailsBuilder.AppendLine(_localizer[localizedSectionName]); sectionWritten = true; } @@ -238,11 +186,7 @@ private bool AppendHttpHeader( return sectionWritten; } - private async Task AppendHttpContentAsync( - StringBuilder detailsBuilder, - HttpContent? content, - string localizedSectionName, - CancellationToken cancellation) + private async Task AppendHttpContentAsync(StringBuilder detailsBuilder, HttpContent? content, string localizedSectionName, CancellationToken cancellation) { if (content is null) { @@ -254,50 +198,74 @@ private async Task AppendHttpContentAsync( detailsBuilder.AppendLine(); detailsBuilder.AppendLine(_localizer[localizedSectionName]); + if (content is not MultipartFormDataContent multipartContent) + { + await WriteContentAsync(content).ConfigureAwait(false); + return; + } + + foreach (var item in multipartContent) + { + AppendHttpHeader(detailsBuilder, item.Headers); + + await WriteContentAsync(item).ConfigureAwait(false); + + detailsBuilder.AppendLine(); + } + async Task WriteContentAsync(HttpContent contentToWrite) { - if (logBinaryContent || contentToWrite.LogsAsTextContent()) + // If the content is binary (i.e. it is not text) and we are not logging binary content, then we should not log the content. + if (!logBinaryContent && !contentToWrite.LogsAsTextContent()) { - if (_traceRedactor.IsSensitiveMultipartContent(contentToWrite.Headers.ContentDisposition?.Name)) - { - detailsBuilder.AppendLine(SENSITIVE_DATA_PLACEHOLDER); - } - else - { - if (contentToWrite.Headers.ContentLength > int.MaxValue) - { - detailsBuilder.AppendLine(_localizer[SharedResourceKeys.NetworkTraceTooLargeDetails]); - } - else - { - var text = await contentToWrite.ReadAsEncodedStringAsync(cancellation) - .ConfigureAwait(false); - - detailsBuilder.AppendLine(_traceRedactor.ReplaceSensitiveData(text)); - } - } + detailsBuilder.AppendLine(_localizer[SharedResourceKeys.NetworkTraceNotDisplayedDetails]); + return; } - else + + if (_traceRedactor.IsSensitiveMultipartContent(contentToWrite.Headers.ContentDisposition?.Name)) { - detailsBuilder.AppendLine(_localizer[SharedResourceKeys.NetworkTraceNotDisplayedDetails]); + detailsBuilder.AppendLine(SENSITIVE_DATA_PLACEHOLDER); + return; } - } - if (content is MultipartFormDataContent multipartContent) - { - foreach (var item in multipartContent) + if (contentToWrite.Headers.ContentLength > int.MaxValue) { - AppendHttpHeader(detailsBuilder, item.Headers); + detailsBuilder.AppendLine(_localizer[SharedResourceKeys.NetworkTraceTooLargeDetails]); + return; + } - await WriteContentAsync(item).ConfigureAwait(false); + var text = await contentToWrite.ReadAsEncodedStringAsync(cancellation).ConfigureAwait(false); + + detailsBuilder.AppendLine(_traceRedactor.ReplaceSensitiveData(text)); + + return; - detailsBuilder.AppendLine(); - } } - else + } + + private bool IsWorkbookDownloadRequest(HttpRequestMessage request) + { + if (request?.RequestUri == null) { - await WriteContentAsync(content).ConfigureAwait(false); + return false; } + + var segments = request.RequestUri.Segments.Select(segment => segment.Trim('/')).ToArray(); + + for (int i = 0; i < segments.Length; i++) + { + // Check if this is a workbooks RestAPI + if (string.Equals(segments[i], "workbooks", StringComparison.OrdinalIgnoreCase)) + { + // Check if this is a workbook download request. "contents" will always show up 2 after the "workbooks" in the URI. + if (i + 2 < segments.Length && string.Equals(segments[i + 2], "content", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; } } -} +} \ No newline at end of file diff --git a/src/Tableau.Migration/Net/Rest/Fields/FieldBuilder.cs b/src/Tableau.Migration/Net/Rest/Fields/FieldBuilder.cs index 252059f3..e8a84912 100644 --- a/src/Tableau.Migration/Net/Rest/Fields/FieldBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/Fields/FieldBuilder.cs @@ -38,7 +38,7 @@ public IFieldBuilder AddField(Field field) } /// - public IFieldBuilder AddFields(params Field[] fields) + public IFieldBuilder AddFields(params IEnumerable fields) { Guard.AgainstNull(fields, nameof(fields)); diff --git a/src/Tableau.Migration/Net/Rest/Fields/IFieldBuilder.cs b/src/Tableau.Migration/Net/Rest/Fields/IFieldBuilder.cs index b43b9e4e..9b1bef95 100644 --- a/src/Tableau.Migration/Net/Rest/Fields/IFieldBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/Fields/IFieldBuilder.cs @@ -15,6 +15,8 @@ // limitations under the License. // +using System.Collections.Generic; + namespace Tableau.Migration.Net.Rest.Fields { /// @@ -44,7 +46,7 @@ public interface IFieldBuilder /// /// The fields to add. /// The current instance. - IFieldBuilder AddFields(params Field[] fields); + IFieldBuilder AddFields(params IEnumerable fields); /// /// Builds the string value for the fields for use in query strings. diff --git a/src/Tableau.Migration/Net/Rest/Filtering/FilterBuilder.cs b/src/Tableau.Migration/Net/Rest/Filtering/FilterBuilder.cs index e12d8aa8..7112e7cc 100644 --- a/src/Tableau.Migration/Net/Rest/Filtering/FilterBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/Filtering/FilterBuilder.cs @@ -38,11 +38,7 @@ public IFilterBuilder AddFilter(Filter filter) } /// - public IFilterBuilder AddFilters(params Filter[] filters) - => AddFilters((IEnumerable)filters); - - /// - public IFilterBuilder AddFilters(IEnumerable filters) + public IFilterBuilder AddFilters(params IEnumerable filters) { foreach (var filter in filters) AddFilter(filter); diff --git a/src/Tableau.Migration/Net/Rest/Filtering/IFilterBuilder.cs b/src/Tableau.Migration/Net/Rest/Filtering/IFilterBuilder.cs index faf815b8..fdce8787 100644 --- a/src/Tableau.Migration/Net/Rest/Filtering/IFilterBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/Filtering/IFilterBuilder.cs @@ -46,14 +46,7 @@ public interface IFilterBuilder /// /// The filters to add. /// The current instance. - IFilterBuilder AddFilters(params Filter[] filters); - - /// - /// Adds filters to the builder. - /// - /// The filters to add. - /// The current instance. - IFilterBuilder AddFilters(IEnumerable filters); + IFilterBuilder AddFilters(params IEnumerable filters); /// /// Builds the string value for the filters for use in query strings. diff --git a/src/Tableau.Migration/Net/Rest/IRestRequestBuilder.cs b/src/Tableau.Migration/Net/Rest/IRestRequestBuilder.cs index bda99101..99801c86 100644 --- a/src/Tableau.Migration/Net/Rest/IRestRequestBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/IRestRequestBuilder.cs @@ -62,7 +62,7 @@ public interface IRestRequestBuilder : IRequestBuilder /// /// The fields used to build the URI's fields query string. /// The current instance. - IRestRequestBuilder WithFields(params Field[] fields); + IRestRequestBuilder WithFields(params IEnumerable fields); /// /// Configures the filters for the URI. @@ -76,14 +76,7 @@ public interface IRestRequestBuilder : IRequestBuilder /// /// The filters used to build the URI's filter query string. /// The current instance. - IRestRequestBuilder WithFilters(params Filter[] filters); - - /// - /// Configures the filters for the URI. - /// - /// The filters used to build the URI's filter query string. - /// The current instance. - IRestRequestBuilder WithFilters(IEnumerable filters); + IRestRequestBuilder WithFilters(params IEnumerable filters); /// /// Configures the page for the URI. @@ -119,6 +112,6 @@ public interface IRestRequestBuilder : IRequestBuilder /// /// The callback used to build the URI's sort query string. /// The current instance. - IRestRequestBuilder WithSorts(params Sort[] sorts); + IRestRequestBuilder WithSorts(params IEnumerable sorts); } } diff --git a/src/Tableau.Migration/Net/Rest/RestRequestBuilder.cs b/src/Tableau.Migration/Net/Rest/RestRequestBuilder.cs index 2a8c445a..824fb572 100644 --- a/src/Tableau.Migration/Net/Rest/RestRequestBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/RestRequestBuilder.cs @@ -96,7 +96,7 @@ public IRestRequestBuilder WithFields(Action fields) } /// - public IRestRequestBuilder WithFields(params Field[] fields) + public IRestRequestBuilder WithFields(params IEnumerable fields) { _fields.AddFields(fields); return this; @@ -110,14 +110,7 @@ public IRestRequestBuilder WithFilters(Action filters) } /// - public IRestRequestBuilder WithFilters(params Filter[] filters) - { - _filters.AddFilters(filters); - return this; - } - - /// - public IRestRequestBuilder WithFilters(IEnumerable filters) + public IRestRequestBuilder WithFilters(params IEnumerable filters) { _filters.AddFilters(filters); return this; @@ -131,7 +124,7 @@ public IRestRequestBuilder WithSorts(Action sorts) } /// - public IRestRequestBuilder WithSorts(params Sort[] sorts) + public IRestRequestBuilder WithSorts(params IEnumerable sorts) { _sorts.AddSorts(sorts); return this; diff --git a/src/Tableau.Migration/Net/Rest/Sorting/ISortBuilder.cs b/src/Tableau.Migration/Net/Rest/Sorting/ISortBuilder.cs index 14972b04..b9dbfea6 100644 --- a/src/Tableau.Migration/Net/Rest/Sorting/ISortBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/Sorting/ISortBuilder.cs @@ -15,6 +15,8 @@ // limitations under the License. // +using System.Collections.Generic; + namespace Tableau.Migration.Net.Rest.Sorting { /// @@ -44,7 +46,7 @@ public interface ISortBuilder /// /// The sorts to add. /// The current instance. - ISortBuilder AddSorts(params Sort[] sorts); + ISortBuilder AddSorts(params IEnumerable sorts); /// /// Builds the string value for the sorts for use in query strings. diff --git a/src/Tableau.Migration/Net/Rest/Sorting/SortBuilder.cs b/src/Tableau.Migration/Net/Rest/Sorting/SortBuilder.cs index dac8da0b..f07d968b 100644 --- a/src/Tableau.Migration/Net/Rest/Sorting/SortBuilder.cs +++ b/src/Tableau.Migration/Net/Rest/Sorting/SortBuilder.cs @@ -38,7 +38,7 @@ public ISortBuilder AddSort(Sort sort) } /// - public ISortBuilder AddSorts(params Sort[] sorts) + public ISortBuilder AddSorts(params IEnumerable sorts) { Guard.AgainstNull(sorts, nameof(sorts)); diff --git a/tests/Tableau.Migration.Tests/MemoryPager.cs b/src/Tableau.Migration/Paging/MemoryPager.cs similarity index 55% rename from tests/Tableau.Migration.Tests/MemoryPager.cs rename to src/Tableau.Migration/Paging/MemoryPager.cs index 62c35c2c..0335b992 100644 --- a/tests/Tableau.Migration.Tests/MemoryPager.cs +++ b/src/Tableau.Migration/Paging/MemoryPager.cs @@ -15,44 +15,63 @@ // limitations under the License. // +using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; -using Tableau.Migration.Paging; -namespace Tableau.Migration.Tests +namespace Tableau.Migration.Paging { /// - /// implementation that wraps an in-memory collection for mocking purposes. + /// implementation that wraps an in-memory collection. /// internal class MemoryPager : IPager { - private readonly IReadOnlyCollection _items; + private readonly Func>>> _getItems; private readonly int _pageSize; + private IReadOnlyCollection? _items; + private int _pagesAvailable; + private int _pageNumber; private int _offset; - public MemoryPager(IReadOnlyCollection items, int pageSize) + public MemoryPager(Func>>> getItems, int pageSize) { - _items = items; + _getItems = getItems; _pageSize = pageSize; _offset = 0; _pageNumber = 1; } - public Task> NextPageAsync(CancellationToken cancel) + public MemoryPager(IReadOnlyCollection items, int pageSize) + : this((c) => Task.FromResult((IResult>)Result>.Succeeded(items)), pageSize) + { } + + public async Task> NextPageAsync(CancellationToken cancel) { + if(_items is null) + { + var getResult = await _getItems(cancel).ConfigureAwait(false); + if(!getResult.Success) + { + return PagedResult.Failed(getResult.Errors); + } + + _items = getResult.Value; + _pagesAvailable = (_items.Count / _pageSize) + (_items.Count % _pageSize > 0 ? 1 : 0); + } + var pageItems = _items.Skip(_offset).Take(_pageSize).ToImmutableArray(); - var result = PagedResult.Succeeded(pageItems, _pageNumber, _pageSize, _items.Count, !pageItems.Any()); + var result = PagedResult.Succeeded(pageItems, _pageNumber, _pageSize, _items.Count, _pageNumber >= _pagesAvailable); _offset += _pageSize; _pageNumber++; - return Task.FromResult>(result); + return result; } } } diff --git a/src/Tableau.Migration/Paging/PagedResult.cs b/src/Tableau.Migration/Paging/PagedResult.cs index aeb5edf1..0dcb2484 100644 --- a/src/Tableau.Migration/Paging/PagedResult.cs +++ b/src/Tableau.Migration/Paging/PagedResult.cs @@ -34,7 +34,7 @@ internal record PagedResult : Result>, IPagedResult /// The total unpaged available item count. /// Whether the SDK has already fetched all pages or not. /// The errors encountered during the operation, if any. - protected PagedResult(bool success, IImmutableList? value, int pageNumber, int pageSize, int totalCount, bool fetchedAllPages, params Exception[] errors) + protected PagedResult(bool success, IImmutableList? value, int pageNumber, int pageSize, int totalCount, bool fetchedAllPages, params IEnumerable errors) : base(success, value, errors) { PageNumber = pageNumber; diff --git a/src/Tableau.Migration/PipelineProfile.cs b/src/Tableau.Migration/PipelineProfile.cs index 59df3239..7692f44e 100644 --- a/src/Tableau.Migration/PipelineProfile.cs +++ b/src/Tableau.Migration/PipelineProfile.cs @@ -22,14 +22,24 @@ namespace Tableau.Migration /// public enum PipelineProfile { + /// + /// A custom pipeline supplied by the migration plan is used. + /// + Custom = 1, + /// /// The pipeline to bulk migrate content from a Tableau Server site to a Tableau Cloud site. /// - ServerToCloud = 1, + ServerToCloud = 2, /// - /// A custom pipeline supplied by the migration plan is used. + /// The pipeline to bulk migrate content from a Tableau Server site to a Tableau Server site. + /// + ServerToServer = 3, + + /// + /// The pipeline to bulk migrate content from a Tableau Cloud site to a Tableau Cloud site. /// - Custom = 2 + CloudToCloud = 4, } } diff --git a/src/Tableau.Migration/Properties/AssemblyInfo.cs b/src/Tableau.Migration/Properties/AssemblyInfo.cs index c7dde9af..c70a3556 100644 --- a/src/Tableau.Migration/Properties/AssemblyInfo.cs +++ b/src/Tableau.Migration/Properties/AssemblyInfo.cs @@ -36,6 +36,7 @@ [assembly: Guid("1062274b-842c-411b-9349-8cf5f12b9c1c")] //Test assemblies can access internals. +[assembly: InternalsVisibleTo("Tableau.Migration.ManifestExplorer")] [assembly: InternalsVisibleTo("Tableau.Migration.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Tableau.Migration/Resources/DefaultExternalAssetsProjectTranslations.cs b/src/Tableau.Migration/Resources/DefaultExternalAssetsProjectTranslations.cs index 887ae186..65f0484a 100644 --- a/src/Tableau.Migration/Resources/DefaultExternalAssetsProjectTranslations.cs +++ b/src/Tableau.Migration/Resources/DefaultExternalAssetsProjectTranslations.cs @@ -21,7 +21,7 @@ namespace Tableau.Migration.Resources { - internal class DefaultExternalAssetsProjectTranslations + internal static class DefaultExternalAssetsProjectTranslations { internal const string English = "External Assets Default Project"; diff --git a/src/Tableau.Migration/Resources/SharedResourceKeys.cs b/src/Tableau.Migration/Resources/SharedResourceKeys.cs index e56405d7..ac20bceb 100644 --- a/src/Tableau.Migration/Resources/SharedResourceKeys.cs +++ b/src/Tableau.Migration/Resources/SharedResourceKeys.cs @@ -216,5 +216,31 @@ internal static class SharedResourceKeys public const string ScheduleUpdatedRemovedEndAtMessage = "ScheduleUpdatedRemovedEndAtMessage"; public const string ScheduleUpdatedHoursMessage = "ScheduleUpdatedHoursMessage"; + + public const string ContainerParentNotFound = "ContainerParentNotFound"; + + public const string MappedContainerNotFound = "MappedContainerNotFound"; + + public const string InitializeMigrationBaseDebugMessage = "InitializeMigrationBaseDebugMessage"; + + public const string InitializeMigrationBaseNoChangesMessage = "InitializeMigrationBaseNoChangesMessage"; + + public const string MigrationDisabledWarning = "MigrationDisabledWarning"; + + public const string HasManagedOAuthCredentialsWarning = "HasManagedOAuthCredentialsWarning"; + + public const string OAuthCredentialMigrationUsersNotAtDestination = "OAuthCredentialMigrationUsersNotAtDestination"; + + public const string DestinationEndpointNotAnApiMsg = "DestinationEndpointNotAnApiMsg"; + + public const string EmbeddedCredsDisabledReason = "EmbeddedCredsDisabledReason"; + + public const string DummySubscriptionName = "DummySubscriptionName"; + + public const string DummySubscriptionMessage = "DummySubscriptionMessage"; + + public const string ContentTypeDisabledWarning = "ContentTypeDisabledWarning"; + + public const string SubscriptionsDisabledReason = "SubscriptionsDisabledReason"; } } diff --git a/src/Tableau.Migration/Resources/SharedResources.resx b/src/Tableau.Migration/Resources/SharedResources.resx index dd1a13cc..447204b5 100644 --- a/src/Tableau.Migration/Resources/SharedResources.resx +++ b/src/Tableau.Migration/Resources/SharedResources.resx @@ -430,4 +430,43 @@ Owner with ID {OwnerID}: {owner} Updated schedule hours from {0} to {1} + + Destination project {0} could not be found for mapped path {1}. + + + Destination project not found when mapping path {0}. + + + {TypeName} changed {PropertyName} from '{OriginalValue}' to '{NewValue}' + + + {TypeName} executed, but made no changes to MigrationCapabilities. + + + {0} migration was disabled. Reason: {1} + + + This is a dummy subscription created by the Migration SDK. You can safely delete it. + + + Migration SDK Dummy Subscription + + + Skipping migration of {ContentType} since it is disabled. + + + Subscriptions are disabled at the destination. + + + Embedded Managed OAuth Credentials migration is not supported. They will be converted to saved credentials for {0} {1} at {2}. The connection IDs are {3}. + + + Some associated user IDs were not found at the destination for workbook/data source {0} at {1} were not migrated. The user IDs are {2}. + + + The destination enpoint is not an API. + + + Embedded Credential migration is disabled. Follow the pre-migration steps from documentation to set them up first. + \ No newline at end of file diff --git a/src/Tableau.Migration/Result.cs b/src/Tableau.Migration/Result.cs index 9a8f3ea9..b5e4e1d1 100644 --- a/src/Tableau.Migration/Result.cs +++ b/src/Tableau.Migration/Result.cs @@ -43,16 +43,7 @@ internal record Result : IResult /// /// True if the operation is successful, false otherwise. /// The errors encountered during the operation, if any. - protected Result(bool success, params Exception[] errors) - : this(success, errors is null ? ImmutableArray.Empty : errors) - { } - - /// - /// Creates a new instance. - /// - /// True if the operation is successful, false otherwise. - /// The errors encountered during the operation, if any. - protected Result(bool success, IEnumerable errors) + protected Result(bool success, params IEnumerable errors) { Success = success; Errors = errors.ToImmutableArray(); @@ -139,17 +130,7 @@ internal record Result : Result, IResult /// True if the operation is successful, false otherwise. /// The result of the operation. /// The errors encountered during the operation, if any. - protected Result(bool success, T? value, params Exception[] errors) - : this(success, value, (IEnumerable)errors) - { } - - /// - /// Creates a new instance. - /// - /// True if the operation is successful, false otherwise. - /// The result of the operation. - /// The errors encountered during the operation, if any. - protected Result(bool success, T? value, IEnumerable errors) + protected Result(bool success, T? value, params IEnumerable errors) : base(success, errors) { Value = value; diff --git a/src/Tableau.Migration/ResultBuilder.cs b/src/Tableau.Migration/ResultBuilder.cs index 46091051..af607aac 100644 --- a/src/Tableau.Migration/ResultBuilder.cs +++ b/src/Tableau.Migration/ResultBuilder.cs @@ -41,7 +41,7 @@ public ResultBuilder() /// /// The results to add errors from. /// This result builder for fluent API usage. - public virtual ResultBuilder Add(params IResult[] results) + public virtual ResultBuilder Add(params IEnumerable results) { foreach (var result in results) { diff --git a/src/Tableau.Migration/StreamExtensions.cs b/src/Tableau.Migration/StreamExtensions.cs index c9bcb961..8034e75b 100644 --- a/src/Tableau.Migration/StreamExtensions.cs +++ b/src/Tableau.Migration/StreamExtensions.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -61,8 +61,11 @@ public static bool IsZip(this Stream stream) try { var bytes = new byte[ZIP_LEAD_BYTES.Length]; + + stream.Seek(0, SeekOrigin.Begin); + stream.ReadExactly(bytes, 0, bytes.Length); stream.Seek(0, SeekOrigin.Begin); - stream.Read(bytes, 0, bytes.Length); + return bytes.SequenceEqual(ZIP_LEAD_BYTES); } finally diff --git a/src/Tableau.Migration/StringEnum.cs b/src/Tableau.Migration/StringEnum.cs index c7d561c2..10999fe0 100644 --- a/src/Tableau.Migration/StringEnum.cs +++ b/src/Tableau.Migration/StringEnum.cs @@ -53,14 +53,7 @@ static StringEnum() /// Gets a collection of all values. /// /// The values to exclude. - public static IImmutableList GetAll(params string[] exclude) - => GetAll((IEnumerable)exclude); - - /// - /// Gets a collection of all values. - /// - /// The values to exclude. - public static IImmutableList GetAll(IEnumerable exclude) + public static IImmutableList GetAll(params IEnumerable exclude) => exclude.IsNullOrEmpty() ? _all.Value : _all.Value.SkipWhile(v => exclude.Any(e => IsAMatch(v, e))).ToImmutableArray(); diff --git a/src/Tableau.Migration/Tableau.Migration.csproj b/src/Tableau.Migration/Tableau.Migration.csproj index 480f77f3..f47a0d0c 100644 --- a/src/Tableau.Migration/Tableau.Migration.csproj +++ b/src/Tableau.Migration/Tableau.Migration.csproj @@ -2,7 +2,7 @@ Tableau Migration SDK https://github.com/tableau/tableau-migration-sdk - net8.0 + net8.0;net9.0 Tableau.Migration True @@ -43,19 +43,19 @@ Note: This SDK is specific for migrating from Tableau Server to Tableau Cloud. I - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + diff --git a/src/Tableau.Migration/TableauInstanceTypeNotSupportedException.cs b/src/Tableau.Migration/TableauInstanceTypeNotSupportedException.cs index 27967dec..380c2384 100644 --- a/src/Tableau.Migration/TableauInstanceTypeNotSupportedException.cs +++ b/src/Tableau.Migration/TableauInstanceTypeNotSupportedException.cs @@ -23,12 +23,40 @@ namespace Tableau.Migration /// /// The exception that is thrown when an operation is not supported for the current . /// - public class TableauInstanceTypeNotSupportedException(TableauInstanceType unsupported, ISharedResourcesLocalizer localizer, string? message = null) - : NotSupportedException(message ?? localizer[SharedResourceKeys.TableauInstanceTypeNotSupportedMessage, unsupported.GetFriendlyName()]) + public class TableauInstanceTypeNotSupportedException(TableauInstanceType unsupportedInstanceType, string message) + : NotSupportedException(message), + IEquatable { + /// + /// Creates a new object. + /// + /// The unsupported . + /// The localizer to use for the standard exception message. + public TableauInstanceTypeNotSupportedException(TableauInstanceType unsupportedInstanceType, ISharedResourcesLocalizer localizer) + : this(unsupportedInstanceType, localizer[SharedResourceKeys.TableauInstanceTypeNotSupportedMessage, unsupportedInstanceType.GetFriendlyName()]) + { } + /// /// Gets the unsupported . /// - public TableauInstanceType UnsupportedInstanceType { get; } = unsupported; + public TableauInstanceType UnsupportedInstanceType { get; } = unsupportedInstanceType; + + /// + public override bool Equals(object? obj) => Equals(obj as TableauInstanceTypeNotSupportedException); + + /// + public override int GetHashCode() => HashCode.Combine(GetType(), Message, UnsupportedInstanceType); + + /// + public bool Equals(TableauInstanceTypeNotSupportedException? other) + { + var baseEquals = this.BaseExceptionEquals(other); + if(baseEquals is not null) + { + return baseEquals.Value; + } + + return UnsupportedInstanceType == other?.UnsupportedInstanceType; + } } } diff --git a/src/Tableau.Migration/TypeExtensions.cs b/src/Tableau.Migration/TypeExtensions.cs index 061439d3..31988fe5 100644 --- a/src/Tableau.Migration/TypeExtensions.cs +++ b/src/Tableau.Migration/TypeExtensions.cs @@ -81,6 +81,7 @@ public static MethodInfo[] GetAllInterfaceMethods(this Type interfaceType, Bindi /// /// Gets the formatted name of the specified . + /// If the type is a generic type, the formatted name will include the generic type arguments. /// /// The to get the formatted name for. /// The formatted name of the . diff --git a/tests/Python.ExampleApplication.Tests/Python.ExampleApplication.Tests.pyproj b/tests/Python.ExampleApplication.Tests/Python.ExampleApplication.Tests.pyproj index 5395d208..17d90565 100644 --- a/tests/Python.ExampleApplication.Tests/Python.ExampleApplication.Tests.pyproj +++ b/tests/Python.ExampleApplication.Tests/Python.ExampleApplication.Tests.pyproj @@ -25,6 +25,7 @@ false + @@ -37,6 +38,7 @@ + diff --git a/tests/Python.ExampleApplication.Tests/pyproject.toml b/tests/Python.ExampleApplication.Tests/pyproject.toml index 9426b49b..d7fb04f4 100644 --- a/tests/Python.ExampleApplication.Tests/pyproject.toml +++ b/tests/Python.ExampleApplication.Tests/pyproject.toml @@ -13,8 +13,17 @@ classifiers = [ ] dependencies = [ - "tableau_migration", - "pytest>=8.2.2" + "pytest>=8.3.3", + "pythonnet==3.0.5", + "typing_extensions==4.12.2" +] + +[tool.hatch.build] +exclude = [ + "obj", + "TestResults", + "pytest.ini", + "Python.pyproj" ] [tool.ruff] @@ -22,14 +31,28 @@ dependencies = [ select = ["E", "F"] ignore = ["E501"] +[tool.ruff.pydocstyle] +# Use Google-style docstrings. +convention = "google" + [tool.hatch.envs.test] dev-mode = false dependencies = [ - "pytest>=8.2.2" + "pytest>=8.3.3", + "pytest-cov>=6.0.0", + "pytest-env>=1.1.5", + "pythonnet==3.0.5", + "typing_extensions==4.12.2" ] +[[tool.hatch.envs.test.matrix]] +python = ["3.9", "3.10", "3.11", "3.12"] + +[tool.hatch.envs.lint.scripts] +lint = "ruff check ." + [tool.hatch.envs.test.scripts] -test = "pytest" +test = "pytest -vv" +testcov = "test --cov-config=pyproject.toml --cov=../../examples/Python.ExampleApplication/hooks/ --cov-report term --cov-report xml:TestResults/coverage-{matrix:python}.xml" + -[[tool.hatch.envs.test.matrix]] -python = ["3.9", "3.10", "3.11", "3.12"] \ No newline at end of file diff --git a/tests/Python.ExampleApplication.Tests/requirements.txt b/tests/Python.ExampleApplication.Tests/requirements.txt index 652bc81a..780f62c9 100644 --- a/tests/Python.ExampleApplication.Tests/requirements.txt +++ b/tests/Python.ExampleApplication.Tests/requirements.txt @@ -1,2 +1,14 @@ -tableau_migration -pytest>=8.2.2 +cffi==1.17.1 +clr_loader==0.2.7.post0 +colorama==0.4.6 +coverage==7.7.1 +iniconfig==2.1.0 +packaging==24.2 +pluggy==1.5.0 +pycparser==2.22 +pytest==8.3.5 +pytest-cov==6.0.0 +pythonnet==3.0.5 +ruff==0.11.2 +typing_extensions==4.13.0 +hatch===1.14.0 \ No newline at end of file diff --git a/tests/Python.ExampleApplication.Tests/tests/__init__.py b/tests/Python.ExampleApplication.Tests/tests/__init__.py index 93926186..ecbf0c02 100644 --- a/tests/Python.ExampleApplication.Tests/tests/__init__.py +++ b/tests/Python.ExampleApplication.Tests/tests/__init__.py @@ -19,7 +19,12 @@ import os from os.path import abspath from pathlib import Path -import tableau_migration + +_module_path = abspath(Path(__file__).parent.resolve().__str__() + "/../../../src/Python/src") +sys.path.append(_module_path) + +from tableau_migration import clr # noqa: E402 +from System.Reflection import Assembly # noqa: E402 print("Adding example application paths to sys.path") sys.path.append(abspath(Path(__file__).parent.resolve().__str__() + "/../../../examples/Python.ExampleApplication/")) @@ -30,14 +35,15 @@ sys.path.append(abspath(Path(__file__).parent.resolve().__str__() + "/../../../examples/Python.ExampleApplication/hooks/post_publish")) sys.path.append(abspath(Path(__file__).parent.resolve().__str__() + "/../../../examples/Python.ExampleApplication/hooks/transformers")) +_bin_path = abspath(Path(__file__).parent.resolve().__str__() + "/../../../src/Python/src/tableau_migration/bin") +sys.path.append(_bin_path) +print("Added 'bin' at '" + _bin_path + "' to sys.path") if os.environ.get('MIG_SDK_PYTHON_BUILD', 'false').lower() == 'true': print("MIG_SDK_PYTHON_BUILD set to true. Building dotnet binaries for python tests.") # Not required for GitHub Actions import subprocess import shutil - _bin_path = abspath(Path(__file__).parent.resolve().__str__() + "/../../../src/Python/src/tableau_migration/bin") - sys.path.append(_bin_path) shutil.rmtree(_bin_path, True) print("Building required binaries") @@ -46,16 +52,25 @@ else: print("MIG_SDK_PYTHON_BUILD set to false. Skipping dotnet build for python tests.") -print("Adding test helpers to sys.path") -_autofixture_helper_path = abspath(Path(__file__).parent.resolve().__str__() + "/../../../src/Python/tests/helpers") +def add_assembly_reference(name,add_by_path): + if add_by_path: + assemblyPath = _bin_path+ "/"+ name + ".dll" + assembly = Assembly.LoadFile(assemblyPath) + print(f"Added reference to {assembly.GetName().Name} v{assembly.GetName().Version} Full Name: {assembly.FullName}") + else: + clr.AddReference(name) + assembly = Assembly.Load(name) + print(f"Added reference to {assembly.GetName().Name} v{assembly.GetName().Version} Full Name: {assembly.FullName}") + +add_assembly_reference("AutoFixture", True) +add_assembly_reference("AutoFixture.AutoMoq", True) +add_assembly_reference("Moq", True) +add_assembly_reference("Tableau.Migration", False) + +_autofixture_helper_path = abspath(Path(__file__).parent.resolve().__str__() + "/helpers") sys.path.append(_autofixture_helper_path) +print("Added 'helpers' at '" + _autofixture_helper_path + "' to sys.path") -from tableau_migration import clr -clr.AddReference("AutoFixture") -clr.AddReference("AutoFixture.AutoMoq") -clr.AddReference("Moq") -clr.AddReference("Tableau.Migration.Tests") -clr.AddReference("Tableau.Migration") diff --git a/tests/Python.ExampleApplication.Tests/tests/helpers/autofixture.py b/tests/Python.ExampleApplication.Tests/tests/helpers/autofixture.py new file mode 100644 index 00000000..b1bebebb --- /dev/null +++ b/tests/Python.ExampleApplication.Tests/tests/helpers/autofixture.py @@ -0,0 +1,30 @@ +# Copyright (c) 2025, Salesforce, Inc. +# SPDX-License-Identifier: Apache-2 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from AutoFixture import Fixture, SpecimenFactory +from AutoFixture.AutoMoq import AutoMoqCustomization + +class PySimpleAutoFixtureTestBase(): + + def setup_method(self, method): + auto_moq_cust= AutoMoqCustomization() + auto_moq_cust.ConfigureMembers = True + self.fixture = Fixture().Customize(auto_moq_cust) + + def create(self, T): + return SpecimenFactory.Create[T](self.fixture) + + def create_many(self, T): + return SpecimenFactory.CreateMany[T](self.fixture) \ No newline at end of file diff --git a/tests/Python.ExampleApplication.Tests/tests/test_default_project_filter.py b/tests/Python.ExampleApplication.Tests/tests/test_default_project_filter.py index ac768328..8fc8246f 100644 --- a/tests/Python.ExampleApplication.Tests/tests/test_default_project_filter.py +++ b/tests/Python.ExampleApplication.Tests/tests/test_default_project_filter.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from autofixture import AutoFixtureTestBase +from autofixture import PySimpleAutoFixtureTestBase from default_project_filter import DefaultProjectFilter @@ -23,7 +23,7 @@ from Tableau.Migration.Content import IProject as DotnetIProject from Tableau.Migration.Engine import ContentMigrationItem as DotnetContentMigrationItem -class TestDefaultProjectFilter(AutoFixtureTestBase): +class TestDefaultProjectFilter(PySimpleAutoFixtureTestBase): def test_init(self): DefaultProjectFilter() @@ -32,10 +32,11 @@ def test_should_migrate(self): dotnet_item = self.create(DotnetContentMigrationItem[DotnetIProject]) item = ContentMigrationItem[IProject](dotnet_item) + filter = DefaultProjectFilter() result = filter.should_migrate(item) assert item.source_item.name !='Default' - assert result == True + assert result \ No newline at end of file diff --git a/tests/Python.ExampleApplication.Tests/tests/test_log_migration_batches_hook.py b/tests/Python.ExampleApplication.Tests/tests/test_log_migration_batches_hook.py index ea4ac4ab..c15ad017 100644 --- a/tests/Python.ExampleApplication.Tests/tests/test_log_migration_batches_hook.py +++ b/tests/Python.ExampleApplication.Tests/tests/test_log_migration_batches_hook.py @@ -20,5 +20,5 @@ def test_init(self): LogMigrationBatchesHookForUsers() def test_execute(self): - hook = LogMigrationBatchesHookForUsers(); + hook = LogMigrationBatchesHookForUsers() assert hook._content_type == "User" diff --git a/tests/Python.TestApplication/Python.TestApplication.pyproj b/tests/Python.TestApplication/Python.TestApplication.pyproj index 64059054..a7b5cea7 100644 --- a/tests/Python.TestApplication/Python.TestApplication.pyproj +++ b/tests/Python.TestApplication/Python.TestApplication.pyproj @@ -31,6 +31,7 @@ + diff --git a/tests/Python.TestApplication/main.py b/tests/Python.TestApplication/main.py index 76310464..7afa0e02 100644 --- a/tests/Python.TestApplication/main.py +++ b/tests/Python.TestApplication/main.py @@ -26,6 +26,7 @@ import sys import time import tableau_migration +import print_result from threading import Thread @@ -117,47 +118,6 @@ def load_manifest(self, manifest_path: str) -> PyMigrationManifest: return None - def print_result(self, result: PyMigrationResult): - """Prints the result of a migration.""" - self.logger.info(f'Result: {result.status}') - - for pipeline_content_type in ServerToCloudMigrationPipeline.ContentTypes: - content_type = pipeline_content_type.ContentType - - result.manifest.entries - type_entries = [IMigrationManifestEntry(x) for x in result.manifest.entries.ForContentType(content_type)] - - count_total = len(type_entries) - - count_migrated = 0 - count_skipped = 0 - count_errored = 0 - count_cancelled = 0 - count_pending = 0 - - for entry in type_entries: - if entry.status == MigrationManifestEntryStatus.MIGRATED: - count_migrated += 1 - elif entry.status == MigrationManifestEntryStatus.SKIPPED: - count_skipped += 1 - elif entry.status == MigrationManifestEntryStatus.ERROR: - count_errored += 1 - elif entry.status == MigrationManifestEntryStatus.CANCELED: - count_cancelled += 1 - elif entry.status == MigrationManifestEntryStatus.PENDING: - count_pending += 1 - - output = f''' - {content_type.Name} - \t{count_migrated}/{count_total} succeeded - \t{count_skipped}/{count_total} skipped - \t{count_errored}/{count_total} errored - \t{count_cancelled}/{count_total} cancelled - \t{count_pending}/{count_total} pending - ''' - - self.logger.info(output) - def migrate(self): """The main migration function.""" self.logger.info("Starting migration") @@ -234,7 +194,7 @@ def migrate(self): # Save the manifest. self._manifest_serializer.save(result.manifest, helper.manifest_path) - self.print_result(result) + print_result.print_result(result, self.logger) self.logger.info(f'Migration Started: {time.ctime(start_time)}') self.logger.info(f'Migration Ended: {time.ctime(end_time)}') diff --git a/tests/Python.TestApplication/migration_testcomponents_filters.py b/tests/Python.TestApplication/migration_testcomponents_filters.py index 594d573b..5b981aa8 100644 --- a/tests/Python.TestApplication/migration_testcomponents_filters.py +++ b/tests/Python.TestApplication/migration_testcomponents_filters.py @@ -146,16 +146,17 @@ def __init__(self, logger_name: str): """Default init to set up logging.""" self._logger = logging.getLogger(logger_name) self._logger.setLevel(logging.DEBUG) + self._skipped_project = helper.config['skipped_project'] def should_migrate(self, item: ContentMigrationItem[TContent], services) -> bool: - if item.source_item.location.parent().path != helper.config['skipped_project']: + if not self._skipped_project or item.source_item.location.parent().path != self._skipped_project: return True source_project_finder = services.get_source_finder(IProject) content_reference = source_project_finder.find_by_source_location(item.source_item.location.parent()) - self._logger.info('Skipping %s that belongs to "%s" (Project ID: %s)', self.__orig_class__.__args__[0].__name__, helper.config['skipped_project'], content_reference.id) + self._logger.info('Skipping %s that belongs to "%s" (Project ID: %s)', self.__orig_class__.__args__[0].__name__, self._skipped_project, content_reference.id) return False class SkipProjectByParentLocationFilter(ContentFilterBase[IProject]): # noqa: N801 diff --git a/tests/Python.TestApplication/print_result.py b/tests/Python.TestApplication/print_result.py new file mode 100644 index 00000000..f363a92c --- /dev/null +++ b/tests/Python.TestApplication/print_result.py @@ -0,0 +1,48 @@ +from tableau_migration import ( + IMigrationManifestEntry, + MigrationManifestEntryStatus, + MigrationResult, + ServerToCloudMigrationPipeline +) + +from logging import Logger + +def print_result(result: MigrationResult, logger: Logger): + """Prints the result of a migration.""" + logger.info(f'Result: {result.status}') + + for pipeline_content_type in ServerToCloudMigrationPipeline.get_content_types(): + content_type = pipeline_content_type.content_type + + type_entries = [IMigrationManifestEntry(x) for x in result.manifest.entries.ForContentType(content_type)] + + count_total = len(type_entries) + + count_migrated = 0 + count_skipped = 0 + count_errored = 0 + count_cancelled = 0 + count_pending = 0 + + for entry in type_entries: + if entry.status == MigrationManifestEntryStatus.MIGRATED: + count_migrated += 1 + elif entry.status == MigrationManifestEntryStatus.SKIPPED: + count_skipped += 1 + elif entry.status == MigrationManifestEntryStatus.ERROR: + count_errored += 1 + elif entry.status == MigrationManifestEntryStatus.CANCELED: + count_cancelled += 1 + elif entry.status == MigrationManifestEntryStatus.PENDING: + count_pending += 1 + + output = f''' + {content_type.Name} + \t{count_migrated}/{count_total} succeeded + \t{count_skipped}/{count_total} skipped + \t{count_errored}/{count_total} errored + \t{count_cancelled}/{count_total} cancelled + \t{count_pending}/{count_total} pending + ''' + + logger.info(output) diff --git a/tests/Python.TestApplication/pyproject.toml b/tests/Python.TestApplication/pyproject.toml index 579871e4..63bfc64b 100644 --- a/tests/Python.TestApplication/pyproject.toml +++ b/tests/Python.TestApplication/pyproject.toml @@ -15,10 +15,10 @@ classifiers = [ dependencies = [ "typing_extensions==4.12.2", - "cffi==1.16.0", + "cffi==1.17.1", "pycparser==2.22", - "pythonnet==3.0.3", - "configparser==7.0.0" + "pythonnet==3.0.4", + "configparser==7.1.0" ] [tool.ruff] @@ -29,7 +29,7 @@ ignore = ["D401", "D407", "E501", "D203", "D212"] [tool.hatch.envs.test] dev-mode = false dependencies = [ - "pytest>=8.2.2" + "pytest>=8.3.3" ] [tool.hatch.envs.test.scripts] diff --git a/tests/Python.TestApplication/requirements.txt b/tests/Python.TestApplication/requirements.txt index 9cddc614..705869f2 100644 --- a/tests/Python.TestApplication/requirements.txt +++ b/tests/Python.TestApplication/requirements.txt @@ -1,5 +1,5 @@ typing_extensions==4.12.2 -cffi==1.16.0 +cffi==1.17.1 pycparser==2.22 -pythonnet==3.0.3 -configparser==7.0.0 +pythonnet==3.0.4 +configparser==7.1.0 diff --git a/tests/Tableau.Migration.TestApplication/Config/SkipIdsOptions.cs b/tests/Tableau.Migration.TestApplication/Config/SkipIdsOptions.cs new file mode 100644 index 00000000..d347200b --- /dev/null +++ b/tests/Tableau.Migration.TestApplication/Config/SkipIdsOptions.cs @@ -0,0 +1,82 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.TestApplication.Config +{ + public class SkipIdsOptions + { + #region Json Properties + + public string[] users { get; set; } = Array.Empty(); + + public string[] groups { get; set; } = Array.Empty(); + + public string[] projects { get; set; } = Array.Empty(); + + public string[] workbooks { get; set; } = Array.Empty(); + + public string[] datasources { get; set; } = Array.Empty(); + + #endregion + + #region Helper Properties + + public Guid[] UserGuids + { + get + { + return Array.ConvertAll(users, Guid.Parse); + } + } + + public Guid[] GroupGuids + { + get + { + return Array.ConvertAll(groups, Guid.Parse); + } + } + + public Guid[] ProjectGuids + { + get + { + return Array.ConvertAll(projects, Guid.Parse); + } + } + + public Guid[] WorkbookGuids + { + get + { + return Array.ConvertAll(workbooks, Guid.Parse); + } + } + + public Guid[] DatasourceGuids + { + get + { + return Array.ConvertAll(datasources, Guid.Parse); + } + } + + #endregion + } +} diff --git a/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs b/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs index dad02aa7..6a2f5e16 100644 --- a/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs +++ b/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs @@ -31,6 +31,8 @@ public sealed class TestApplicationOptions public SpecialUsersOptions SpecialUsers { get; set; } = new(); + public SkipIdsOptions SkipIds { get; set; } = new(); + public string PreviousManifestPath { get; set; } = ""; public string SkippedProject { get; set; } = string.Empty; @@ -38,5 +40,7 @@ public sealed class TestApplicationOptions public string SkippedMissingParentDestination { get; set; } = "Missing Parent"; public string[] SkipTypes { get; set; } = Array.Empty(); + + } } diff --git a/tests/Tableau.Migration.TestApplication/Hooks/ContentWithinSkippedLocationMapping.cs b/tests/Tableau.Migration.TestApplication/Hooks/ContentWithinSkippedLocationMapping.cs index ad9ec29a..410ae9fa 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/ContentWithinSkippedLocationMapping.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/ContentWithinSkippedLocationMapping.cs @@ -24,11 +24,12 @@ using Tableau.Migration.Content; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Mappings; +using Tableau.Migration.Resources; using Tableau.Migration.TestApplication.Config; namespace Tableau.Migration.TestApplication.Hooks { - class ContentWithinSkippedLocationMapping : IContentMapping + class ContentWithinSkippedLocationMapping : ContentMappingBase where TContent : IContentReference { private readonly ContentLocation _skippedParentProject; @@ -37,9 +38,10 @@ class ContentWithinSkippedLocationMapping : IContentMapping private readonly ILogger> _logger; public ContentWithinSkippedLocationMapping( + ISharedResourcesLocalizer localizer, ILogger> logger, IDestinationContentReferenceFinderFactory destinationContentReferenceFinderFactory, - IOptions options) + IOptions options) : base(localizer, logger) { _destinationProjectContentReferenceFinder = destinationContentReferenceFinderFactory.ForDestinationContentType(); _logger = logger; @@ -47,7 +49,7 @@ public ContentWithinSkippedLocationMapping( _missingParentLocation = ContentLocation.FromPath(options.Value.SkippedMissingParentDestination); } - public async Task?> ExecuteAsync( + public override async Task?> MapAsync( ContentMappingContext ctx, CancellationToken cancel) { diff --git a/tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs b/tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs index 9505d8ef..cfed8112 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs @@ -45,7 +45,7 @@ public LogMigrationBatchSummaryHook(IMigrationManifest manifest, var entries = _manifest.Entries.ForContentType(); var contentTypeName = MigrationPipelineContentType.GetConfigKeyForType(typeof(T)); - + var processedCount = entries.GetStatusTotals() .Where(s => s.Key is not MigrationManifestEntryStatus.Pending) .Sum(s => s.Value); diff --git a/tests/Tableau.Migration.TestApplication/Hooks/NonDomainUserFilter.cs b/tests/Tableau.Migration.TestApplication/Hooks/NonDomainUserFilter.cs index 33b4baf4..46b2e52d 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/NonDomainUserFilter.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/NonDomainUserFilter.cs @@ -39,7 +39,7 @@ public class NonDomainUserFilter : ContentFilterBase public NonDomainUserFilter( IOptions options, ISharedResourcesLocalizer localizer, - ILogger> logger) : base (localizer, logger) + ILogger logger) : base (localizer, logger) { _options = options.Value; diff --git a/tests/Tableau.Migration.TestApplication/Hooks/RemoveMissingDestinationUsersFromGroupsTransformer.cs b/tests/Tableau.Migration.TestApplication/Hooks/RemoveMissingDestinationUsersFromGroupsTransformer.cs index 6f62391e..c882a625 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/RemoveMissingDestinationUsersFromGroupsTransformer.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/RemoveMissingDestinationUsersFromGroupsTransformer.cs @@ -32,7 +32,7 @@ class RemoveMissingDestinationUsersFromGroupsTransformer : ContentTransformerBas public RemoveMissingDestinationUsersFromGroupsTransformer( ISharedResourcesLocalizer localizer, - ILogger> logger, + ILogger logger, IDestinationContentReferenceFinderFactory destinationContentReferenceFinderFactory) : base(localizer, logger) { diff --git a/tests/Tableau.Migration.TestApplication/Hooks/SkipIdsFilter.cs b/tests/Tableau.Migration.TestApplication/Hooks/SkipIdsFilter.cs new file mode 100644 index 00000000..bf917d46 --- /dev/null +++ b/tests/Tableau.Migration.TestApplication/Hooks/SkipIdsFilter.cs @@ -0,0 +1,79 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tableau.Migration.Content; +using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Hooks.Filters; +using Tableau.Migration.Resources; +using Tableau.Migration.TestApplication.Config; + +namespace Tableau.Migration.TestApplication.Hooks +{ + public class SkipIdsFilter : ContentFilterBase + where TContent : IContentReference + { + private readonly Guid[] idsToSkip; + + public SkipIdsFilter( + IOptions options, + ISharedResourcesLocalizer localizer, + ILogger> logger) : base(localizer, logger) + { + Type filterType = typeof(TContent); + + if (filterType == typeof(IUser)) + { + idsToSkip = options.Value.SkipIds.UserGuids; + } + else if (filterType == typeof(IGroup)) + { + idsToSkip = options.Value.SkipIds.GroupGuids; + } + else if (filterType == typeof(IProject)) + { + idsToSkip = options.Value.SkipIds.ProjectGuids; + } + else if (filterType == typeof(IDataSource)) + { + idsToSkip = options.Value.SkipIds.DatasourceGuids; + } + else if (filterType == typeof(IWorkbook)) + { + idsToSkip = options.Value.SkipIds.WorkbookGuids; + } + else + { + idsToSkip = Array.Empty(); + } + } + + + public override bool ShouldMigrate(ContentMigrationItem item) + { + if (idsToSkip.Contains(item.SourceItem.Id)) + { + return false; + } + + return true; + } + } +} diff --git a/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserFilter.cs b/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserFilter.cs index 2b4b1208..872bcb00 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserFilter.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserFilter.cs @@ -34,7 +34,7 @@ public class SpecialUserFilter : ContentFilterBase public SpecialUserFilter( IOptions options, ISharedResourcesLocalizer localizer, - ILogger> logger) : base(localizer, logger) + ILogger logger) : base(localizer, logger) { _options = options.Value; } diff --git a/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserMapping.cs b/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserMapping.cs index a9719d92..e41ee529 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserMapping.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/SpecialUserMapping.cs @@ -36,7 +36,7 @@ public class SpecialUserMapping : ContentMappingBase public SpecialUserMapping( IOptions options, ISharedResourcesLocalizer localizer, - ILogger> logger) + ILogger logger) : base(localizer, logger) { _options = options.Value; diff --git a/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserFilter.cs b/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserFilter.cs index 538bb7cb..d7e1dcef 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserFilter.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserFilter.cs @@ -35,7 +35,7 @@ public class UnlicensedUserFilter : ContentFilterBase { public UnlicensedUserFilter( ISharedResourcesLocalizer localizer, - ILogger> logger) + ILogger logger) : base(localizer, logger) { } public override bool ShouldMigrate(ContentMigrationItem item) diff --git a/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserMapping.cs b/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserMapping.cs index d432e363..efe7a472 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserMapping.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/UnlicensedUserMapping.cs @@ -36,7 +36,7 @@ public class UnlicensedUserMapping : ContentMappingBase public UnlicensedUserMapping( IOptions options, ISharedResourcesLocalizer localizer, - ILogger> logger) : base(localizer, logger) + ILogger logger) : base(localizer, logger) { _adminUser = ContentLocation.ForUsername(options.Value.SpecialUsers.AdminDomain, options.Value.SpecialUsers.AdminUsername); } diff --git a/tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs b/tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs index d5eee1a7..87891d19 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs @@ -19,8 +19,11 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Tableau.Migration.Api; using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Content; +using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers; using Tableau.Migration.Resources; @@ -39,11 +42,13 @@ public class ViewerOwnerTransformer : ContentTransformerBase { private readonly ContentLocation _adminUser; private readonly IDestinationContentReferenceFinder _destinationContentReferenceFinder; + private readonly IUsersApiClient _usersApiClient; public ViewerOwnerTransformer( ISharedResourcesLocalizer localizer, - ILogger> logger, + ILogger> logger, IDestinationContentReferenceFinder destinationContentReferenceFinder, + IMigration migration, IOptions options) : base(localizer, logger) { @@ -51,24 +56,37 @@ public ViewerOwnerTransformer( _adminUser = ContentLocation.ForUsername(_options.SpecialUsers.AdminDomain, _options.SpecialUsers.AdminUsername); _destinationContentReferenceFinder = destinationContentReferenceFinder; + _usersApiClient = ((TableauApiDestinationEndpoint)migration.Destination).SiteApi.Users; } public async override Task TransformAsync(TPublish itemToTransform, CancellationToken cancel) { - var owner = await _destinationContentReferenceFinder.FindByIdAsync(itemToTransform.Owner.Id, cancel) as IUser; - + // At this point, the owner of the itemToTransform is already mapped, so we need to find it on the destination + var owner = await _destinationContentReferenceFinder.FindByMappedLocationAsync(itemToTransform.Owner.Location, cancel) as IUser; + + if (owner == null) + { + // If owner is null, then the _destinationContentReferenceFinder either couldn't find the user, or it was a ContentReferenceStub that couldn't be turned into an IUser + // A ContentReferenceStub doesn't have the license level, so we need to get the owner from the API to get the complete content item. + var ownerResult = await _usersApiClient.GetByIdAsync(itemToTransform.Owner.Id, cancel).ConfigureAwait(false); + if (ownerResult.Success) + { + owner = ownerResult.Value; + } + } + if (owner == null) { - throw new System.Exception("Owner not found"); + throw new System.Exception($"Owner {itemToTransform.Owner.Location} for {typeof(TPublish).GetFormattedName()} named {itemToTransform.Location} could not be found."); } if (owner.LicenseLevel == LicenseLevels.Viewer) { - var adminUser = await _destinationContentReferenceFinder.FindBySourceLocationAsync(_adminUser, cancel); + var adminUser = await _destinationContentReferenceFinder.FindByMappedLocationAsync(_adminUser, cancel); if (adminUser == null) { - throw new System.Exception("Admin user not found"); + throw new System.Exception($"Admin user {_adminUser} not found"); } itemToTransform.Owner = adminUser; diff --git a/tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs b/tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs index f7f08bcb..c1cf7f44 100644 --- a/tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs +++ b/tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs @@ -50,11 +50,11 @@ private static StringBuilder AppendContentMigrationResult(this StringBuilder sum { summaryBuilder.AppendLine(); - var contentTypeList = GetContentTypes(); + var pipelineContentTypes = MigrationPipelineContentType.GetMigrationPipelineContentTypes(result.Manifest.PipelineProfile); - foreach (var contentType in contentTypeList) + foreach (var pipelineContentType in pipelineContentTypes) { - var typeResult = result.Manifest.Entries.ForContentType(contentType); + var typeResult = result.Manifest.Entries.ForContentType(pipelineContentType.ContentType); if (typeResult is null) { @@ -70,7 +70,7 @@ private static StringBuilder AppendResultRow( this StringBuilder summaryBuilder, IMigrationManifestContentTypePartition typeResult) { - var total = typeResult.ExpectedTotalCount; + var total = typeResult.Count; if (total == 0) { @@ -134,7 +134,5 @@ private static string ToMetricString(this KeyValuePair $"{metricName}{KEY_VALUE_SEPARATOR}{metricValue}"; - private static List GetContentTypes() - => ServerToCloudMigrationPipeline.ContentTypes.Select(c => c.ContentType).ToList(); } } diff --git a/tests/Tableau.Migration.TestApplication/Program.cs b/tests/Tableau.Migration.TestApplication/Program.cs index 783d6a6c..823960bb 100644 --- a/tests/Tableau.Migration.TestApplication/Program.cs +++ b/tests/Tableau.Migration.TestApplication/Program.cs @@ -69,7 +69,8 @@ public static IServiceCollection AddCustomizations(this IServiceCollection servi .AddScoped(typeof(SkipByParentLocationFilter<>)) .AddScoped(typeof(ContentWithinSkippedLocationMapping<>)) .AddScoped() - .AddScoped(typeof(ViewerOwnerTransformer<>)); + .AddScoped(typeof(ViewerOwnerTransformer<>)) + .AddScoped(typeof(SkipIdsFilter<>)); return services; } @@ -85,9 +86,7 @@ public static IServiceCollection ConfigureLogging(this IServiceCollection servic .Enrich.WithThreadId() .Enrich.With() // Set the log level to Debug for select interfaces. - .MinimumLevel.Override("Tableau.Migration.Engine.Hooks.Filters.IContentFilter", LogEventLevel.Debug) - .MinimumLevel.Override("Tableau.Migration.Engine.Hooks.Mappings.IContentMapping", LogEventLevel.Debug) - .MinimumLevel.Override("Tableau.Migration.Engine.Hooks.Transformers.IContentTransformer", LogEventLevel.Debug) + .MinimumLevel.Override("Tableau.Migration.Engine.Hooks", LogEventLevel.Debug) .WriteTo.Logger(lc => lc // Create a filter that writes certain loggers to the console .Filter.ByIncludingOnly((logEvent) => @@ -109,6 +108,10 @@ public static IServiceCollection ConfigureLogging(this IServiceCollection servic return sourceContextToPrint.Contains(sourceContext); }) + .WriteTo.Console()) + .WriteTo.Logger(lc => lc + // Create a filter that writes fatal log events to the console + .Filter.ByIncludingOnly(logEvent => logEvent.Level == LogEventLevel.Fatal) .WriteTo.Console()); var logPath = ctx.Configuration.GetSection("log:folderPath").Value; diff --git a/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj b/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj index 4c4875be..daf254df 100644 --- a/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj +++ b/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj @@ -1,7 +1,7 @@  Exe - net8.0 + net9.0 CA2007 7d7631f1-dc4a-49de-89d5-a194544705c1 @@ -10,17 +10,17 @@ - - - + + + - + - - + + diff --git a/tests/Tableau.Migration.TestApplication/TestApplication.cs b/tests/Tableau.Migration.TestApplication/TestApplication.cs index eee2b463..54f87674 100644 --- a/tests/Tableau.Migration.TestApplication/TestApplication.cs +++ b/tests/Tableau.Migration.TestApplication/TestApplication.cs @@ -25,7 +25,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Engine.Manifest; using Tableau.Migration.Engine.Pipelines; using Tableau.Migration.TestApplication.Config; @@ -77,6 +76,8 @@ public async Task StartAsync(CancellationToken cancel) Console.WriteLine("Starting app"); _logger.LogInformation("Starting app log"); + AskIfTsmHasBeenRun(); + _planBuilder = _planBuilder .FromSourceTableauServer(_options.Source.ServerUrl, _options.Source.SiteContentUrl, _options.Source.AccessTokenName, Environment.GetEnvironmentVariable("TABLEAU_MIGRATION_SOURCE_TOKEN") ?? _options.Source.AccessToken) .ToDestinationTableauCloud(_options.Destination.ServerUrl, _options.Destination.SiteContentUrl, _options.Destination.AccessTokenName, Environment.GetEnvironmentVariable("TABLEAU_MIGRATION_DESTINATION_TOKEN") ?? _options.Destination.AccessToken) @@ -130,13 +131,21 @@ public async Task StartAsync(CancellationToken cancel) _planBuilder.Mappings.Add(); // Save manifest every every batch of every content type. - var contentTypeArrays = ServerToCloudMigrationPipeline.ContentTypes.Select(t => new[] { t.ContentType }); + var contentTypeArrays = MigrationPipelineContentType.GetMigrationPipelineContentTypes(_planBuilder.PipelineProfile) + .Select(t => new[] { t.ContentType }); + _planBuilder.Hooks.Add(typeof(LogMigrationBatchSummaryHook<>), contentTypeArrays); if (!string.IsNullOrEmpty(_options.Log.ManifestFolderPath)) { _planBuilder.Hooks.Add(typeof(SaveManifestAfterBatchMigrationCompletedHook<>), contentTypeArrays); } + // ViewOwnerTransformer + _planBuilder.Transformers.Add, IProject>(); + _planBuilder.Transformers.Add, IPublishableDataSource>(); + _planBuilder.Transformers.Add, IPublishableWorkbook>(); + _planBuilder.Transformers.Add, IPublishableCustomView>(); + // ViewOwnerTransformer _planBuilder.Transformers.Add, IProject>(); _planBuilder.Transformers.Add, IDataSource>(); @@ -153,10 +162,18 @@ public async Task StartAsync(CancellationToken cancel) _planBuilder.Mappings.Add, IDataSource>(); _planBuilder.Mappings.Add, IWorkbook>(); + // Skip content with specific IDs + _planBuilder.Filters.Add, IUser>(); + _planBuilder.Filters.Add, IGroup>(); + _planBuilder.Filters.Add, IProject>(); + _planBuilder.Filters.Add, IDataSource>(); + _planBuilder.Filters.Add, IWorkbook>(); + + var prevManifest = await LoadManifest(_options.PreviousManifestPath, cancel); // Start timer - var startTime = DateTime.UtcNow; + var startTime = DateTime.Now; _timer.Start(); // Build plan @@ -167,7 +184,7 @@ public async Task StartAsync(CancellationToken cancel) _timer.Stop(); - var endTime = DateTime.UtcNow; + var endTime = DateTime.Now; await _manifestSerializer.SaveAsync(result.Manifest, manifestFilePath); @@ -178,6 +195,30 @@ public async Task StartAsync(CancellationToken cancel) _appLifetime.StopApplication(); } + private void AskIfTsmHasBeenRun() + { + ConsoleKey key; + do + { + Console.Write("Has the "); + Console.ForegroundColor = ConsoleColor.Blue; + Console.Write("tsm security authorize-credential"); + Console.ResetColor(); + Console.Write(" command been run? [Y/n] "); + key = Console.ReadKey().Key; + Console.WriteLine(); + } while (key is not ConsoleKey.Enter && key is not ConsoleKey.Y && key is not ConsoleKey.N); + + if (key is ConsoleKey.N) + { + Console.WriteLine("Please run the tsm command before proceeding."); + Console.WriteLine("Press any key to exit"); + Console.ReadKey(); + _appLifetime.StopApplication(); + return; + } + } + public Task StopAsync(CancellationToken cancel) => Task.CompletedTask; diff --git a/tests/Tableau.Migration.TestApplication/appsettings.json b/tests/Tableau.Migration.TestApplication/appsettings.json index dfdc7043..f465c688 100644 --- a/tests/Tableau.Migration.TestApplication/appsettings.json +++ b/tests/Tableau.Migration.TestApplication/appsettings.json @@ -71,5 +71,13 @@ //"IWorkbook", //"IServerExtractRefreshTask", //"ICustomView" - ] + ], + + "skipIds": { + "users": [], + "groups": [], + "projects": [], + "datasources": [], + "workbooks": [] + } } diff --git a/tests/Tableau.Migration.Tests/FixtureFactory.cs b/tests/Tableau.Migration.Tests/FixtureFactory.cs index 39291202..cb2d5ffa 100644 --- a/tests/Tableau.Migration.Tests/FixtureFactory.cs +++ b/tests/Tableau.Migration.Tests/FixtureFactory.cs @@ -25,11 +25,11 @@ using AutoFixture; using AutoFixture.AutoMoq; using AutoFixture.Kernel; -using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Simulation; using Tableau.Migration.Content; using Tableau.Migration.Content.Schedules; using Tableau.Migration.Engine.Manifest; @@ -215,7 +215,9 @@ static string GetRandomValue(IEnumerable values) // These properties should return nullable bool strings instead of the default Guid-like ones. fixture.Customize(composer => composer - .With(j => j.QueryTaggingEnabled, () => fixture.Create().ToString())); + .With(j => j.QueryTaggingEnabled, () => fixture.Create().ToString()) + .With(j => j.UseOAuthManagedKeychain, () => fixture.Create().ToString()) + .With(j => j.EmbedPassword, () => fixture.Create().ToString())); #endregion @@ -223,7 +225,9 @@ static string GetRandomValue(IEnumerable values) // These properties should return nullable bool strings instead of the default Guid-like ones. fixture.Customize(composer => composer - .With(j => j.QueryTaggingEnabled, () => fixture.Create().ToString())); + .With(j => j.QueryTaggingEnabled, () => fixture.Create().ToString()) + .With(j => j.UseOAuthManagedKeychain, () => fixture.Create().ToString()) + .With(j => j.EmbedPassword, () => fixture.Create().ToString())); #endregion @@ -315,6 +319,14 @@ string GetRandomExtractType() #endregion + #region - SimulatedConnectionCredentials - + + // These properties should return nullable bool strings instead of the default Guid-like ones. + fixture.Customize(composer => composer + .With(j => j.Embed, () => fixture.Create().ToString())); + + #endregion + return fixture; } @@ -355,9 +367,9 @@ internal static SerializableManifestEntry CreateSerializableManifestEntry(IFixtu internal static MigrationManifest CreateMigrationManifest(IFixture fixture) { - var ret = new MigrationManifest(fixture.Create(), fixture.Create(), Guid.NewGuid(), Guid.NewGuid()); + var ret = new MigrationManifest(Guid.NewGuid(), Guid.NewGuid(), PipelineProfile.ServerToCloud); - foreach (var type in ServerToCloudMigrationPipeline.ContentTypes) + foreach (var type in MigrationPipelineContentType.GetMigrationPipelineContentTypes(ret.PipelineProfile)) { var p = ret.Entries.GetOrCreatePartition(type.ContentType); p.CreateEntries(fixture.CreateMany().ToList()); @@ -409,7 +421,7 @@ public static List CreateErrors(IFixture fixture, int countOfEach = 1 var tableauMigrationAssembly = loadedAssemblies.Where(a => a.ManifestModule.Name == "Tableau.Migration.dll").First(); var exceptionTypes = tableauMigrationAssembly.GetTypes() - .Where(t => t.BaseType == typeof(Exception) && !t.IsAbstract) + .Where(t => t.IsAssignableTo(typeof(Exception)) && !t.IsAbstract) .Where(t => t != typeof(MismatchException)) // MismatchException will never be in a manifest .ToList(); diff --git a/tests/Tableau.Migration.Tests/IViewReferenceTypeComparer.cs b/tests/Tableau.Migration.Tests/IViewReferenceTypeComparer.cs index 5108cc84..6c8da67b 100644 --- a/tests/Tableau.Migration.Tests/IViewReferenceTypeComparer.cs +++ b/tests/Tableau.Migration.Tests/IViewReferenceTypeComparer.cs @@ -22,11 +22,11 @@ namespace Tableau.Migration.Tests { - internal class IViewReferenceTypeComparer : ComparerBase + internal class IViewReferenceTypeComparer : ComparerBase { public static IViewReferenceTypeComparer Instance = new(); - protected override int CompareItems(IViewReferenceType x, IViewReferenceType y) + protected override int CompareItems(IWorkbookViewReferenceType x, IWorkbookViewReferenceType y) { Assert.NotNull(x.ContentUrl); Assert.NotNull(y.ContentUrl); diff --git a/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs b/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs index 34ebf3db..5490053d 100644 --- a/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs +++ b/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs @@ -16,8 +16,10 @@ // using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; +using System.Diagnostics.Metrics; using System.Linq; using AutoFixture; using Microsoft.Extensions.DependencyInjection; @@ -25,6 +27,8 @@ using Tableau.Migration.Api; using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Tableau.Migration.Api.Rest.Models.Responses.Server; using Tableau.Migration.Api.Rest.Models.Types; using Tableau.Migration.Api.Simulation; using Tableau.Migration.Config; @@ -140,13 +144,102 @@ protected static void AssertPermissionsMigrated(IMigrationManifest manifest, Per Assert.NotNull(sourceGranteeCapabilities); Assert.NotNull(destinationGranteeCapabilities); - var mappedGranteeCapabilities = sourceGranteeCapabilities.Select(g => ServerToCloudSimulationTestBase.MapGrantee(manifest, g)); + var mappedGranteeCapabilities = sourceGranteeCapabilities.Select(g => MapGrantee(manifest, g)); var comparer = new IGranteeCapabilityComparer(false); Assert.Equal(mappedGranteeCapabilities.ToIGranteeCapabilities(), destinationGranteeCapabilities.ToIGranteeCapabilities(), comparer); } + protected static void AssertEmbeddedCredentialsMigrated( + IMigrationManifest manifest, + RetrieveKeychainResponse sourceKeychains, + RetrieveKeychainResponse destinationKeychains, + ConcurrentDictionary sourceUserSavedCredentials, + ConcurrentDictionary destinationUserSavedCredentials) + { + Assert.Equal(sourceKeychains.EncryptedKeychainList, destinationKeychains.EncryptedKeychainList); + + var sourceUserIds = sourceKeychains.AssociatedUserLuidList.ToList(); + + var userMappings = GetUserMappings(manifest, sourceUserIds); + + var expectedUserIds = userMappings.Select(uid => uid.Value).ToArray(); + + Assert.Equal(expectedUserIds, destinationKeychains.AssociatedUserLuidList); + + foreach (var userMapping in userMappings) + { + AssertSavedCredentials(sourceUserSavedCredentials, destinationUserSavedCredentials, userMapping.Key, userMapping.Value); + } + + static Dictionary GetUserMappings(IMigrationManifest manifest, List sourceUserIds) + { + var result = new Dictionary(); + + foreach (var sourceUserId in sourceUserIds) + { + var destinationUser = MapReference(manifest, sourceUserId); + if (destinationUser is null) + { + continue; + } + Assert.True(result.TryAdd(sourceUserId, destinationUser.Id)); + } + + return result; + } + + static void AssertSavedCredentials( + ConcurrentDictionary sourceUserSavedCredentials, + ConcurrentDictionary destinationUserSavedCredentials, + Guid sourceUserId, + Guid destinationUserId) + { + sourceUserSavedCredentials.TryGetValue(sourceUserId, out RetrieveKeychainResponse? sourceCreds); + + if (sourceCreds is null) + return; ; + + destinationUserSavedCredentials.TryGetValue(destinationUserId, out RetrieveKeychainResponse? destinationCreds); + Assert.NotNull(destinationCreds); + + var sourceUserKeychains = sourceCreds.EncryptedKeychainList; + var destinationUserKeychains = destinationCreds.EncryptedKeychainList; + Assert.Equal(sourceUserKeychains.Length, destinationUserKeychains.Length); + + var sourceUserLuids = sourceCreds.AssociatedUserLuidList; + var destUserLuids = destinationCreds.AssociatedUserLuidList; + Assert.Equal(sourceUserLuids.Length, destUserLuids.Length); + + foreach (var keyChain in sourceUserKeychains) + { + Assert.Contains(destinationUserKeychains, kc => string.Equals(kc, keyChain, StringComparison.OrdinalIgnoreCase)); + } + } + } + + protected static void AssertScheduleMigrated(ScheduleResponse.ScheduleType sourceSchedule, ICloudScheduleType? destinationSchedule) + { + Assert.NotNull(destinationSchedule); + + // Assert schedule information + // This can't be done completely without manually writing the source and destination schedules to compare against. + // Server schedules requirements are different than Cloud schedule requirements. So we just check the frequence and start time. + // We can check frequency because non of the source schedule we built will change frequency to cloud, even though that is a possibilty, + // we just didn't include those. + Assert.Equal(sourceSchedule.Frequency, destinationSchedule.Frequency); + Assert.Equal(sourceSchedule.FrequencyDetails.Start, destinationSchedule.FrequencyDetails!.Start); + if (sourceSchedule.FrequencyDetails.End is null) + { + Assert.Null(destinationSchedule.FrequencyDetails.End); + } + else + { + Assert.Equal(sourceSchedule.FrequencyDetails.End, destinationSchedule.FrequencyDetails.End); + } + } + #endregion #region - Prepare Source Data (Users) - @@ -186,6 +279,13 @@ protected static void AssertPermissionsMigrated(IMigrationManifest manifest, Per } SourceApi.Data.Users.Add(user); + + var savedCredentials = (i % 2) switch + { + 0 => new RetrieveKeychainResponse(), + _ => new RetrieveKeychainResponse(CreateMany(), [user.Id]) + }; + SourceApi.Data.UserSavedCredentials.TryAdd(user.Id, savedCredentials); } return (nonSupportUsers, supportUsers); @@ -305,6 +405,44 @@ protected static void AssertPermissionsMigrated(IMigrationManifest manifest, Per #endregion - Prepare Source Data (Projects) - + #region - Prepare Embedded Credentials - + + // Creates a number of connections and returns the simulated "workbook file data" as a byte array + private void CreateConnections(SimulatedDataWithConnections data, bool embed, int connectionCount = 2) + { + for (int i = 0; i < connectionCount; i++) + { + var conn = Create(); + data.Connections.Add(conn); + + if (embed) + { + conn.Credentials = Create(); + conn.Credentials.Embed = "true"; + } + else if (conn.Credentials is not null) + { + conn.Credentials.Embed = null; + } + } + } + + private RetrieveKeychainResponse PrepareEmbeddedCredentials(ConcurrentDictionary keychainCollection, + List users, Guid itemId, int counter) + { + var keychains = (counter % 3) switch + { + 0 => new RetrieveKeychainResponse(), + 1 => new RetrieveKeychainResponse(CreateMany(), [users[counter % users.Count].Id]), + _ => new RetrieveKeychainResponse(CreateMany(), [users[counter % users.Count].Id, users[(counter + 1) % users.Count].Id]) + }; + + keychainCollection.TryAdd(itemId, keychains); + return keychains; + } + + #endregion + #region - Prepare Source Data (Data Sources) - protected List PrepareSourceDataSourceData() @@ -322,6 +460,8 @@ protected static void AssertPermissionsMigrated(IMigrationManifest manifest, Per var dataSource = Create(); dataSource.Project = new DataSourceResponse.DataSourceType.ProjectType { Id = project.Id, Name = project.Name }; + var dataSourceFileData = new SimulatedDataSourceData(); + var owner = users[counter % users.Count]; dataSource.Owner = new DataSourceResponse.DataSourceType.OwnerType { Id = owner.Id }; @@ -350,9 +490,14 @@ protected static void AssertPermissionsMigrated(IMigrationManifest manifest, Per Assert.NotNull(dataSource.Tags); Assert.NotEmpty(dataSource.Tags); + PrepareEmbeddedCredentials(SourceApi.Data.DataSourceKeychains, users, dataSource.Id, counter); + + var keychains = PrepareEmbeddedCredentials(SourceApi.Data.DataSourceKeychains, users, dataSource.Id, counter); + CreateConnections(dataSourceFileData, embed: keychains.EncryptedKeychainList.Any()); + // Our data source data will just be a guid as a string, encoded to a byte array - byte[] dataSourceData = Constants.DefaultEncoding.GetBytes($"{Guid.NewGuid()}"); + byte[] dataSourceData = Constants.DefaultEncoding.GetBytes(dataSourceFileData.ToXml()); SourceApi.Data.AddDataSource(dataSource, dataSourceData); dataSources.Add(dataSource); counter++; @@ -420,15 +565,6 @@ void CreateViewsForWorkbook(WorkbookResponse.WorkbookType workbook, SimulatedWor } } - // Creates a number of connections and returns the simulated "workbook file data" as a byte array - void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connectionCount = 2) - { - for (int i = 0; i < connectionCount; i++) - { - workbookData.Connections.Add(Create()); - } - } - /* * Build workbook metadata * Add owner to workbook @@ -453,7 +589,7 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec // Create workbook var workbook = AutoFixture.Build() // Views need to be saved in the workbook data, so created at a later step - .With(x => x.Views, Array.Empty()) + .With(x => x.Views, Array.Empty()) .Create(); var workbookFileData = new SimulatedWorkbookData(); @@ -489,7 +625,9 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec CreateViewsForWorkbook(workbook, workbookFileData); - CreateConnectionsForWorkbook(workbookFileData); + var keychains = PrepareEmbeddedCredentials(SourceApi.Data.WorkbookKeychains, users, workbook.Id, counter); + CreateConnections(workbookFileData, embed: keychains.EncryptedKeychainList.Any()); + CreateViewsForWorkbook(workbook, workbookFileData); SourceApi.Data.AddWorkbook(workbook, Constants.DefaultEncoding.GetBytes(workbookFileData.ToXml())); @@ -741,7 +879,7 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec CustomViewResponse.CustomViewType CreateCustomView( WorkbookResponse.WorkbookType workbook, - WorkbookResponse.WorkbookType.ViewReferenceType view, + WorkbookResponse.WorkbookType.WorkbookViewReferenceType view, WorkbookResponse.WorkbookType.OwnerType owner) { return AutoFixture.Build() @@ -769,5 +907,49 @@ CustomViewResponse.CustomViewType CreateCustomView( } } #endregion + + #region - Prepare Source Data (Subscriptions) - + + protected IImmutableList PrepareSourceSubscriptionsData() + { + var subscriptions = ImmutableArray.CreateBuilder(); + var schedules = PrepareSchedulesData(); + + foreach (var workbook in SourceApi.Data.Workbooks) + { + var workbookSubscription = Create(); + + var user = SourceApi.Data.Users.PickRandom(); + workbookSubscription.User = new() { Id = user.Id, Name = user.Name }; + workbookSubscription.Content = new() { Id = workbook.Id, Type = "workbook" }; + subscriptions.Add(workbookSubscription); + + var schedule = SourceApi.Data.Schedules.PickRandom(); + workbookSubscription.Schedule = new() { Id = schedule.Id, Name = schedule.Name }; + + foreach(var view in workbook.Views) + { + var viewSubscription = Create(); + + user = SourceApi.Data.Users.PickRandom(); + viewSubscription.User = new() { Id = user.Id, Name = user.Name }; + viewSubscription.Content = new() { Id = view.Id, Type = "view" }; + + schedule = SourceApi.Data.Schedules.PickRandom(); + viewSubscription.Schedule = new() { Id = schedule.Id, Name = schedule.Name }; + + subscriptions.Add(viewSubscription); + } + } + + foreach (var subscription in subscriptions) + { + SourceApi.Data.ServerSubscriptions.Add(subscription); + } + + return subscriptions.ToImmutable(); + } + + #endregion } } diff --git a/tests/Tableau.Migration.Tests/Simulation/TableauDataExtensions.cs b/tests/Tableau.Migration.Tests/Simulation/TableauDataExtensions.cs index 2ee07641..048980a2 100644 --- a/tests/Tableau.Migration.Tests/Simulation/TableauDataExtensions.cs +++ b/tests/Tableau.Migration.Tests/Simulation/TableauDataExtensions.cs @@ -142,7 +142,7 @@ public static PermissionsType CreateWorkbookPermissions( public static PermissionsType CreateViewPermissions( this TableauData data, IFixture autoFixture, - WorkbookResponse.WorkbookType.ViewReferenceType view, + WorkbookResponse.WorkbookType.WorkbookViewReferenceType view, Guid viewId, string? viewName) { diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/Api/ApiClientTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/Api/ApiClientTests.cs index 53c5bd4a..33fb79c0 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/Api/ApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/Api/ApiClientTests.cs @@ -223,7 +223,7 @@ public async Task Returns_error_on_invalid_credentials() var error = Assert.Single(result.Errors); var restException = Assert.IsType(error); - Assert.Equal("401001", restException.Code); + Assert.Equal(RestErrorCodes.LOGIN_ERROR, restException.Code); Assert.Equal(errorBuilder.Summary, restException.Summary); Assert.Equal(errorBuilder.Detail, restException.Detail); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/Api/SubscriptionsApiClientTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/Api/SubscriptionsApiClientTests.cs new file mode 100644 index 00000000..6aeb5651 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/Api/SubscriptionsApiClientTests.cs @@ -0,0 +1,149 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Threading.Tasks; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Simulation.Tests.Api +{ + public sealed class SubscriptionsApiClientTests + { + public abstract class SubscriptionsApiClientTest : ApiClientTestBase + { + public SubscriptionsApiClientTest(bool isCloud = false) + : base(isCloud) + { } + } + + #region - Cloud - + + #region - CreateSubscriptionAsync - + + public sealed class CreateSubscriptionAsyncTests : SubscriptionsApiClientTest + { + public CreateSubscriptionAsyncTests() + : base(true) + { } + + [Fact] + public async Task CreateWorkbookSubscriptionAsync() + { + await using var sitesClient = await GetSitesClientAsync(Cancel); + + var wb = Create(); + Api.Data.AddWorkbook(wb, null); + + var subscription = Create(); + subscription.Content = new SubscriptionContent(wb.Id, "Workbook", false); + subscription.Owner = new ContentReferenceStub(Api.Data.Users.First().Id, "", new()); + + var result = await sitesClient.CloudSubscriptions.CreateSubscriptionAsync(subscription, Cancel); + + result.AssertSuccess(); + + var createdSub = Api.Data.CloudSubscriptions.SingleOrDefault(); + Assert.NotNull(createdSub); + Assert.Equal(createdSub.Id, result.Value!.Id); + } + + [Fact] + public async Task CreateWorkbookSubscriptionInvalidWorkbookAsync() + { + await using var sitesClient = await GetSitesClientAsync(Cancel); + + var subscription = Create(); + subscription.Content = new SubscriptionContent(Guid.NewGuid(), "Workbook", false); + subscription.Owner = new ContentReferenceStub(Api.Data.Users.First().Id, "", new()); + + var result = await sitesClient.CloudSubscriptions.CreateSubscriptionAsync(subscription, Cancel); + + result.AssertFailure(); + } + + [Fact] + public async Task CreateViewSubscriptionAsync() + { + await using var sitesClient = await GetSitesClientAsync(Cancel); + + var wb = Create(); + Api.Data.AddWorkbook(wb, null); + + var view = wb.Views.First(); + + var subscription = Create(); + subscription.Content = new SubscriptionContent(view.Id, "View", false); + subscription.Owner = new ContentReferenceStub(Api.Data.Users.First().Id, "", new()); + + var result = await sitesClient.CloudSubscriptions.CreateSubscriptionAsync(subscription, Cancel); + + result.AssertSuccess(); + + var createdSub = Api.Data.CloudSubscriptions.SingleOrDefault(); + Assert.NotNull(createdSub); + Assert.Equal(createdSub.Id, result.Value!.Id); + } + + [Fact] + public async Task CreateViewSubscriptionInvalidViewAsync() + { + await using var sitesClient = await GetSitesClientAsync(Cancel); + + var subscription = Create(); + subscription.Content = new SubscriptionContent(Guid.NewGuid(), "View", false); + subscription.Owner = new ContentReferenceStub(Api.Data.Users.First().Id, "", new()); + + var result = await sitesClient.CloudSubscriptions.CreateSubscriptionAsync(subscription, Cancel); + + result.AssertFailure(); + } + } + + #endregion + + #region - UpdateSubscriptionAsync - + + public sealed class UpdateSubscriptionAsyncTests : SubscriptionsApiClientTest + { + public UpdateSubscriptionAsyncTests() + : base(true) + { } + + public async Task UpdatesSubscription() + { + await using var sitesClient = await GetSitesClientAsync(Cancel); + + var sub = Create(); + Api.Data.CloudSubscriptions.Add(sub); + + var result = await sitesClient.CloudSubscriptions.UpdateSubscriptionAsync(sub.Id, Cancel, newSuspended: true); + + result.AssertSuccess(); + + Assert.Equal(sub.Id, result.Value!.Id); + } + } + + #endregion + + #endregion + } +} diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/Api/UsersApiClientTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/Api/UsersApiClientTests.cs index 1f029397..f2004b57 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/Api/UsersApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/Api/UsersApiClientTests.cs @@ -19,7 +19,9 @@ using System.Linq; using System.Threading.Tasks; using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; using Xunit; namespace Tableau.Migration.Tests.Simulation.Tests.Api @@ -35,7 +37,8 @@ internal static void AddUserAssert(UsersResponse.UserType user, IResult g.Name != "All Users"), AssertGroupMigrated); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/SubscriptionMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/SubscriptionMigrationTests.cs new file mode 100644 index 00000000..a1b91df7 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/SubscriptionMigrationTests.cs @@ -0,0 +1,105 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Tableau.Migration.Api.Rest.Models.Responses.Server; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Manifest; +using Xunit; + +namespace Tableau.Migration.Tests.Simulation.Tests +{ + public sealed class SubscriptionMigrationTests + { + public sealed class ServerToCloud : ServerToCloudSimulationTestBase + { + [Fact] + public async Task MigratesAllSubscriptionsToCloudAsync() + { + //Arrange - create source content to migrate. + var (NonSupportUsers, SupportUsers) = PrepareSourceUsersData(5); + var sourceProjects = PrepareSourceProjectsData(); + var sourceWorkbooks = PrepareSourceWorkbooksData(); + var sourceSubscriptions = PrepareSourceSubscriptionsData(); + + //Migrate + var plan = ServiceProvider.GetRequiredService() + .FromSource(SourceEndpointConfig) + .ToDestination(CloudDestinationEndpointConfig) + .ForServerToCloud() + .WithTableauIdAuthenticationType() + .WithTableauCloudUsernames("test.com") + .Build(); + + var migrator = ServiceProvider.GetRequiredService(); + var result = await migrator.ExecuteAsync(plan, Cancel); + + //Assert - all subscriptions should be migrated. + + Assert.Empty(result.Manifest.Errors); + Assert.Equal(MigrationCompletionStatus.Completed, result.Status); + + Assert.Equal(CloudDestinationApi.Data.CloudSubscriptions.Count, + result.Manifest.Entries.ForContentType().Where(e => e.Status == MigrationManifestEntryStatus.Migrated).Count()); + + Assert.All(sourceSubscriptions, AssertSubscriptionMigrated); + + void AssertSubscriptionMigrated(GetSubscriptionsResponse.SubscriptionType sourceSubscription) + { + // Get source references + var sourceSchedule = SourceApi.Data.Schedules.Single(s => s.Id == sourceSubscription.Schedule!.Id); + Assert.NotNull(sourceSubscription.Content); + + var sourceUser = SourceApi.Data.Users.Single(u => u.Id == sourceSubscription.User!.Id); + var mappedUserId = result.Manifest.Entries.ForContentType().Single(e => e.Source.Id == sourceUser.Id).Destination?.Id; + + var contentType = sourceSubscription.Content.Type ?? string.Empty; + + var sourceWorkbook = contentType != "workbook" ? null : SourceApi.Data.Workbooks.Single(w => w.Id == sourceSubscription.Content.Id); + var destinationWorkbook = sourceWorkbook is null ? null : CloudDestinationApi.Data.Workbooks.Single(w => w.Name == sourceWorkbook.Name); + + var sourceView = contentType != "view" ? null : SourceApi.Data.Views.Single(w => w.Id == sourceSubscription.Content.Id); + var destinationView = sourceView is null ? null : CloudDestinationApi.Data.Views.Single(w => w.Name == sourceView.Name); + + // Get destination subscription + var destinationSubscription = Assert.Single(CloudDestinationApi.Data.CloudSubscriptions, sub => + sub.Content?.Type == contentType && + ( + (contentType == "workbook" && sub.Content?.Id == destinationWorkbook?.Id) || + (contentType == "view" && sub.Content?.Id == destinationView?.Id) + )); + + Assert.NotEqual(sourceSubscription.Id, destinationSubscription.Id); + + Assert.Equal(sourceSubscription.AttachImage, destinationSubscription.AttachImage); + Assert.Equal(sourceSubscription.AttachPdf, destinationSubscription.AttachPdf); + Assert.Equal(sourceSubscription.Message, destinationSubscription.Message); + Assert.Equal(sourceSubscription.PageOrientation, destinationSubscription.PageOrientation); + Assert.Equal(sourceSubscription.PageSizeOption, destinationSubscription.PageSizeOption); + Assert.Equal(sourceSubscription.Subject, destinationSubscription.Subject); + + Assert.Equal(mappedUserId, destinationSubscription.User?.Id); + + AssertScheduleMigrated(sourceSchedule, destinationSubscription.Schedule); + } + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs index acf80eff..c850a7c4 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs @@ -30,6 +30,7 @@ public class UserMigrationTests { public class Batch : ServerToCloud { + protected override bool UsersBatchImportEnabled => true; } public class Individual : ServerToCloud @@ -66,17 +67,19 @@ public async Task MigratesAllUsersToCloudAsync() Assert.Equal(supportUsers.Count, result.Manifest.Entries.ForContentType().Where(e => e.Status == MigrationManifestEntryStatus.Skipped).Count()); - void AssertUserMigrated(UsersResponse.UserType sourceUser) { - var destinationUser = Assert.Single(CloudDestinationApi.Data.Users, u => - u.Domain?.Name == sourceUser.Domain?.Name && + var destinationUser = Assert.Single(CloudDestinationApi.Data.Users, u => + u.Domain?.Name == sourceUser.Domain?.Name && u.Name == sourceUser.Name); Assert.NotEqual(sourceUser.Id, destinationUser.Id); Assert.Equal(sourceUser.Domain?.Name, destinationUser.Domain?.Name); Assert.Equal(sourceUser.Name, destinationUser.Name); + + // Tableau Cloud does not allow updating user email/full name. Assert.Null(destinationUser.Email); + Assert.Null(destinationUser.FullName); if (sourceUser.SiteRole == SiteRoles.Viewer || sourceUser.SiteRole == SiteRoles.Guest || @@ -92,7 +95,6 @@ void AssertUserMigrated(UsersResponse.UserType sourceUser) { Assert.Equal(sourceUser.SiteRole, destinationUser.SiteRole); } - Assert.Null(destinationUser.FullName); } Assert.All(SourceApi.Data.Users.Where(u => u.SiteRole != SiteRoles.SupportUser), AssertUserMigrated); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs index 61b6674d..aa65604e 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs @@ -86,6 +86,13 @@ void AssertWorkbookMigrated(WorkbookResponse.WorkbookType sourceWorkbook) SourceApi.Data.WorkbookPermissions[sourceWorkbook.Id], CloudDestinationApi.Data.WorkbookPermissions[destinationWorkbook.Id]); + // Assert keychains + AssertEmbeddedCredentialsMigrated(result.Manifest, + SourceApi.Data.WorkbookKeychains[sourceWorkbook.Id], + CloudDestinationApi.Data.WorkbookKeychains[destinationWorkbook.Id], + SourceApi.Data.UserSavedCredentials, + CloudDestinationApi.Data.UserSavedCredentials); + // Assert workbook owner Assert.NotNull(destinationWorkbook.Owner); Assert.NotEqual(destinationWorkbook.Owner.Id, Guid.Empty); @@ -100,7 +107,7 @@ void AssertWorkbookMigrated(WorkbookResponse.WorkbookType sourceWorkbook) // Assert connection AssertWorkbookConnectionsMigrated(sourceWorkbook, destinationWorkbook); - void AssertWorkbookViewMigrated(WorkbookResponse.WorkbookType.ViewReferenceType sourceView) + void AssertWorkbookViewMigrated(WorkbookResponse.WorkbookType.WorkbookViewReferenceType sourceView) { // Get destination view var destinationView = Assert.Single(destinationWorkbook!.Views, v => IViewReferenceTypeComparer.Instance.Equals(sourceView, v)); diff --git a/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj b/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj index bfa6a27e..e1c25488 100644 --- a/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj +++ b/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj @@ -1,6 +1,6 @@  - net8.0 + net8.0;net9.0 true CA2007 @@ -18,18 +18,18 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestBase.cs index 0821508f..e019efc7 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestBase.cs @@ -15,7 +15,6 @@ // limitations under the License. // -using System; using Tableau.Migration.Api; using Tableau.Migration.Api.Rest; diff --git a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs index 364558f9..9b956db0 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -21,6 +21,7 @@ using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Publishing; using Tableau.Migration.Api.Tags; @@ -56,9 +57,13 @@ internal class ApiClientTestDependencies : IDisposable, IApiClientTestDependenci public Mock MockWorkbookPublisher { get; } = new(); public Mock MockTagsApiClientFactory { get; } = new(); public Mock MockTagsApiClient { get; } = new(); + + public Mock MockSchedulesApiClient { get; } = new(); + public Mock MockSchedulesApiClientFactory { get; } = new(); + public Mock MockViewsApiClientFactory { get; } = new(); public Mock MockViewsApiClient { get; } = new(); - + public Mock MockCustomViewsApiClient { get; } = new(); public IHttpContentSerializer Serializer { get; } = HttpContentSerializer.Instance; public IRestRequestBuilderFactory RestRequestBuilderFactory { get; } @@ -80,6 +85,9 @@ internal class ApiClientTestDependencies : IDisposable, IApiClientTestDependenci public TableauSiteConnectionConfiguration SiteConnectionConfiguration { get; } + public Mock MockEmbeddedCredentialsApiClient { get; } = new(); + public Mock MockEmbeddedCredentialsApiClientFactory { get; } = new(); + public ApiClientTestDependencies(IFixture autoFixture) { _autoFixture = autoFixture; @@ -128,6 +136,10 @@ public ApiClientTestDependencies(IFixture autoFixture) MockViewsApiClientFactory.Setup(x => x.Create()).Returns(MockViewsApiClient.Object); + MockEmbeddedCredentialsApiClientFactory.Setup(x => x.Create(It.IsAny())).Returns(MockEmbeddedCredentialsApiClient.Object); + + MockSchedulesApiClientFactory.Setup(x => x.Create()).Returns(MockSchedulesApiClient.Object); + Services = new ServiceCollection() .AddTableauMigrationSdk(); @@ -158,6 +170,8 @@ private void ReplaceServices() ReplaceService(Serializer); ReplaceService(RestRequestBuilderFactory); ReplaceService(HttpStreamProcessor); + ReplaceService(MockEmbeddedCredentialsApiClientFactory); + ReplaceService(MockSchedulesApiClientFactory); } public void ReplaceService(T service) @@ -166,7 +180,7 @@ public void ReplaceService(T service) #region - API Client Creation Factory Methods - public TApiClient CreateClient() - where TApiClient : IContentApiClient + where TApiClient: notnull => this.GetRequiredService(); #endregion diff --git a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTests.cs index 0f6a64c6..be88cdaf 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTests.cs @@ -21,6 +21,7 @@ using Moq; using Tableau.Migration.Api; using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Net; @@ -242,7 +243,7 @@ public async Task Returns_cloud_instance_type() { var mockSitesResponse = new MockHttpResponseMessage(); var tsResponse = new EmptyTableauServerResponse( - new() { Code = ApiClient.SITES_QUERY_NOT_SUPPORTED, Summary = It.IsAny(), Detail = It.IsAny() }); + new() { Code = RestErrorCodes.SITES_QUERY_NOT_SUPPORTED, Summary = It.IsAny(), Detail = It.IsAny() }); var content = new DefaultHttpResponseMessage( new HttpResponseMessage(HttpStatusCode.Forbidden) diff --git a/tests/Tableau.Migration.Tests/Unit/Api/ApiTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Api/ApiTestBase.cs index 3b87b1cf..a0be02fa 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ApiTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ApiTestBase.cs @@ -21,6 +21,7 @@ using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Publishing; using Tableau.Migration.Api.Rest.Models; @@ -64,19 +65,24 @@ public abstract class ApiTestBase : AutoFixtureTestBase, IApiClientTestDependenc public Mock MockWorkbookPublisher => Dependencies.MockWorkbookPublisher; public Mock MockTagsApiClient => Dependencies.MockTagsApiClient; public Mock MockViewsApiClient => Dependencies.MockViewsApiClient; + public Mock MockEmbeddedCredentialsApiClient => Dependencies.MockEmbeddedCredentialsApiClient; public TestHttpStreamProcessor HttpStreamProcessor => Dependencies.HttpStreamProcessor; public IHttpContentSerializer Serializer => Dependencies.Serializer; public IRestRequestBuilderFactory RestRequestBuilderFactory => Dependencies.RestRequestBuilderFactory; public TableauServerVersion TableauServerVersion => Dependencies.TableauServerVersion; public TableauSiteConnectionConfiguration SiteConnectionConfiguration => Dependencies.SiteConnectionConfiguration; + public Mock MockSchedulesApiClient => Dependencies.MockSchedulesApiClient; #endregion + protected TableauInstanceType InstanceType { get; set; } + internal readonly ApiClientTestDependencies Dependencies; public ApiTestBase() { Dependencies = new(AutoFixture); + MockSessionProvider.SetupGet(x => x.InstanceType).Returns(() => InstanceType); } protected void AssertUri(HttpRequestMessage request, string expectedRelativeUri) diff --git a/tests/Tableau.Migration.Tests/Unit/Api/AuthenticationConfigurationsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/AuthenticationConfigurationsApiClientTests.cs new file mode 100644 index 00000000..43c6aeaa --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/AuthenticationConfigurationsApiClientTests.cs @@ -0,0 +1,154 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Tableau.Migration.Api; +using Tableau.Migration.Api.Rest.Models.Responses; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api +{ + public sealed class AuthenticationConfigurationsApiClientTests + { + public abstract class AuthenticationConfigurationsApiClientTest : SiteApiTestBase + { + internal readonly AuthenticationConfigurationsApiClient ApiClient; + + public AuthenticationConfigurationsApiClientTest() + { + InstanceType = TableauInstanceType.Cloud; + ApiClient = (AuthenticationConfigurationsApiClient)Dependencies.CreateClient(); + } + } + + #region - GetAuthenticationConfigurationsAsync - + + public sealed class GetAuthenticationConfigurationsAsync : AuthenticationConfigurationsApiClientTest + { + [Fact] + public async Task ErrorAsync() + { + var exception = new Exception(); + + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError, null); + mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); + + MockHttpClient.SetupResponse(mockResponse); + + var result = await ApiClient.GetAuthenticationConfigurationsAsync(Cancel); + + result.AssertFailure(); + + var resultError = Assert.Single(result.Errors); + Assert.Same(exception, resultError); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/site-auth-configurations"); + } + + [Fact] + public async Task FailureResponseAsync() + { + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.NotFound, null); + MockHttpClient.SetupResponse(mockResponse); + + var result = await ApiClient.GetAuthenticationConfigurationsAsync(Cancel); + + result.AssertFailure(); + + Assert.Null(result.Value); + Assert.Single(result.Errors); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/site-auth-configurations"); + } + + [Fact] + public async Task SuccessAsync() + { + var configResponse = AutoFixture.CreateResponse(); + + var mockResponse = new MockHttpResponseMessage(configResponse); + MockHttpClient.SetupResponse(mockResponse); + + var result = await ApiClient.GetAuthenticationConfigurationsAsync(Cancel); + + result.AssertSuccess(); + Assert.NotNull(result.Value); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/site-auth-configurations"); + } + + [Fact] + public async Task ServerNotSupportedAsync() + { + InstanceType = TableauInstanceType.Server; + + var result = await ApiClient.GetAuthenticationConfigurationsAsync(Cancel); + + result.AssertSuccess(); + Assert.NotNull(result.Value); + Assert.Empty(result.Value); + + MockHttpClient.AssertNoRequests(); + } + } + + #endregion + + #region - GetPager - + + public sealed class GetPager : AuthenticationConfigurationsApiClientTest + { + [Fact] + public async Task GetsPagerAsync() + { + var configResponse = AutoFixture.CreateResponse(); + + var mockResponse = new MockHttpResponseMessage(configResponse); + MockHttpClient.SetupResponse(mockResponse); + + var pager = ApiClient.GetPager(AuthenticationConfigurationsApiClient.MAX_CONFIGURATIONS); + + var result = await pager.GetAllPagesAsync(Cancel); + + result.AssertSuccess(); + Assert.Equal(configResponse.Items.Count(), result.Value!.Count); + } + + [Fact] + public async Task EmptyOnFailureAsync() + { + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.NotFound, null); + MockHttpClient.SetupResponse(mockResponse); + + var pager = ApiClient.GetPager(AuthenticationConfigurationsApiClient.MAX_CONFIGURATIONS); + + var result = await pager.GetAllPagesAsync(Cancel); + + result.AssertFailure(); + Assert.Empty(result.Value!); + } + } + + #endregion + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/CustomViewsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/CustomViewsApiClientTests.cs index 5c5d6f1c..1aaea80a 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/CustomViewsApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/CustomViewsApiClientTests.cs @@ -16,6 +16,7 @@ // using System; +using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; @@ -24,9 +25,12 @@ using Moq; using Tableau.Migration.Api; using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Config; using Tableau.Migration.Content; +using Tableau.Migration.Content.Files; +using Tableau.Migration.Net; using Tableau.Migration.Net.Rest; using Xunit; @@ -37,22 +41,22 @@ public class CustomViewsApiClientTests public abstract class CustomViewsApiClientTest : ApiClientTestBase { internal CustomViewsApiClient CustomViewsApiClient => GetApiClient(); - private readonly string _baseApiUri; + protected readonly string BaseApiUri; protected CustomViewsApiClientTest() { MockConfigReader .Setup(x => x.Get()) .Returns(new ContentTypesOptions()); - _baseApiUri = $"/api/{TableauServerVersion.RestApiVersion}"; + BaseApiUri = $"/api/{TableauServerVersion.RestApiVersion}"; } protected internal void AssertCustomViewRelativeUri( - HttpRequestMessage request, + HttpRequestMessage request, Guid customViewId, string? suffix = null) { - request.AssertRelativeUri($"{_baseApiUri}/{RestUrlPrefixes.Sites}/{SiteId}/{RestUrlPrefixes.CustomViews}/{customViewId.ToUrlSegment()}{suffix ?? string.Empty}"); + request.AssertRelativeUri($"{BaseApiUri}/{RestUrlPrefixes.Sites}/{SiteId}/{RestUrlPrefixes.CustomViews}/{customViewId.ToUrlSegment()}{suffix ?? string.Empty}"); } } @@ -162,7 +166,7 @@ public async Task SuccessAsync() } #endregion - + #region - GetCustomViewDefaultUsersAsync - public class GetCustomViewDefaultUsersAsync : CustomViewsApiClientTest @@ -422,6 +426,11 @@ public async Task FailureResponseAsync() public class DownloadCustomViewAsync : CustomViewsApiClientTest { + private void AssertCustomViewDownloadUri(Guid CustomViewId, HttpRequestMessage request) + { + request.AssertRelativeUri($"{BaseApiUri}/sites/{SiteId}/customviews/{CustomViewId}/content"); + } + [Fact] public async Task ErrorAsync() { @@ -441,7 +450,7 @@ public async Task ErrorAsync() Assert.Same(exception, resultError); var request = MockHttpClient.AssertSingleRequest(); - request.AssertRelativeUri($"/api/exp/sites/{SiteId}/customviews/{CustomViewId}/content"); + AssertCustomViewDownloadUri(CustomViewId, request); } [Fact] @@ -461,9 +470,10 @@ public async Task FailureResponseAsync() Assert.Single(result.Errors); var request = MockHttpClient.AssertSingleRequest(); - request.AssertRelativeUri($"/api/exp/sites/{SiteId}/customviews/{CustomViewId}/content"); + AssertCustomViewDownloadUri(CustomViewId, request); } + [Fact] public async Task SuccessAsync() { @@ -480,7 +490,7 @@ public async Task SuccessAsync() Assert.NotNull(result.Value); var request = MockHttpClient.AssertSingleRequest(); - request.AssertRelativeUri($"/api/exp/sites/{SiteId}/customviews/{CustomViewId}/content"); + AssertCustomViewDownloadUri(CustomViewId, request); } } @@ -755,5 +765,112 @@ public async Task SuccessAsync() } #endregion + + #region - PublishCustomViewAsync - + public class PublishCustomViewAsync : CustomViewsApiClientTest + { + public PublishCustomViewAsync() + { + MockConfigReader + .Setup(x => x.Get()) + .Returns(new ContentTypesOptions()); + } + + private void SetupFileUploadErrorResponse(string errorCode) + => SetupErrorResponse(new Error() { Code = errorCode }); + + private (IHttpResponseMessage Response, CustomViewsResponse Content) SetupCustomViewListResponse() + => SetupSuccessResponse(); + + private PublishableCustomView CreatePublishableCustomView(CustomViewsResponse.CustomViewResponseType.WorkbookType workBook, CustomViewsResponse.CustomViewResponseType lastCustomView, IContentReference owner) + { + var cvToPublish = new CustomView( + lastCustomView, + new ContentReferenceStub(workBook.Id, Create(), + Create()), + owner); + + var publishableCv = new PublishableCustomView( + cvToPublish, + Create>(), + Create()); + + return publishableCv; + } + + private IContentReference CreateNewContentReferenceStub() + => new ContentReferenceStub(Create(), Create(), Create()); + + [Fact] + public async Task SuccessAsync() + { + SetupSuccessResponse(); + + // Setup for commit file upload request + SetupSuccessResponse(); + + var customView = Create(); + + var result = await ApiClient.PublishAsync(customView, Cancel); + + result.AssertSuccess(); + Assert.NotNull(result.Value); + } + + [Fact] + public async Task Publish_fails() + { + SetupErrorResponse(); + + var customView = Create(); + + var result = await ApiClient.PublishAsync(customView, Cancel); + + result.AssertFailure(); + Assert.Null(result.Value); + } + + [Fact] + public async Task Publish_succeeds_on_existing_custom_view() + { + SetupFileUploadErrorResponse(RestErrorCodes.CUSTOM_VIEW_ALREADY_EXISTS); + SetupSuccessResponse(); + + var workBook = Create(); + + var customViewListSetup = SetupCustomViewListResponse(); + var customViews = customViewListSetup.Content.Items.ToList(); + var lastCustomView = customViews.Last(); + var owner = CreateNewContentReferenceStub(); + + var publishableCv = CreatePublishableCustomView(workBook, lastCustomView, owner); + + var result = await ApiClient.PublishAsync(publishableCv, Cancel); + + MockHttpClient.AssertRequestCount(4); + } + + [Fact] + public async Task Publish_fails_for_reasons_other_than_existing_custom_view() + { + SetupFileUploadErrorResponse(CreateString()); + + var workBook = Create(); + var customViewListSetup = SetupCustomViewListResponse(); + + var customViews = customViewListSetup.Content.Items.ToList(); + var lastCustomView = customViews.Last(); + var owner = CreateNewContentReferenceStub(); + + var publishableCv = CreatePublishableCustomView(workBook, lastCustomView, owner); + + var result = await ApiClient.PublishAsync(publishableCv, Cancel); + + result.AssertFailure(); + Assert.Null(result.Value); + } + } + + #endregion } } \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientFactoryTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientFactoryTests.cs new file mode 100644 index 00000000..6086a950 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientFactoryTests.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api; +using Tableau.Migration.Api.EmbeddedCredentials; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.EmbeddedCredentials +{ + public class EmbeddedCredentialsApiClientFactoryTests + { + public class Create : AutoFixtureTestBase + { + [Fact] + public void CreatesEmbeddedCredentialsApiClient() + { + var factory = Create(); + + var apiClient = Create(); + + var embeddedCredentialsApiClient = factory.Create(apiClient); + + Assert.IsType(embeddedCredentialsApiClient); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientTests.cs new file mode 100644 index 00000000..97a6ce76 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/EmbeddedCredentials/EmbeddedCredentialsApiClientTests.cs @@ -0,0 +1,184 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Net; +using System.Threading.Tasks; +using AutoFixture; +using Tableau.Migration.Api; +using Tableau.Migration.Api.EmbeddedCredentials; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Responses; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.EmbeddedCredentials +{ + public sealed class EmbeddedCredentialsApiClientTests + { + public abstract class EmbeddedCredentialsApiClientTest : ApiTestBase + { + protected IEmbeddedCredentialsApiClient ApiClient { get; } + + protected Guid SiteId { get; } = Guid.NewGuid(); + + public EmbeddedCredentialsApiClientTest() + { + MockSessionProvider.SetupGet(p => p.SiteId).Returns(() => SiteId); + + var wbApiClient = Dependencies.CreateClient(); + ApiClient = new EmbeddedCredentialsApiClient( + Dependencies.RestRequestBuilderFactory, + Dependencies.MockLoggerFactory.Object, + Dependencies.MockSharedResourcesLocalizer.Object, + new(wbApiClient.UrlPrefix), + Dependencies.Serializer); + } + } + #region - RetrieveKeychainAsync - + + public sealed class RetrieveKeychainAsync : EmbeddedCredentialsApiClientTest + { + [Fact] + public async Task ErrorAsync() + { + var exception = new Exception(); + + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError); + mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); + MockHttpClient.SetupResponse(mockResponse); + + var id = Guid.NewGuid(); + var options = Create(); + + var result = await ApiClient.RetrieveKeychainAsync(id, options, Cancel); + + result.AssertFailure(); + + var resultError = Assert.Single(result.Errors); + Assert.Same(exception, resultError); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri( + $"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/workbooks/{id}/retrievekeychain"); + } + + [Fact] + public async Task SuccessAsync() + { + var retrieveKeychainResponse = AutoFixture.CreateResponse(); + var mockResponse = new MockHttpResponseMessage( + retrieveKeychainResponse); + MockHttpClient.SetupResponse(mockResponse); + + var id = Guid.NewGuid(); + var options = Create(); + + var result = await ApiClient.RetrieveKeychainAsync(id, options, Cancel); + + result.AssertSuccess(); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri( + $"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/workbooks/{id}/retrievekeychain"); + + var embeddedCredKeychain = result.Value; + Assert.NotNull(embeddedCredKeychain); + Assert.NotEmpty(embeddedCredKeychain.EncryptedKeychains); + Assert.NotEmpty(embeddedCredKeychain.AssociatedUserIds); + } + + [Fact] + public async Task Success_with_empty_users_Async() + { + var retrieveKeychainResponse = AutoFixture + .Build() + .Without(r => r.Error) + .With(r => r.AssociatedUserLuidList, () => []) + .Create(); + + var mockResponse = new MockHttpResponseMessage( + retrieveKeychainResponse); + MockHttpClient.SetupResponse(mockResponse); + + var id = Guid.NewGuid(); + var options = Create(); + + var result = await ApiClient.RetrieveKeychainAsync(id, options, Cancel); + + result.AssertSuccess(); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri( + $"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/workbooks/{id}/retrievekeychain"); + + var embeddedCredKeychain = result.Value; + Assert.NotNull(embeddedCredKeychain); + Assert.NotEmpty(embeddedCredKeychain.EncryptedKeychains); + Assert.Empty(embeddedCredKeychain.AssociatedUserIds); + } + } + + #endregion + + #region - ApplyKeychainAsync - + + public sealed class ApplyKeychainAsync : EmbeddedCredentialsApiClientTest + { + [Fact] + public async Task ErrorAsync() + { + var exception = new Exception(); + + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError); + mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); + MockHttpClient.SetupResponse(mockResponse); + + var id = Guid.NewGuid(); + var options = Create(); + + var result = await ApiClient.ApplyKeychainAsync(id, options, Cancel); + + result.AssertFailure(); + + var resultError = Assert.Single(result.Errors); + Assert.Same(exception, resultError); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/workbooks/{id}/applykeychain"); + } + + [Fact] + public async Task SuccessAsync() + { + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.OK); + MockHttpClient.SetupResponse(mockResponse); + + var id = Guid.NewGuid(); + var options = Create(); + + var result = await ApiClient.ApplyKeychainAsync(id, options, Cancel); + + result.AssertSuccess(); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/workbooks/{id}/applykeychain"); + } + } + + #endregion + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/GroupsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/GroupsApiClientTests.cs index a3ec95d3..ea367c93 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/GroupsApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/GroupsApiClientTests.cs @@ -25,6 +25,7 @@ using System.Threading.Tasks; using Moq; using Tableau.Migration.Api; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; @@ -335,7 +336,7 @@ public async Task Succeeds_when_group_exists() { Error = new Error { - Code = GroupsApiClient.GROUP_NAME_CONFLICT_ERROR_CODE + Code = RestErrorCodes.GROUP_NAME_CONFLICT_ERROR_CODE } }; @@ -380,7 +381,7 @@ public async Task Removes_extra_users() { Error = new Error { - Code = GroupsApiClient.GROUP_NAME_CONFLICT_ERROR_CODE + Code = RestErrorCodes.GROUP_NAME_CONFLICT_ERROR_CODE } }; diff --git a/tests/Tableau.Migration.Tests/Unit/Api/IContentFileStoreExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/IContentFileStoreExtensionsTests.cs index 70f0340a..c6632009 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/IContentFileStoreExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/IContentFileStoreExtensionsTests.cs @@ -36,11 +36,12 @@ public async Task CreatesFromFileDownloadAsync() var fs = new MemoryContentFileStore(); var fileText = "text"; - await using (var fileDownload = new FileDownload("fileName", new MemoryStream(Constants.DefaultEncoding.GetBytes(fileText)))) + await using (var fileDownload = new FileDownload("fileName", new MemoryStream(Constants.DefaultEncoding.GetBytes(fileText)), true)) { var file = await fs.CreateAsync(new object(), fileDownload, cancel); Assert.Equal(fileDownload.Filename, file.OriginalFileName); + Assert.Equal(fileDownload.IsZipFile, file.IsZipFile); await using (var readStream = await file.OpenReadAsync(cancel)) using (var reader = new StreamReader(readStream.Content, Constants.DefaultEncoding)) diff --git a/tests/Tableau.Migration.Tests/Unit/Api/IContentReferenceFinderFactoryExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/IContentReferenceFinderFactoryExtensionsTests.cs index ce44ea9d..ed2a9b76 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/IContentReferenceFinderFactoryExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/IContentReferenceFinderFactoryExtensionsTests.cs @@ -31,11 +31,12 @@ namespace Tableau.Migration.Tests.Unit.Api { public class IContentReferenceFinderFactoryExtensionsTests { - private class WithProjectType : IWithProjectType, INamedContent, IRestIdentifiable + private class WithProjectNamedReferenceType : IWithProjectNamedReferenceType, INamedContent, IRestIdentifiable { public virtual Guid Id { get; set; } public virtual string? Name { get; set; } - public virtual IProjectReferenceType? Project { get; set; } + public virtual IProjectNamedReferenceType? Project { get; set; } + IProjectReferenceType? IWithProjectReferenceType.Project => Project; } private class WithUserType : INamedContent, IRestIdentifiable @@ -51,11 +52,11 @@ private class WithOwnerType : IWithOwnerType, INamedContent, IRestIdentifiable public virtual IRestIdentifiable? Owner { get; set; } } - private class WithWorkbookReferenceType : IWithWorkbookReferenceType, INamedContent, IRestIdentifiable + private class WithWorkbookNamedReferenceType : IWithWorkbookReferenceType, INamedContent, IRestIdentifiable { public virtual Guid Id { get; set; } public virtual string? Name { get; set; } - public virtual IRestIdentifiable? Workbook { get; set; } + public virtual IWorkbookReferenceType? Workbook { get; set; } } public abstract class IContentReferenceFinderFactoryExtensionsTest : AutoFixtureTestBase @@ -83,7 +84,7 @@ public class FindProjectAsync : IContentReferenceFinderFactoryExtensionsTest public async Task Throws_when_response_is_null() { await Assert.ThrowsAsync(() => - MockFinderFactory.Object.FindProjectAsync( + MockFinderFactory.Object.FindProjectAsync( null, MockLogger.Object, SharedResourcesLocalizer, @@ -96,7 +97,7 @@ public async Task Throws_when_response_project_is_null() { await Assert.ThrowsAsync(() => MockFinderFactory.Object.FindProjectAsync( - new WithProjectType(), + new WithProjectNamedReferenceType(), MockLogger.Object, SharedResourcesLocalizer, true, @@ -106,10 +107,10 @@ await Assert.ThrowsAsync(() => [Fact] public async Task Throws_when_response_project_id_is_default() { - var mockProjectReference = new Mock(); + var mockProjectReference = new Mock(); mockProjectReference.SetupGet(p => p.Id).Returns(Guid.Empty); - var response = new WithProjectType { Project = mockProjectReference.Object }; + var response = new WithProjectNamedReferenceType { Project = mockProjectReference.Object }; await Assert.ThrowsAsync(() => MockFinderFactory.Object.FindProjectAsync( @@ -126,12 +127,12 @@ public async Task Returns_project_when_found() var mockContentReference = new Mock(); mockContentReference.SetupGet(p => p.Id).Returns(Guid.NewGuid()); - var mockProjectReference = new Mock(); + var mockProjectReference = new Mock(); mockProjectReference.SetupGet(p => p.Id).Returns(mockContentReference.Object.Id); MockProjectFinder.Setup(f => f.FindByIdAsync(mockContentReference.Object.Id, Cancel)).ReturnsAsync(mockContentReference.Object); - var response = new WithProjectType { Project = mockProjectReference.Object }; + var response = new WithProjectNamedReferenceType { Project = mockProjectReference.Object }; var result = await MockFinderFactory.Object.FindProjectAsync( response, @@ -146,7 +147,7 @@ public async Task Returns_project_when_found() [Fact] public async Task Returns_null_when_not_found_and_throw_is_false() { - var response = new WithProjectType { Project = Create>().Object }; + var response = new WithProjectNamedReferenceType { Project = Create>().Object }; var result = await MockFinderFactory.Object.FindProjectAsync( response, @@ -161,7 +162,7 @@ public async Task Returns_null_when_not_found_and_throw_is_false() [Fact] public async Task Throws_when_not_found_and_throw_is_true() { - var response = new WithProjectType { Project = Create>().Object }; + var response = new WithProjectNamedReferenceType { Project = Create>().Object }; await Assert.ThrowsAsync(() => MockFinderFactory.Object.FindProjectAsync( response, @@ -348,7 +349,7 @@ public class FindWorkbookAsync : IContentReferenceFinderFactoryExtensionsTest public async Task Throws_when_response_is_null() { await Assert.ThrowsAsync(() => - MockFinderFactory.Object.FindWorkbookAsync( + MockFinderFactory.Object.FindWorkbookAsync( null, MockLogger.Object, SharedResourcesLocalizer, @@ -361,7 +362,7 @@ public async Task Throws_when_response_workbook_is_null() { await Assert.ThrowsAsync(() => MockFinderFactory.Object.FindWorkbookAsync( - new WithWorkbookReferenceType(), + new WithWorkbookNamedReferenceType(), MockLogger.Object, SharedResourcesLocalizer, true, @@ -371,10 +372,10 @@ await Assert.ThrowsAsync(() => [Fact] public async Task Throws_when_response_workbook_id_is_default() { - var mockWorkbookReference = new Mock(); + var mockWorkbookReference = new Mock(); mockWorkbookReference.SetupGet(o => o.Id).Returns(Guid.Empty); - var response = new WithWorkbookReferenceType { Workbook = mockWorkbookReference.Object }; + var response = new WithWorkbookNamedReferenceType { Workbook = mockWorkbookReference.Object }; await Assert.ThrowsAsync(() => MockFinderFactory.Object.FindWorkbookAsync( @@ -391,12 +392,12 @@ public async Task Returns_workbook_when_found() var mockContentReference = new Mock(); mockContentReference.SetupGet(o => o.Id).Returns(Guid.NewGuid()); - var mockWorkbookReference = new Mock(); + var mockWorkbookReference = new Mock(); mockWorkbookReference.SetupGet(o => o.Id).Returns(mockContentReference.Object.Id); - MockWorkbookFinder.Setup(f => f.FindByIdAsync(mockContentReference.Object.Id, Cancel)).ReturnsAsync(mockContentReference.Object); + MockWorkbookFinder.Setup(f => f.FindByIdAsync(mockWorkbookReference.Object.Id, Cancel)).ReturnsAsync(mockContentReference.Object); - var response = new WithWorkbookReferenceType { Workbook = mockWorkbookReference.Object }; + var response = new WithWorkbookNamedReferenceType { Workbook = mockWorkbookReference.Object }; var result = await MockFinderFactory.Object.FindWorkbookAsync( response, @@ -411,7 +412,7 @@ public async Task Returns_workbook_when_found() [Fact] public async Task Returns_null_when_not_found_and_throw_is_false() { - var response = new WithWorkbookReferenceType { Workbook = Create>().Object }; + var response = new WithWorkbookNamedReferenceType { Workbook = Create>().Object }; var result = await MockFinderFactory.Object.FindWorkbookAsync( response, @@ -426,7 +427,7 @@ public async Task Returns_null_when_not_found_and_throw_is_false() [Fact] public async Task Throws_when_not_found_and_throw_is_true() { - var response = new WithWorkbookReferenceType { Workbook = Create>().Object }; + var response = new WithWorkbookNamedReferenceType { Workbook = Create>().Object }; await Assert.ThrowsAsync(() => MockFinderFactory.Object.FindWorkbookAsync( response, diff --git a/tests/Tableau.Migration.Tests/Unit/Api/IHttpResponseMessageExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/IHttpResponseMessageExtensionsTests.cs index 9380a19e..95199b58 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/IHttpResponseMessageExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/IHttpResponseMessageExtensionsTests.cs @@ -435,6 +435,55 @@ public async Task CreatesFileDownloadAsync() var resultContent = await new StreamReader(result.Value.Content).ReadToEndAsync(); Assert.Equal("test content", resultContent); } + + [Fact] + public async Task DetectsUtf8FilenameAsync() + { + var content = new ByteArrayContent(Constants.DefaultEncoding.GetBytes("test content")); + MockResponse.Setup(x => x.Content).Returns(content); + + content.Headers.TryAddWithoutValidation(RestHeaders.ContentDisposition, @"FileName*=UTF-8''""test"""); + + var result = await Task.FromResult(MockResponse.Object) + .DownloadResultAsync(Cancel); + + result.AssertSuccess(); + + Assert.Equal("test", result.Value!.Filename); + } + + [Fact] + public async Task DetectsXmlFormatFromContentTypeAsync() + { + var content = new StringContent("test content", MediaTypes.Xml); + MockResponse.Setup(x => x.Content).Returns(content); + + content.Headers.TryAddWithoutValidation(RestHeaders.ContentDisposition, @"FileName*=UTF-8''""test"""); + + var result = await Task.FromResult(MockResponse.Object) + .DownloadResultAsync(Cancel); + + result.AssertSuccess(); + + Assert.Equal(false, result.Value!.IsZipFile); + } + + [Fact] + public async Task DetectsZipFormatFromContentTypeAsync() + { + var content = new ByteArrayContent(Constants.DefaultEncoding.GetBytes("test content")); + content.Headers.ContentType = MediaTypes.OctetStream; + MockResponse.Setup(x => x.Content).Returns(content); + + content.Headers.TryAddWithoutValidation(RestHeaders.ContentDisposition, @"FileName*=UTF-8''""test"""); + + var result = await Task.FromResult(MockResponse.Object) + .DownloadResultAsync(Cancel); + + result.AssertSuccess(); + + Assert.Equal(true, result.Value!.IsZipFile); + } } #endregion diff --git a/tests/Tableau.Migration.Tests/Unit/Api/IServiceCollectionExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/IServiceCollectionExtensionsTests.cs index 5dc2044f..3640fcc4 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/IServiceCollectionExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/IServiceCollectionExtensionsTests.cs @@ -19,6 +19,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Tableau.Migration.Api; +using Tableau.Migration.Api.EmbeddedCredentials; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Search; using Tableau.Migration.Api.Tags; @@ -63,6 +64,7 @@ public async Task Registers_expected_services() await AssertServiceAsync(ServiceLifetime.Singleton); await AssertServiceAsync(ServiceLifetime.Scoped); await AssertServiceAsync(ServiceLifetime.Scoped); + await AssertServiceAsync(ServiceLifetime.Scoped); } [Fact] @@ -105,6 +107,7 @@ public async Task RegistersScopedApiClients() await using var scope = InitializeApiScope(); AssertService(scope, ServiceLifetime.Scoped); + AssertService(scope, ServiceLifetime.Scoped); AssertService(scope, ServiceLifetime.Scoped); AssertService(scope, ServiceLifetime.Scoped); AssertService(scope, ServiceLifetime.Scoped); diff --git a/tests/Tableau.Migration.Tests/Unit/Api/JobsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/JobsApiClientTests.cs index 9e4c99cf..6e9023bb 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/JobsApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/JobsApiClientTests.cs @@ -128,7 +128,8 @@ public async Task Returns_success() foreach (var request in requests) request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/jobs/{jobId.ToUrlSegment()}"); - MockTaskDelayer.Verify(d => d.DelayAsync(TimeSpan.FromMilliseconds(50), Cancel), Times.Exactly(2)); + var timeSpan = TimeSpan.FromMilliseconds(50); + MockTaskDelayer.Verify(d => d.DelayAsync(timeSpan, Cancel), Times.Exactly(2)); } [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Models/ApplyKeychainOptionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Models/ApplyKeychainOptionsTests.cs new file mode 100644 index 00000000..61477c94 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Models/ApplyKeychainOptionsTests.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Models; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Models +{ + public sealed class ApplyKeychainOptionsTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var encryptedKeychains = CreateMany(); + var keychainUserMapping = CreateMany(); + + var o = new ApplyKeychainOptions(encryptedKeychains, keychainUserMapping); + + Assert.Same(encryptedKeychains, o.EncryptedKeychains); + Assert.Same(keychainUserMapping, o.KeychainUserMapping); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Models/Cloud/CreateSubscriptionOptionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Models/Cloud/CreateSubscriptionOptionsTests.cs new file mode 100644 index 00000000..3007bbe4 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Models/Cloud/CreateSubscriptionOptionsTests.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Models.Cloud; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Models.Cloud +{ + public sealed class CreateSubscriptionOptionsTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var sub = Create(); + + var o = new CreateSubscriptionOptions(sub); + + Assert.Equal(o.Subject, sub.Subject); + Assert.Equal(o.AttachImage, sub.AttachImage); + Assert.Equal(o.AttachPdf, sub.AttachPdf); + Assert.Equal(o.PageOrientation, sub.PageOrientation); + Assert.Equal(o.PageSizeOption, sub.PageSizeOption); + Assert.Equal(o.Message, sub.Message); + + Assert.Same(o.Content, sub.Content); + Assert.Equal(o.UserId, sub.Owner.Id); + Assert.Same(o.Schedule, sub.Schedule); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Models/DestinationSiteInfoTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Models/DestinationSiteInfoTests.cs new file mode 100644 index 00000000..bbc454de --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Models/DestinationSiteInfoTests.cs @@ -0,0 +1,43 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Models; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Models +{ + public sealed class DestinationSiteInfoTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var destinationSiteName = Create(); + var destinationSiteId = Create(); + var destinationTableauUrl = Create(); + + var o = new DestinationSiteInfo(destinationSiteName, destinationSiteId, destinationTableauUrl); + + Assert.Equal(destinationSiteName, o.ContentUrl); + Assert.Equal(destinationSiteId, o.SiteId); + Assert.Equal(destinationTableauUrl, o.SiteUrl); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Models/FileDownloadTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Models/FileDownloadTests.cs index 4a043756..6d71c25d 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Models/FileDownloadTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Models/FileDownloadTests.cs @@ -32,7 +32,7 @@ public async Task DisposesStreamAsync() { var mockStream = new Mock(); - var d = new FileDownload(null, mockStream.Object); + var d = new FileDownload(null, mockStream.Object, null); await using (d) { } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Models/KeychainUserMappingTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Models/KeychainUserMappingTests.cs new file mode 100644 index 00000000..b8fb7ac0 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Models/KeychainUserMappingTests.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Models; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Models +{ + public sealed class KeychainUserMappingTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var sourceId = Create(); + var destinationId = Create(); + + var mapping = new KeychainUserMapping(sourceId, destinationId); + + Assert.Equal(sourceId, mapping.SourceUserId); + Assert.Equal(destinationId, mapping.DestinationUserId); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/PagedListApiClientTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Api/PagedListApiClientTestBase.cs index 6773f2f8..0151d023 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/PagedListApiClientTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/PagedListApiClientTestBase.cs @@ -23,7 +23,7 @@ namespace Tableau.Migration.Tests.Unit.Api { public abstract class PagedListApiClientTestBase : ApiClientTestBase - where TApiClient : IPagedListApiClient + where TApiClient : IPagedListApiClient, IContentApiClient where TResponse : TableauServerResponse, new() { [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Api/ProjectsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/ProjectsApiClientTests.cs index d89bac14..e11f55cd 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ProjectsApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ProjectsApiClientTests.cs @@ -16,14 +16,15 @@ // using System; +using System.Linq; using System.Net; using System.Net.Http; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Moq; using Tableau.Migration.Api; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Permissions; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; @@ -165,8 +166,45 @@ private IProject CreateProject(Action>? configure = null) return mockProject.Object; } + private async Task AssertCreateProjectRequestAsync(HttpRequestMessage r, IProject project) + { + r.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/projects"); + + r.AssertHttpMethod(HttpMethod.Post); + + var content = Assert.IsType(r.Content); + + var model = await HttpContentSerializer.Instance.DeserializeAsync(content, Cancel); + + Assert.NotNull(project); + + Assert.NotNull(model); + Assert.NotNull(model.Project); + + Assert.Equal(project.ParentProject?.Id.ToString(), model.Project.ParentProjectId); + Assert.Equal(project.Name, model.Project.Name); + Assert.Equal(project.Description, model.Project.Description); + Assert.Equal(project.ContentPermissions, model.Project.ContentPermissions); + } + + private void AssertGetProjectRequest(HttpRequestMessage r, string name, Guid? parentProjectId) + { + r.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/projects"); + + var filter = $"name:eq:{name}"; + + if (parentProjectId is not null) + filter = $"{filter},parentProjectId:eq:{parentProjectId}"; + else + filter = $"{filter},topLevelProject:eq:true"; + + r.AssertQuery("filter", filter); + + r.AssertHttpMethod(HttpMethod.Get); + } + [Fact] - public async Task Returns_success() + public async Task ReturnsSuccessAsync() { var project = CreateProject(); @@ -188,7 +226,7 @@ public async Task Returns_success() } [Fact] - public async Task Succeeds_when_project_exists() + public async Task SucceedsWhenProjectExistsAsync() { var existingProject = CreateProject(); @@ -196,7 +234,7 @@ public async Task Succeeds_when_project_exists() { Error = new Error { - Code = ProjectsApiClient.PROJECT_NAME_CONFLICT_ERROR_CODE + Code = RestErrorCodes.PROJECT_NAME_CONFLICT_ERROR_CODE } }; @@ -237,65 +275,77 @@ public async Task Succeeds_when_project_exists() } [Fact] - public async Task Returns_failure() + public async Task SucceedsWhenSystemProjectCreationFailsAsync() { - var project = CreateProject(); + var existingProject = CreateProject(m => m.SetupGet(x => x.Name).Returns(Constants.SystemProjectNames.First())); - var exception = new Exception(); + var createProjectResponse = new CreateProjectResponse + { + Error = new Error + { + Code = RestErrorCodes.CREATE_PROJECT_FORBIDDEN + } + }; - var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError, null); - mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); + var mockCreateProjectResponse = new MockHttpResponseMessage(HttpStatusCode.Forbidden, createProjectResponse); - MockHttpClient.SetupResponse(mockResponse); + MockHttpClient.SetupResponse(mockCreateProjectResponse); - var result = await ProjectsApiClient.PublishAsync(project, Cancel); + var owner = Create(); + MockUserFinder.Setup(x => x.FindByIdAsync(owner.Id, Cancel)) + .ReturnsAsync(owner); - Assert.False(result.Success); + var getProjectResponse = AutoFixture.CreateResponse(); + getProjectResponse.Items = new[] + { + new ProjectsResponse.ProjectType + { + Id = existingProject.Id, + ParentProjectId = existingProject.ParentProject?.Id.ToString(), + Name = existingProject.Name, + Description = existingProject.Description, + ContentPermissions = existingProject.ContentPermissions, + Owner = new() { Id = owner.Id } + } + }; - var error = Assert.Single(result.Errors); + var mockGetProjectResponse = new MockHttpResponseMessage(getProjectResponse); - Assert.Same(exception, error); + MockHttpClient.SetupResponse(mockGetProjectResponse); - var request = MockHttpClient.AssertSingleRequest(); + var result = await ProjectsApiClient.PublishAsync(existingProject, Cancel); - request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/projects"); - } + Assert.True(result.Success); - private async Task AssertCreateProjectRequestAsync(HttpRequestMessage r, IProject project) - { - r.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/projects"); + await AssertCreateProjectRequestAsync(MockHttpClient.SentRequests[0], existingProject); + AssertGetProjectRequest(MockHttpClient.SentRequests[1], existingProject.Name, existingProject.ParentProject?.Id); - r.AssertHttpMethod(HttpMethod.Post); + MockUserFinder.Verify(x => x.FindByIdAsync(getProjectResponse.Items[0].Owner!.Id, Cancel), Times.Once); + } - var content = Assert.IsType(r.Content); + [Fact] + public async Task ReturnsFailureAsync() + { + var project = CreateProject(); - var model = await HttpContentSerializer.Instance.DeserializeAsync(content, Cancel); + var exception = new Exception(); - Assert.NotNull(project); + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError, null); + mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); - Assert.NotNull(model); - Assert.NotNull(model.Project); + MockHttpClient.SetupResponse(mockResponse); - Assert.Equal(project.ParentProject?.Id.ToString(), model.Project.ParentProjectId); - Assert.Equal(project.Name, model.Project.Name); - Assert.Equal(project.Description, model.Project.Description); - Assert.Equal(project.ContentPermissions, model.Project.ContentPermissions); - } + var result = await ProjectsApiClient.PublishAsync(project, Cancel); - private void AssertGetProjectRequest(HttpRequestMessage r, string name, Guid? parentProjectId) - { - r.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/projects"); + Assert.False(result.Success); - var filter = $"name:eq:{name}"; + var error = Assert.Single(result.Errors); - if (parentProjectId is not null) - filter = $"{filter},parentProjectId:eq:{parentProjectId}"; - else - filter = $"{filter},topLevelProject:eq:true"; + Assert.Same(exception, error); - r.AssertQuery("filter", filter); + var request = MockHttpClient.AssertSingleRequest(); - r.AssertHttpMethod(HttpMethod.Get); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/projects"); } } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/AddUserResultTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/AddUserResultTests.cs new file mode 100644 index 00000000..12815746 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/AddUserResultTests.cs @@ -0,0 +1,118 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models +{ + public sealed class AddUserResultTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void UserRequired() + { + var response = Create(); + response.Item = null; + + Assert.Throws(() => new AddUserResult(response)); + } + + [Fact] + public void IdRequired() + { + var response = Create(); + response.Item!.Id = Guid.Empty; + + Assert.Throws(() => new AddUserResult(response)); + } + + [Fact] + public void NameRequired() + { + var response = Create(); + response.Item!.Name = string.Empty; + + Assert.Throws(() => new AddUserResult(response)); + } + + [Fact] + public void SiteRoleRequired() + { + var response = Create(); + response.Item!.SiteRole = string.Empty; + + Assert.Throws(() => new AddUserResult(response)); + } + + [Fact] + public void ExposeFullAuthInfo() + { + var id = Guid.NewGuid(); + + var response = Create(); + response.Item!.IdpConfigurationId = id.ToString(); + + var result = new AddUserResult(response); + + Assert.Equal(response.Item.AuthSetting, result.Authentication.AuthenticationType); + Assert.Equal(id, result.Authentication.IdpConfigurationId); + } + + [Fact] + public void PreferIdpConfigurationId() + { + var id = Guid.NewGuid(); + + var response = Create(); + response.Item!.AuthSetting = null; + response.Item.IdpConfigurationId = id.ToString(); + + var result = new AddUserResult(response); + + Assert.Equal(UserAuthenticationType.ForConfigurationId(id), result.Authentication); + } + + [Fact] + public void AuthSetting() + { + var response = Create(); + response.Item!.IdpConfigurationId = null; + + var result = new AddUserResult(response); + + Assert.Equal(UserAuthenticationType.ForAuthenticationType(response.Item.AuthSetting!), result.Authentication); + } + + [Fact] + public void NoAuth() + { + var response = Create(); + response.Item!.AuthSetting = null; + response.Item.IdpConfigurationId = null; + + var result = new AddUserResult(response); + + Assert.Equal(UserAuthenticationType.Default, result.Authentication); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/IUserTypeExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/IUserTypeExtensionsTests.cs new file mode 100644 index 00000000..94f20ea7 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/IUserTypeExtensionsTests.cs @@ -0,0 +1,82 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Moq; +using Tableau.Migration.Api.Rest.Models; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models +{ + public sealed class IUserTypeExtensionsTests + { + public sealed class GetIdpConfigurationId : AutoFixtureTestBase + { + [Fact] + public void Parses() + { + var id = Guid.NewGuid(); + var mockUser = Create>(); + mockUser.SetupGet(x => x.IdpConfigurationId).Returns(id.ToString()); + + var result = mockUser.Object.GetIdpConfigurationId(); + + Assert.NotNull(result); + Assert.Equal(id, result); + } + + [Fact] + public void Null() + { + var mockUser = Create>(); + mockUser.SetupGet(x => x.IdpConfigurationId).Returns((string?)null); + + var result = mockUser.Object.GetIdpConfigurationId(); + + Assert.Null(result); + } + + [Fact] + public void ParseError() + { + var mockUser = Create>(); + mockUser.SetupGet(x => x.IdpConfigurationId).Returns("test"); + + var result = mockUser.Object.GetIdpConfigurationId(); + + Assert.Null(result); + } + } + + public sealed class GetAuthenticationType : AutoFixtureTestBase + { + [Fact] + public void BuildsFullAuthInfo() + { + var id = Guid.NewGuid(); + + var mockUser = Create>(); + mockUser.SetupGet(x => x.IdpConfigurationId).Returns(id.ToString()); + + var result = mockUser.Object.GetAuthenticationType(); + + Assert.Equal(mockUser.Object.AuthSetting, result.AuthenticationType); + Assert.Equal(id, result.IdpConfigurationId); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/ImportUsersFromCsvRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/ImportUsersFromCsvRequestTests.cs index 9b5335d7..55bb582b 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/ImportUsersFromCsvRequestTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/ImportUsersFromCsvRequestTests.cs @@ -15,9 +15,11 @@ // limitations under the License. // +using System; using System.Linq; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content; using Xunit; namespace Tableau.Migration.Tests.Unit.Api.Rest.Models @@ -43,7 +45,7 @@ public void SerializesDefaultUser() [Fact] public void SerializesDefaultAuthType() { - var request = new ImportUsersFromCsvRequest(AuthenticationTypes.Saml); + var request = new ImportUsersFromCsvRequest(UserAuthenticationType.ForAuthenticationType(AuthenticationTypes.Saml)); Assert.NotEmpty(request.Users); @@ -56,6 +58,23 @@ public void SerializesDefaultAuthType() AssertXmlEqual(expected, serialized); } + [Fact] + public void SerializesDefaultIdpConfigurationId() + { + var id = Guid.NewGuid(); + var request = new ImportUsersFromCsvRequest(UserAuthenticationType.ForConfigurationId(id)); + + Assert.NotEmpty(request.Users); + + var serialized = Serializer.SerializeToXml(request); + + Assert.NotNull(serialized); + + var expected = $@""; + + AssertXmlEqual(expected, serialized); + } + [Fact] public void SerializesMultipleUsers() { @@ -68,7 +87,7 @@ public void SerializesMultipleUsers() Assert.NotNull(serialized); - var expected = $@"{string.Join("", users.Select(u => $@""))}"; + var expected = $@"{string.Join("", users.Select(u => $@""))}"; AssertXmlEqual(expected, serialized); } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/AddUserToSiteRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/AddUserToSiteRequestTests.cs new file mode 100644 index 00000000..e9830539 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/AddUserToSiteRequestTests.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests +{ + public sealed class AddUserToSiteRequestTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void SetsIdpConfigurationName() + { + var auth = UserAuthenticationType.ForConfigurationId(Guid.NewGuid()); + var r = new AddUserToSiteRequest("name", "siteRole", auth); + + Assert.NotNull(r.User); + Assert.Equal("name", r.User.Name); + Assert.Equal("siteRole", r.User.SiteRole); + + Assert.Null(r.User.AuthSetting); + Assert.Equal(auth.IdpConfigurationId.ToString(), r.User.IdpConfigurationId); + } + + [Fact] + public void SetsAuthSetting() + { + var auth = UserAuthenticationType.ForAuthenticationType("authType"); + var r = new AddUserToSiteRequest("name", "siteRole", auth); + + Assert.NotNull(r.User); + Assert.Equal("name", r.User.Name); + Assert.Equal("siteRole", r.User.SiteRole); + + Assert.Equal(auth.AuthenticationType, r.User.AuthSetting); + Assert.Null(r.User.IdpConfigurationId); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/ApplyKeychainRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/ApplyKeychainRequestTests.cs new file mode 100644 index 00000000..f1d2b28b --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/ApplyKeychainRequestTests.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Linq; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Requests; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests +{ + public sealed class ApplyKeychainRequestTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var options = Create(); + + var request = new ApplyKeychainRequest(options); + + Assert.Equal(options.EncryptedKeychains, request.EncryptedKeychains); + + Assert.NotNull(request.AssociatedUserLuidMapping); + Assert.Equal(options.KeychainUserMapping.Count(), request.AssociatedUserLuidMapping.Count()); + foreach (var mapping in options.KeychainUserMapping) + { + Assert.Contains(request.AssociatedUserLuidMapping, i => i.SourceSiteUserLuid == mapping.SourceUserId && i.DestinationSiteUserLuid == mapping.DestinationUserId); + } + } + } + + public sealed class Serialization : SerializationTestBase + { + [Fact] + public void Serializes() + { + var options = Create(); + var request = new ApplyKeychainRequest(options); + + var serialized = Serializer.SerializeToXml(request); + + var expected = $@" + + + {string.Join("", options.EncryptedKeychains.Select(k => $"{k}"))} + + + {string.Join("", options.KeychainUserMapping.Select(m => $@""))} + +"; + AssertXmlEqual(expected, serialized); + + } + + [Fact] + public void SerializesWithNoUserMapping() + { + var options = new ApplyKeychainOptions(CreateMany().ToArray(), []); + var request = new ApplyKeychainRequest(options); + + var serialized = Serializer.SerializeToXml(request); + + var expected = $@" + + + {string.Join("", options.EncryptedKeychains.Select(k => $"{k}"))} + +"; + AssertXmlEqual(expected, serialized); + + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/Cloud/CreateSubscriptionRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/Cloud/CreateSubscriptionRequestTests.cs new file mode 100644 index 00000000..5d44bce9 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/Cloud/CreateSubscriptionRequestTests.cs @@ -0,0 +1,63 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Linq; +using Tableau.Migration.Api.Models.Cloud; +using Tableau.Migration.Api.Rest.Models.Requests.Cloud; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests.Cloud +{ + public sealed class CreateSubscriptionRequestTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var opts = Create(); + + var request = new CreateSubscriptionRequest(opts); + + Assert.NotNull(request.Subscription); + Assert.Equal(opts.Subject, request.Subscription.Subject); + Assert.Equal(opts.AttachImage, request.Subscription.AttachImage); + Assert.Equal(opts.AttachPdf, request.Subscription.AttachPdf); + Assert.Equal(opts.PageSizeOption, request.Subscription.PageSizeOption); + Assert.Equal(opts.PageOrientation, request.Subscription.PageOrientation); + Assert.Equal(opts.Message, request.Subscription.Message); + + Assert.NotNull(request.Subscription.Content); + Assert.Equal(opts.Content.Id, request.Subscription.Content.Id); + Assert.Equal(opts.Content.Type, request.Subscription.Content.Type); + Assert.Equal(opts.Content.SendIfViewEmpty, request.Subscription.Content.SendIfViewEmpty); + + Assert.NotNull(request.Subscription.User); + Assert.Equal(opts.UserId, request.Subscription.User.Id); + + Assert.NotNull(request.Schedule); + Assert.Equal(opts.Schedule.Frequency, request.Schedule.Frequency); + + Assert.NotNull(request.Schedule.FrequencyDetails); + Assert.Equal(opts.Schedule.FrequencyDetails.StartAt?.ToString(Constants.FrequencyTimeFormat), request.Schedule.FrequencyDetails.Start); + Assert.Equal(opts.Schedule.FrequencyDetails.EndAt?.ToString(Constants.FrequencyTimeFormat), request.Schedule.FrequencyDetails.End); + + Assert.Equal(opts.Schedule.FrequencyDetails.Intervals.Count, request.Schedule.FrequencyDetails.Intervals.Count()); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/Cloud/UpdateSubscriptionRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/Cloud/UpdateSubscriptionRequestTests.cs new file mode 100644 index 00000000..2c0d9c43 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/Cloud/UpdateSubscriptionRequestTests.cs @@ -0,0 +1,92 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Rest.Models.Requests.Cloud; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules.Cloud; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests.Cloud +{ + public sealed class UpdateSubscriptionRequestTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var subject = Create(); + var attachImage = Create(); + var attachPdf = Create(); + var pageOrientation = Create(); + var pageSizeOptions = Create(); + var suspended = Create(); + var message = Create(); + var content = Create(); + var userId = Create(); + var schedule = Create(); + + var r = new UpdateSubscriptionRequest(subject, attachImage, attachPdf, pageOrientation, pageSizeOptions, suspended, + message, content, userId, schedule); + + Assert.NotNull(r.Subscription); + Assert.Equal(subject, r.Subscription.Subject); + Assert.Equal(attachImage, r.Subscription.AttachImage); + Assert.Equal(attachPdf, r.Subscription.AttachPdf); + Assert.Equal(pageOrientation, r.Subscription.PageOrientation); + Assert.Equal(pageSizeOptions, r.Subscription.PageSizeOption); + Assert.Equal(suspended, r.Subscription.Suspended); + Assert.Equal(message, r.Subscription.Message); + + Assert.NotNull(r.Subscription.Content); + Assert.Equal(content.Id, r.Subscription.Content.Id); + Assert.Equal(content.Type, r.Subscription.Content.Type); + Assert.Equal(content.SendIfViewEmpty, r.Subscription.Content.SendIfViewEmpty); + + Assert.NotNull(r.Subscription.User); + Assert.Equal(userId, r.Subscription.User.Id); + + Assert.NotNull(r.Schedule); + Assert.Equal(schedule.Frequency, r.Schedule.Frequency); + + Assert.NotNull(r.Schedule.FrequencyDetails); + Assert.Equal(schedule.FrequencyDetails.StartAt?.ToString(Constants.FrequencyTimeFormat), r.Schedule.FrequencyDetails.Start); + Assert.Equal(schedule.FrequencyDetails.EndAt?.ToString(Constants.FrequencyTimeFormat), r.Schedule.FrequencyDetails.End); + Assert.Equal(schedule.FrequencyDetails.Intervals.Count, r.Schedule.FrequencyDetails.Intervals.Length); + } + + [Fact] + public void NullOptional() + { + var r = new UpdateSubscriptionRequest(null); + + Assert.NotNull(r.Subscription); + Assert.Null(r.Subscription.Subject); + Assert.False(r.Subscription.AttachImageSpecified); + Assert.False(r.Subscription.AttachPdfSpecified); + Assert.Null(r.Subscription.PageOrientation); + Assert.Null(r.Subscription.PageSizeOption); + Assert.False(r.Subscription.SuspendedSpecified); + Assert.Null(r.Subscription.Message); + Assert.Null(r.Subscription.Content); + Assert.Null(r.Subscription.User); + Assert.Null(r.Schedule); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/RetrieveKeychainRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/RetrieveKeychainRequestTests.cs new file mode 100644 index 00000000..9ccce1b8 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/RetrieveKeychainRequestTests.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using AutoFixture; +using Tableau.Migration.Api.Rest.Models.Requests; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests +{ + public class RetrieveKeychainRequestTests + { + public class Serialization : SerializationTestBase + { + [Fact] + public void Serializes() + { + var request = AutoFixture.Create(); + + Assert.NotNull(request); + + var serialized = Serializer.SerializeToXml(request); + + Assert.NotNull(serialized); + var expected = $@" + + {request.DestinationSiteUrlNamespace} + {request.DestinationSiteLuid} + {request.DestinationServerUrl} + +"; + + AssertXmlEqual(expected, serialized); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/RetrieveUserSavedCredentialsRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/RetrieveUserSavedCredentialsRequestTests.cs new file mode 100644 index 00000000..aa96d551 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/RetrieveUserSavedCredentialsRequestTests.cs @@ -0,0 +1,63 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Linq; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Requests; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests +{ + public sealed class RetrieveUserSavedCredentialsRequestTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var options = Create(); + + var request = new RetrieveUserSavedCredentialsRequest(options); + + Assert.Equal(options.ContentUrl, request.DestinationSiteUrlNamespace); + Assert.Equal(options.SiteId, request.DestinationSiteLuid); + Assert.Equal(options.SiteUrl, request.DestinationServerUrl); + } + } + + public sealed class Serialization : SerializationTestBase + { + [Fact] + public void Serializes() + { + var options = Create(); + var request = new RetrieveUserSavedCredentialsRequest(options); + + var serialized = Serializer.SerializeToXml(request); + + var expected = $@" + + {options.ContentUrl} + {options.SiteId} + {options.SiteUrl} +"; + AssertXmlEqual(expected, serialized); + + } + } + } +} \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/UpdateUserRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/UpdateUserRequestTests.cs new file mode 100644 index 00000000..a70b5505 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/UpdateUserRequestTests.cs @@ -0,0 +1,94 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests +{ + public sealed class UpdateUserRequestTests + { + public sealed class Ctor + { + [Fact] + public void Optional() + { + var r = new UpdateUserRequest("siteRole"); + + Assert.NotNull(r.User); + Assert.Equal("siteRole", r.User.SiteRole); + + Assert.Null(r.User.FullName); + Assert.Null(r.User.Email); + Assert.Null(r.User.Password); + Assert.Null(r.User.AuthSetting); + Assert.Null(r.User.IdpConfigurationId); + } + + [Fact] + public void FullName() + { + var r = new UpdateUserRequest("siteRole", "full name"); + + Assert.NotNull(r.User); + Assert.Equal("full name", r.User.FullName); + } + + [Fact] + public void Email() + { + var r = new UpdateUserRequest("siteRole", newEmail: "email"); + + Assert.NotNull(r.User); + Assert.Equal("email", r.User.Email); + } + + [Fact] + public void Password() + { + var r = new UpdateUserRequest("siteRole", newPassword: "pass"); + + Assert.NotNull(r.User); + Assert.Equal("pass", r.User.Password); + } + + [Fact] + public void UpdateIdpConfigurationId() + { + var auth = UserAuthenticationType.ForConfigurationId(Guid.NewGuid()); + var r = new UpdateUserRequest("siteRole", newAuthentication: auth); + + Assert.NotNull(r.User); + Assert.Null(r.User.AuthSetting); + Assert.Equal(auth.IdpConfigurationId.ToString(), r.User.IdpConfigurationId); + } + + [Fact] + public void AuthSetting() + { + var auth = UserAuthenticationType.ForAuthenticationType("authType"); + var r = new UpdateUserRequest("siteRole", newAuthentication: auth); + + Assert.NotNull(r.User); + Assert.Equal(auth.AuthenticationType, r.User.AuthSetting); + Assert.Null(r.User.IdpConfigurationId); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/UploadUserSavedCredentialsRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/UploadUserSavedCredentialsRequestTests.cs new file mode 100644 index 00000000..e36b615d --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/UploadUserSavedCredentialsRequestTests.cs @@ -0,0 +1,68 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Requests; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Requests +{ + public sealed class UploadUserSavedCredentialsRequestTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var encryptedKeychains = Create>(); + + var request = new UploadUserSavedCredentialsRequest(encryptedKeychains); + + Assert.Equal(encryptedKeychains, request.EncryptedKeychains); + } + } + + public sealed class Serialization : SerializationTestBase + { + [Fact] + public void Serializes() + { + var encryptedKeychains = Create>(); + + var request = new UploadUserSavedCredentialsRequest(encryptedKeychains); + + Assert.NotNull(request); + + var serialized = Serializer.SerializeToXml(request); + + Assert.NotNull(serialized); + var expected = $@" + + + {string.Join("", (request.EncryptedKeychains ?? Array.Empty()).Select(k => $"{k}"))} + + +"; + + AssertXmlEqual(expected, serialized); + } + } + } +} \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/CreateSubscriptionResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/CreateSubscriptionResponseTests.cs new file mode 100644 index 00000000..261e2059 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/CreateSubscriptionResponseTests.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Responses.Cloud +{ + public sealed class CreateSubscriptionResponseTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void InitializesSubscription() + { + var s = Create(); + var schedule = Create(); + + var r = new CreateSubscriptionResponse.SubscriptionType(s, schedule); + + Assert.Equal(s.Id, r.Id); + Assert.Equal(s.Subject, r.Subject); + Assert.Equal(s.AttachImage, r.AttachImage); + Assert.Equal(s.AttachPdf, r.AttachPdf); + Assert.Equal(s.PageOrientation, r.PageOrientation); + Assert.Equal(s.PageSizeOption, r.PageSizeOption); + Assert.Equal(s.Suspended, r.Suspended); + Assert.Equal(s.Message, r.Message); + + Assert.NotNull(r.Content); + Assert.Equal(s.Content!.Id, r.Content.Id); + Assert.Equal(s.Content.Type, r.Content.Type); + Assert.Equal(s.Content.SendIfViewEmpty, r.Content.SendIfViewEmpty); + + Assert.NotNull(r.User); + Assert.Equal(s.User!.Id, r.User.Id); + } + + [Fact] + public void InitializesSchedule() + { + var s = Create(); + + var r = new CreateSubscriptionResponse.SubscriptionType.ScheduleType(s); + + Assert.Equal(s.Frequency, r.Frequency); + + Assert.NotNull(r.FrequencyDetails); + Assert.Equal(s.FrequencyDetails!.Start, r.FrequencyDetails.Start); + Assert.Equal(s.FrequencyDetails.End, r.FrequencyDetails.End); + Assert.Equal(s.FrequencyDetails.Intervals.Length, r.FrequencyDetails.Intervals.Length); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/GetSubscriptionsResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/GetSubscriptionsResponseTests.cs new file mode 100644 index 00000000..05c129b1 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/GetSubscriptionsResponseTests.cs @@ -0,0 +1,179 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Responses.Cloud +{ + public class GetSubscriptionsResponseTests + { + public class Serialization : SerializationTestBase + { + [Fact] + public void DeSerializes() + { + var expectedResponse = Create(); + + var expectedSubscriptions = expectedResponse.Items.ToList(); + + var xml = BuildInputXml(expectedSubscriptions); + + var deserialized = Serializer.DeserializeFromXml(xml); + Assert.NotNull(deserialized); + var actualSubscriptions = deserialized.Items; + Assert.Equal(expectedSubscriptions.Count, actualSubscriptions.Length); + + foreach (var expectedSubscription in expectedSubscriptions) + { + AssertSubscription(expectedSubscription, actualSubscriptions.FirstOrDefault(x => x.Id == expectedSubscription.Id)); + } + + static void AssertSubscription(GetSubscriptionsResponse.SubscriptionType? expectedSubscription, GetSubscriptionsResponse.SubscriptionType? actualSubscription) + { + Assert.NotNull(expectedSubscription); + Assert.NotNull(actualSubscription); + + Assert.Equal(expectedSubscription.Id, actualSubscription.Id); + Assert.Equal(expectedSubscription.Subject, actualSubscription.Subject); + Assert.Equal(expectedSubscription.AttachImage, actualSubscription.AttachImage); + Assert.Equal(expectedSubscription.AttachPdf, actualSubscription.AttachPdf); + + AssertContent(expectedSubscription.Content, actualSubscription.Content); + + AssertSchedule(expectedSubscription.Schedule, actualSubscription.Schedule); + + AssertUser(expectedSubscription.User, actualSubscription.User); + } + + static void AssertSchedule(GetSubscriptionsResponse.SubscriptionType.ScheduleType? expected, GetSubscriptionsResponse.SubscriptionType.ScheduleType? actual) + { + Assert.NotNull(actual); + Assert.Equal(expected?.Frequency, actual?.Frequency); + Assert.Equal(expected?.NextRunAt, actual?.NextRunAt); + + AssertFrequencyDetails(expected?.FrequencyDetails, actual?.FrequencyDetails); + } + + static void AssertFrequencyDetails( + GetSubscriptionsResponse.SubscriptionType.ScheduleType.FrequencyDetailsType? expected, + GetSubscriptionsResponse.SubscriptionType.ScheduleType.FrequencyDetailsType? actual) + { + Assert.NotNull(expected); + Assert.NotNull(actual); + Assert.Equal(expected.Start, actual.Start); + Assert.Equal(expected.End, actual.End); + + Assert.Equal(expected.Intervals.Length, actual.Intervals.Length); + } + + static void AssertUser( + GetSubscriptionsResponse.SubscriptionType.UserType? expected, + GetSubscriptionsResponse.SubscriptionType.UserType? actual) + { + Assert.NotNull(actual); + Assert.Equal(expected?.Id, actual?.Id); + Assert.Equal(expected?.Name, actual?.Name); + } + + static void AssertContent(GetSubscriptionsResponse.SubscriptionType.ContentType? expected, GetSubscriptionsResponse.SubscriptionType.ContentType? actual) + { + Assert.NotNull(actual); + Assert.Equal(expected?.Id, actual.Id); + Assert.Equal(expected?.Type, actual.Type); + Assert.Equal(expected?.SendIfViewEmpty, actual.SendIfViewEmpty); + } + } + + private static string BuildInputXml(List expectedSubscriptions) + { + var builder = new StringBuilder(); + + AppendTsResponseStartElement(builder); + AppendPaginationElement(builder, 1, 100, expectedSubscriptions.Count); + builder.AppendLine(@" "); + foreach (var item in expectedSubscriptions) + { + builder.Append($@" + "); + builder.Append($@" + "); + + builder.Append($@" + "); + var frequencyDetails = item.Schedule?.FrequencyDetails; + if (frequencyDetails is not null) + { + builder.Append($@" + "); + AppendIntervals(builder, frequencyDetails?.Intervals); + builder.Append($@" + "); + } + builder.Append($@" + "); + builder.Append($@" "); + builder.Append($@" "); + } + + builder.AppendLine(@" "); + AppendTsResponseEndElement(builder); + return builder.ToString(); + + static void AppendIntervals( + StringBuilder xmlBuilder, + GetSubscriptionsResponse.SubscriptionType.ScheduleType.FrequencyDetailsType.IntervalType[]? intervals) + { + if (intervals is null) + { + xmlBuilder.Append($@" + "); + return; + } + + xmlBuilder.Append($@" + "); + + foreach (var interval in intervals) + { + xmlBuilder.Append($@" + + "); + } + + xmlBuilder.Append($@" + "); + } + } + + private static void AppendPaginationElement(StringBuilder builder, int pageNumber, int pageSize, int totalAvailable) + => builder.AppendLine(@$" "); + + + private static void AppendTsResponseEndElement(StringBuilder builder) + => builder.AppendLine(@$""); + + private static void AppendTsResponseStartElement(StringBuilder builder) + => builder.Append($"<{TableauServerResponse.XmlTypeName} xmlns=\"http://tableau.com/api\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://tableau.com/api https://help.tableau.com/samples/en-us/rest_api/ts-api_3_24.xsd\">"); + + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/UpdateSubscriptionResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/UpdateSubscriptionResponseTests.cs new file mode 100644 index 00000000..450ed26f --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Cloud/UpdateSubscriptionResponseTests.cs @@ -0,0 +1,69 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Responses.Cloud +{ + public sealed class UpdateSubscriptionResponseTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void InitializesSubscription() + { + var s = Create(); + + var r = new UpdateSubscriptionResponse.SubscriptionType(s); + + Assert.Equal(s.Id, r.Id); + Assert.Equal(s.Subject, r.Subject); + Assert.Equal(s.AttachImage, r.AttachImage); + Assert.Equal(s.AttachPdf, r.AttachPdf); + Assert.Equal(s.PageOrientation, r.PageOrientation); + Assert.Equal(s.PageSizeOption, r.PageSizeOption); + Assert.Equal(s.Suspended, r.Suspended); + Assert.Equal(s.Message, r.Message); + + Assert.NotNull(r.Content); + Assert.Equal(s.Content!.Id, r.Content.Id); + Assert.Equal(s.Content.Type, r.Content.Type); + Assert.Equal(s.Content.SendIfViewEmpty, r.Content.SendIfViewEmpty); + + Assert.NotNull(r.User); + Assert.Equal(s.User!.Id, r.User.Id); + } + + [Fact] + public void InitializesSchedule() + { + var s = Create(); + + var r = new UpdateSubscriptionResponse.ScheduleType(s); + + Assert.Equal(s.Frequency, r.Frequency); + + Assert.NotNull(r.FrequencyDetails); + Assert.Equal(s.FrequencyDetails!.Start, r.FrequencyDetails.Start); + Assert.Equal(s.FrequencyDetails.End, r.FrequencyDetails.End); + Assert.Equal(s.FrequencyDetails.Intervals.Length, r.FrequencyDetails.Intervals.Length); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/RetrieveKeychainResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/RetrieveKeychainResponseTests.cs new file mode 100644 index 00000000..c8291828 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/RetrieveKeychainResponseTests.cs @@ -0,0 +1,121 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Text; +using AutoFixture; +using Tableau.Migration.Api.Rest.Models.Responses; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Responses +{ + public class RetrieveKeychainResponseTests + { + public class DeSerialization : SerializationTestBase + { + [Fact] + public void DeSerializes() + { + var expectedResponse = Create(); + + Assert.NotNull(expectedResponse.AssociatedUserLuidList); + + var userLuidElements = new StringBuilder(); + + foreach (var userId in expectedResponse.AssociatedUserLuidList) + { + userLuidElements.AppendLine($"{userId.ToString()}"); + } + + Assert.NotNull(expectedResponse.EncryptedKeychainList); + + var keyChainElements = new StringBuilder(); + + foreach (var keychain in expectedResponse.EncryptedKeychainList) + { + keyChainElements.AppendLine($"{keychain}"); + } + + var xml = $@" + + + {userLuidElements} + + + {keyChainElements} + + +"; + var deserialized = Serializer.DeserializeFromXml(xml); + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.AssociatedUserLuidList); + Assert.Equal(expectedResponse.AssociatedUserLuidList.Length, deserialized.AssociatedUserLuidList.Length); + Assert.All( + expectedResponse.AssociatedUserLuidList, + (responseUserLuid) => Assert.Contains(responseUserLuid, deserialized.AssociatedUserLuidList)); + + Assert.NotNull(deserialized.EncryptedKeychainList); + Assert.Equal(expectedResponse.EncryptedKeychainList.Length, deserialized.EncryptedKeychainList.Length); + Assert.All( + expectedResponse.EncryptedKeychainList, + (responseKeychain) => Assert.Contains(responseKeychain, deserialized.EncryptedKeychainList)); + } + + [Fact] + public void DeSerializes_with_empty_users() + { + var expectedResponse = AutoFixture + .Build() + .With(r => r.AssociatedUserLuidList, () => []) + .Create(); + + Assert.NotNull(expectedResponse.AssociatedUserLuidList); + Assert.Empty(expectedResponse.AssociatedUserLuidList); + + + + Assert.NotNull(expectedResponse.EncryptedKeychainList); + + var keyChainElements = new StringBuilder(); + + foreach (var keychain in expectedResponse.EncryptedKeychainList) + { + keyChainElements.AppendLine($"{keychain}"); + } + + var xml = $@" + + + + {keyChainElements} + + +"; + var deserialized = Serializer.DeserializeFromXml(xml); + Assert.NotNull(deserialized); + Assert.NotNull(deserialized.AssociatedUserLuidList); + Assert.Empty(deserialized.AssociatedUserLuidList); + + Assert.NotNull(deserialized.EncryptedKeychainList); + Assert.Equal(expectedResponse.EncryptedKeychainList.Length, deserialized.EncryptedKeychainList.Length); + Assert.All( + expectedResponse.EncryptedKeychainList, + (responseKeychain) => Assert.Contains(responseKeychain, deserialized.EncryptedKeychainList)); + } + } + + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Server/GetSubscriptionsResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Server/GetSubscriptionsResponseTests.cs new file mode 100644 index 00000000..be9d844f --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/Server/GetSubscriptionsResponseTests.cs @@ -0,0 +1,124 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Rest.Models.Responses.Server; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Responses.Server +{ + public class GetSubscriptionsResponseTests + { + public class Serialization : SerializationTestBase + { + [Fact] + public void DeSerializes() + { + var expectedResponse = Create(); + + var expectedSubscriptions = expectedResponse.Items.ToList(); + var xmlBuilder = new StringBuilder(); + + var xml = BuildInputXml(expectedSubscriptions, xmlBuilder); + + var deserialized = Serializer.DeserializeFromXml(xml); + Assert.NotNull(deserialized); + var actualSubscriptions = deserialized.Items; + Assert.Equal(expectedSubscriptions.Count, actualSubscriptions.Length); + + foreach (var expectedSubscription in expectedSubscriptions) + { + AssertSubscription(expectedSubscription, actualSubscriptions.FirstOrDefault(x => x.Id == expectedSubscription.Id)); + } + + static void AssertSubscription(GetSubscriptionsResponse.SubscriptionType? expectedSubscription, GetSubscriptionsResponse.SubscriptionType? actualSubscription) + { + Assert.NotNull(expectedSubscription); + Assert.NotNull(actualSubscription); + + Assert.Equal(expectedSubscription.Id, actualSubscription.Id); + Assert.Equal(expectedSubscription.Subject, actualSubscription.Subject); + Assert.Equal(expectedSubscription.AttachImage, actualSubscription.AttachImage); + Assert.Equal(expectedSubscription.AttachPdf, actualSubscription.AttachPdf); + + AssertContent(expectedSubscription.Content, actualSubscription.Content); + + AssertSchedule(expectedSubscription.Schedule, actualSubscription.Schedule); + + AssertUser(expectedSubscription.User, actualSubscription.User); + } + + static void AssertSchedule(GetSubscriptionsResponse.SubscriptionType.ScheduleType? expected, GetSubscriptionsResponse.SubscriptionType.ScheduleType? actual) + { + Assert.NotNull(actual); + Assert.Equal(expected?.Id, actual?.Id); + Assert.Equal(expected?.Name, actual?.Name); + } + + static void AssertUser(GetSubscriptionsResponse.SubscriptionType.UserType? expected, GetSubscriptionsResponse.SubscriptionType.UserType? actual) + { + Assert.NotNull(actual); + Assert.Equal(expected?.Id, actual?.Id); + Assert.Equal(expected?.Name, actual?.Name); + } + + static void AssertContent(GetSubscriptionsResponse.SubscriptionType.ContentType? expected, GetSubscriptionsResponse.SubscriptionType.ContentType? actual) + { + Assert.NotNull(actual); + Assert.Equal(expected?.Id, actual.Id); + Assert.Equal(expected?.Type, actual.Type); + Assert.Equal(expected?.SendIfViewEmpty, actual.SendIfViewEmpty); + } + } + + private static string BuildInputXml(List expectedSubscriptions, StringBuilder xmlBuilder) + { + AppendTsResponseStartElement(xmlBuilder); + AppendPaginationElement(xmlBuilder, 1, 100, expectedSubscriptions.Count); + xmlBuilder.AppendLine(@" "); + foreach (var item in expectedSubscriptions) + { + xmlBuilder.Append($@" + + + + + + "); + } + + xmlBuilder.AppendLine(@" "); + AppendTsResponseEndElement(xmlBuilder); + return xmlBuilder.ToString(); + } + + private static void AppendPaginationElement(StringBuilder builder, int pageNumber, int pageSize, int totalAvailable) + => builder.AppendLine(@$" "); + + + private static void AppendTsResponseEndElement(StringBuilder builder) + => builder.AppendLine(@$""); + + private static void AppendTsResponseStartElement(StringBuilder builder) + => builder.Append($"<{TableauServerResponse.XmlTypeName} xmlns=\"http://tableau.com/api\" xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" xsi:schemaLocation=\"http://tableau.com/api https://help.tableau.com/samples/en-us/rest_api/ts-api_3_24.xsd\">"); + + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/SiteAuthConfigurationResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/SiteAuthConfigurationResponseTests.cs new file mode 100644 index 00000000..50773d65 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Responses/SiteAuthConfigurationResponseTests.cs @@ -0,0 +1,65 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Text; +using Tableau.Migration.Api.Rest.Models.Responses; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models.Responses +{ + public sealed class SiteAuthConfigurationResponseTests + { + public sealed class Deserialization : SerializationTestBase + { + [Fact] + public void Deserializes() + { + var expectedResponse = Create(); + + var itemsXml = new StringBuilder(); + foreach(var item in expectedResponse.Items) + { + itemsXml.AppendLine($@""); + } + + var xml = $@" + + + {itemsXml} + +"; + + var deserialized = Serializer.DeserializeFromXml(xml); + Assert.NotNull(deserialized); + Assert.Equal(expectedResponse.Items, deserialized.Items, + (SiteAuthConfigurationsResponse.SiteAuthConfigurationType a, SiteAuthConfigurationsResponse.SiteAuthConfigurationType b) => + { + return a.AuthSetting == b.AuthSetting && + a.IdpConfigurationId == b.IdpConfigurationId && + a.IdpConfigurationName == b.IdpConfigurationName && + a.KnownProviderAlias == b.KnownProviderAlias && + a.Enabled == b.Enabled; + }); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/RestProjectBuilderTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/RestProjectBuilderTests.cs index ddd3a9e1..5452b364 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/RestProjectBuilderTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/RestProjectBuilderTests.cs @@ -25,6 +25,8 @@ using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Content; using Tableau.Migration.Content.Search; +using Tableau.Migration.Paging; +using Tableau.Migration.Resources; using Xunit; using RestProject = Tableau.Migration.Api.Rest.Models.Responses.ProjectsResponse.ProjectType; @@ -115,6 +117,29 @@ public async Task OwnerNotFoundAsync() await Assert.ThrowsAsync(() => builder.BuildProjectAsync(restProj, mockUserFinder.Object, Cancel)); } + + [Fact] + public async Task ExternalAssetsDefaultHasSystemOwnerAsync() + { + var externalAssetsDefaultProject = Create(); + externalAssetsDefaultProject.Name = DefaultExternalAssetsProjectTranslations.English; + + Projects.Clear(); + Projects.Add(externalAssetsDefaultProject); + + var builder = CreateBuilder(); + + var mockUserFinder = Create>>(); + mockUserFinder.Setup(x => x.FindByIdAsync(It.IsAny(), Cancel)) + .ReturnsAsync((IContentReference?)null); + + var restProj = Projects.First(); + var proj = await builder.BuildProjectAsync(restProj, mockUserFinder.Object, Cancel); + + Assert.Equal(restProj.Id, proj.Id); + Assert.Equal(restProj.Owner!.Id, proj.Owner.Id); + Assert.Equal(Constants.SystemUserLocation, proj.Owner.Location); + } } #endregion diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/RetrieveUserSavedCredentialsResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/RetrieveUserSavedCredentialsResponseTests.cs new file mode 100644 index 00000000..f3f9100d --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/RetrieveUserSavedCredentialsResponseTests.cs @@ -0,0 +1,62 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Rest.Models.Responses; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models +{ + public class RetrieveUserSavedCredentialsResponseTests + { + public class Serialization : SerializationTestBase + { + [Fact] + public void Deserializes() + { + var expected = Create(); + + Assert.NotNull(expected.EncryptedKeychainList); + Assert.NotNull(expected.AssociatedUserLuidList); + + var xml = $@" + + + {expected.EncryptedKeychainList[0]} + {expected.EncryptedKeychainList[1]} + {expected.EncryptedKeychainList[2]} + + + {expected.AssociatedUserLuidList[0]} + {expected.AssociatedUserLuidList[1]} + {expected.AssociatedUserLuidList[2]} + +"; + + var deserialized = Serializer.DeserializeFromXml(xml); + + Assert.NotNull(deserialized); + Assert.Null(deserialized.Error); + + Assert.Equal(expected.EncryptedKeychainList, deserialized.EncryptedKeychainList); + Assert.Equal(expected.AssociatedUserLuidList, deserialized.AssociatedUserLuidList); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/UpdateUserResultTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/UpdateUserResultTests.cs new file mode 100644 index 00000000..e6290b16 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/UpdateUserResultTests.cs @@ -0,0 +1,109 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Api.Rest.Models +{ + public sealed class UpdateUserResultTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void UserRequired() + { + var response = Create(); + response.Item = null; + + Assert.Throws(() => new UpdateUserResult(response)); + } + + [Fact] + public void NameRequired() + { + var response = Create(); + response.Item!.Name = string.Empty; + + Assert.Throws(() => new UpdateUserResult(response)); + } + + [Fact] + public void SiteRoleRequired() + { + var response = Create(); + response.Item!.SiteRole = string.Empty; + + Assert.Throws(() => new UpdateUserResult(response)); + } + + [Fact] + public void ExposeFullAuthInfo() + { + var id = Guid.NewGuid(); + + var response = Create(); + response.Item!.IdpConfigurationId = id.ToString(); + + var result = new UpdateUserResult(response); + + Assert.Equal(response.Item.AuthSetting, result.Authentication.AuthenticationType); + Assert.Equal(id, result.Authentication.IdpConfigurationId); + } + + [Fact] + public void PreferIdpConfigurationId() + { + var id = Guid.NewGuid(); + + var response = Create(); + response.Item!.AuthSetting = null; + response.Item.IdpConfigurationId = id.ToString(); + + var result = new UpdateUserResult(response); + + Assert.Equal(UserAuthenticationType.ForConfigurationId(id), result.Authentication); + } + + [Fact] + public void AuthSetting() + { + var response = Create(); + response.Item!.IdpConfigurationId = null; + + var result = new UpdateUserResult(response); + + Assert.Equal(UserAuthenticationType.ForAuthenticationType(response.Item.AuthSetting!), result.Authentication); + } + + [Fact] + public void NoAuth() + { + var response = Create(); + response.Item!.AuthSetting = null; + response.Item.IdpConfigurationId = null; + + var result = new UpdateUserResult(response); + + Assert.Equal(UserAuthenticationType.Default, result.Authentication); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/WorkbookResponseTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/WorkbookResponseTests.cs index a6a65f73..0c74acc8 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/WorkbookResponseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/WorkbookResponseTests.cs @@ -70,8 +70,8 @@ private static void AssertWorkbook( } private static void AssertViews( - List expected, - List actual) + List expected, + List actual) { Assert.Equal(expected.Count, actual.Count); Assert.All(actual, view => Assert.NotEqual(Guid.Empty, view.Id)); @@ -110,9 +110,9 @@ private static void AssertTags( private (string TestXML, WorkbookResponse.WorkbookType ExpectedResult) GetTestData() { - var viewTags = CreateMany(2).ToArray(); + var viewTags = CreateMany(2).ToArray(); var views = AutoFixture - .Build() + .Build() .With(wb => wb.Tags, viewTags) .CreateMany(2) .ToArray(); diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs index 5eba9583..40b92994 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs @@ -16,6 +16,7 @@ // using System; +using System.Diagnostics; using System.Net.Http; using Microsoft.Extensions.Localization; using Moq; @@ -49,6 +50,7 @@ public void Initializes() new Uri("http://localhost"), correlationId, error, + new StackTrace(fNeedFileInfo: true).ToString(), mockLocalizer.Object); Assert.Equal(error.Code, exception.Code); diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilderTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilderTests.cs index e0cb80c0..4b16ecb1 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilderTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Simulation/Rest/Net/Responses/RestPermissionsCreateResponseBuilderTests.cs @@ -19,6 +19,7 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; @@ -73,7 +74,7 @@ public async Task DenyProjectLeaderErrorAsync() var responseContent = await Serializer.DeserializeAsync(response.Content, Cancel); Assert.NotNull(responseContent); Assert.NotNull(responseContent.Error); - Assert.Equal("400009", responseContent.Error.Code); + Assert.Equal(RestErrorCodes.INVALID_CAPABILITY_FOR_RESOURCE, responseContent.Error.Code); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/SubscriptionsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/SubscriptionsApiClientTests.cs new file mode 100644 index 00000000..aeb663fd --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/SubscriptionsApiClientTests.cs @@ -0,0 +1,328 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Moq; +using Tableau.Migration.Api; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Xunit; + +using CloudResponses = Tableau.Migration.Api.Rest.Models.Responses.Cloud; +using ServerResponses = Tableau.Migration.Api.Rest.Models.Responses.Server; + +namespace Tableau.Migration.Tests.Unit.Api +{ + public sealed class SubscriptionsApiClientTests + { + public abstract class SubscriptionsApiClientTest : ApiClientTestBase + { + internal virtual SubscriptionsApiClient SubscriptionsApiClient => GetApiClient(); + + protected TableauInstanceType CurrentInstanceType { get; set; } + + public SubscriptionsApiClientTest() + => MockSessionProvider.SetupGet(p => p.InstanceType).Returns(() => CurrentInstanceType); + + protected static List AssertSuccess( + IResult> result) + where TSubscription : ISubscription + where TSchedule : ISchedule + { + Assert.NotNull(result); + Assert.Empty(result.Errors); + + var actualSubscriptions = result.Value?.ToList(); + Assert.NotNull(actualSubscriptions); + return actualSubscriptions; + } + + protected TResponse CreateGetResponse(string contentType) + where TResponse : TableauServerResponse + { + AutoFixture.Customize( + composer => composer.With(j => j.Type, () => contentType)); + + return AutoFixture.CreateResponse(); + } + } + + public class ForServer : SubscriptionsApiClientTest + { + [Theory] + [EnumData(TableauInstanceType.Server)] + public void Fails_when_current_instance_is_not_server(TableauInstanceType instanceType) + { + CurrentInstanceType = instanceType; + + var exception = Assert.Throws(SubscriptionsApiClient.ForServer); + + Assert.Equal(instanceType, exception.UnsupportedInstanceType); + + MockSessionProvider.VerifyGet(p => p.InstanceType, Times.Once); + } + + [Fact] + public void Returns_client_when_current_instance_is_server() + { + CurrentInstanceType = TableauInstanceType.Server; + + var client = SubscriptionsApiClient.ForServer(); + + MockSessionProvider.VerifyGet(p => p.InstanceType, Times.Once); + } + } + + public class ForCloud : SubscriptionsApiClientTest + { + [Theory] + [EnumData(TableauInstanceType.Cloud)] + public void Fails_when_current_instance_is_not_cloud(TableauInstanceType instanceType) + { + CurrentInstanceType = instanceType; + + var exception = Assert.Throws(SubscriptionsApiClient.ForCloud); + + Assert.Equal(instanceType, exception.UnsupportedInstanceType); + + MockSessionProvider.VerifyGet(p => p.InstanceType, Times.Once); + } + + [Fact] + public void Returns_client_when_current_instance_is_cloud() + { + CurrentInstanceType = TableauInstanceType.Cloud; + + var client = SubscriptionsApiClient.ForCloud(); + + MockSessionProvider.VerifyGet(p => p.InstanceType, Times.Once); + } + } + + #region - Cloud - + + public class Cloud + { + public abstract class CloudSubscriptionsApiClientTest : SubscriptionsApiClientTest + { + internal ICloudSubscriptionsApiClient CloudSubscriptionsApiClient => SubscriptionsApiClient; + + public CloudSubscriptionsApiClientTest() + { + CurrentInstanceType = TableauInstanceType.Cloud; + } + + protected CloudResponses.GetSubscriptionsResponse CreateCloudResponse(string contentType) + { + AutoFixture.Customize( + composer => composer.With(j => j.Type, () => contentType)); + + return AutoFixture.CreateResponse(); + } + } + + #region - GetAllSubscriptionsAsync - + + public class GetAllSubscriptionsAsync : CloudSubscriptionsApiClientTest + { + [Fact] + public async Task Gets_view_subscriptions() + { + var response = CreateGetResponse("view"); + + SetupSuccessResponse(response); + + var result = await CloudSubscriptionsApiClient.GetAllSubscriptionsAsync(0, 100, Cancel); + + var actualSubscriptions = AssertSuccess(result); + var expectedSubscriptions = response.Items.ToList(); + + Assert.Equal(expectedSubscriptions.Count, actualSubscriptions.Count); + } + + [Fact] + public async Task Gets_workbook_subscriptions() + { + var response = CreateGetResponse("workbook"); + + SetupSuccessResponse(response); + + var result = await CloudSubscriptionsApiClient.GetAllSubscriptionsAsync(0, 100, Cancel); + + var actualSubscriptions = AssertSuccess(result); + var expectedSubscriptions = response.Items.ToList(); + + Assert.Equal(expectedSubscriptions.Count, actualSubscriptions.Count); + } + } + + #endregion + + #region - CreateSubscriptionAsync - + + public sealed class CreateSubscriptionAsync : CloudSubscriptionsApiClientTest + { + [Fact] + public async Task CreatesSubscriptionAsync() + { + SetupSuccessResponse(); + + var sub = Create(); + + var result = await CloudSubscriptionsApiClient.CreateSubscriptionAsync(sub, Cancel); + + result.AssertSuccess(); + } + + [Fact] + public async Task ReturnsFailureAsync() + { + SetupErrorResponse(); + + var sub = Create(); + + var result = await CloudSubscriptionsApiClient.CreateSubscriptionAsync(sub, Cancel); + + result.AssertFailure(); + } + } + + #endregion + + #region - UpdateSubscriptionAsync - + + public sealed class UpdateSubscriptionAsync : CloudSubscriptionsApiClientTest + { + [Fact] + public async Task UpdatesSubscriptionAsync() + { + SetupSuccessResponse(); + + var sub = Create(); + + var result = await CloudSubscriptionsApiClient.UpdateSubscriptionAsync(Guid.NewGuid(), Cancel); + + result.AssertSuccess(); + } + + [Fact] + public async Task ReturnsFailureAsync() + { + SetupErrorResponse(); + + var sub = Create(); + + var result = await CloudSubscriptionsApiClient.UpdateSubscriptionAsync(Guid.NewGuid(), Cancel); + + result.AssertFailure(); + } + } + + #endregion + + #region - DeleteAsync - + public sealed class DeleteAsync : CloudSubscriptionsApiClientTest + { + [Fact] + public async Task DeletesSubscriptionAsync() + { + SetupSuccessResponse(); + + var result = await CloudSubscriptionsApiClient.DeleteAsync(Guid.NewGuid(), Cancel); + + result.AssertSuccess(); + } + + [Fact] + public async Task ReturnsFailureAsync() + { + SetupErrorResponse(); + + var result = await CloudSubscriptionsApiClient.DeleteAsync(Guid.NewGuid(), Cancel); + + result.AssertFailure(); + } + } + #endregion + } + + #endregion + + #region - Server - + + public class Server + { + public abstract class ServerSubscriptionsApiClientTest : SubscriptionsApiClientTest + { + internal IServerSubscriptionsApiClient ServerSubscriptionsApiClient => SubscriptionsApiClient; + + public ServerSubscriptionsApiClientTest() + { + CurrentInstanceType = TableauInstanceType.Server; + } + } + + #region - GetAllSubscriptionsAsync - + + public class GetAllSubscriptionsAsync : ServerSubscriptionsApiClientTest + { + [Fact] + public async Task Gets_view_subscriptions() + { + MockSessionProvider.SetupGet(p => p.InstanceType).Returns(TableauInstanceType.Server); + + var response = CreateGetResponse("view"); + + SetupSuccessResponse(response); + + var result = await ServerSubscriptionsApiClient.GetAllSubscriptionsAsync(0, 100, Cancel); + + var actualSubscriptions = AssertSuccess(result); + var expectedSubscriptions = response.Items.ToList(); + + Assert.Equal(expectedSubscriptions.Count, actualSubscriptions.Count); + } + + [Fact] + public async Task Gets_workbook_subscriptions() + { + var response = CreateGetResponse("workbook"); + + SetupSuccessResponse(response); + + var result = await ServerSubscriptionsApiClient.GetAllSubscriptionsAsync(0, 100, Cancel); + + var actualSubscriptions = AssertSuccess(result); + var expectedSubscriptions = response.Items.ToList(); + + Assert.Equal(expectedSubscriptions.Count, actualSubscriptions.Count); + } + } + + #endregion + } + + #endregion + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Api/TasksApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/TasksApiClientTests.cs index 4b047b40..fd197194 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/TasksApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/TasksApiClientTests.cs @@ -26,6 +26,7 @@ using Moq; using Tableau.Migration.Api; using Tableau.Migration.Api.Models.Cloud; +using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models.Types; using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; @@ -348,7 +349,7 @@ public async Task Fails_to_create_extract_refresh() contentReference.Id, cloudSchedule); - SetupErrorResponse(error => error.Code = "400000"); + SetupErrorResponse(error => error.Code = RestErrorCodes.BAD_REQUEST); // Act var result = await CloudTasksApiClient.CreateExtractRefreshTaskAsync( diff --git a/tests/Tableau.Migration.Tests/Unit/Api/UsersApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/UsersApiClientTests.cs index 11acc0cb..d153a79f 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/UsersApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/UsersApiClientTests.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2025, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -16,6 +16,7 @@ // using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; @@ -24,7 +25,9 @@ using AutoFixture; using Moq; using Tableau.Migration.Api; +using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Requests; using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Api.Rest.Models.Types; using Tableau.Migration.Content; @@ -44,20 +47,27 @@ public abstract class UsersApiClientTest : ApiClientTestBase #region - List - - public class ListClient : PagedListApiClientTestBase + public sealed class ListClient : PagedListApiClientTestBase { } - public class PageAccessor : ApiPageAccessorTestBase + public sealed class PageAccessor : ApiPageAccessorTestBase { } #endregion - public class ImportUsersAsync : UsersApiClientTest + #region - ImportUsersAsync - + + public sealed class ImportUsersAsync : UsersApiClientTest { [Fact] public async Task MultipleAuthTypes() { var users = AutoFixture.CreateMany(); + foreach(var u in users) + { + u.Authentication = UserAuthenticationType.ForConfigurationId(Guid.NewGuid()); + } + using var dataStream = Migration.Api.UsersApiClient.GenerateUserCsvStream(users); var userData = await new StreamContent(dataStream).ReadAsStringAsync(Cancel); dataStream.Seek(0, SeekOrigin.Begin); @@ -89,7 +99,7 @@ public async Task MultipleAuthTypes() Assert.Equal("request_payload", requestUserContent.Headers.ContentDisposition?.Name); var userPayload = await requestUserContent.ReadAsStringAsync(); - var expectedPayload = $"{string.Join("", users.Select(u => $@""))}"; + var expectedPayload = $"{string.Join("", users.Select(u => $@""))}"; Assert.Equal(expectedPayload, userPayload); request.AssertSingleHeaderValue("Accept", MediaTypes.Xml.MediaType!); @@ -101,7 +111,7 @@ public async Task SingleAuthType() var users = AutoFixture.CreateMany(); foreach (var user in users) { - user.AuthenticationType = AuthenticationTypes.ServerDefault; + user.Authentication = UserAuthenticationType.ForAuthenticationType(AuthenticationTypes.ServerDefault); } using var dataStream = Migration.Api.UsersApiClient.GenerateUserCsvStream(users); @@ -146,7 +156,7 @@ public async Task NoAuthTypes() var users = AutoFixture.CreateMany(); foreach (var user in users) { - user.AuthenticationType = string.Empty; + user.Authentication = UserAuthenticationType.Default; } using var dataStream = Migration.Api.UsersApiClient.GenerateUserCsvStream(users); @@ -207,33 +217,38 @@ public async Task ReturnsFailure() request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/users/import"); } - } - public class AddUserAsync : UsersApiClientTest + #endregion + + #region - AddUserAsync - + + public sealed class AddUserAsync : UsersApiClientTest { [Fact] public async Task Success() { //Setup var addResponse = AutoFixture.CreateResponse(); + addResponse.Item!.IdpConfigurationId = Guid.NewGuid().ToString(); - var expectedUserId = addResponse?.Item?.Id; - Assert.NotNull(expectedUserId); + var expectedUserId = addResponse.Item.Id; - var expectedUserName = addResponse?.Item?.Name; + var expectedUserName = addResponse.Item.Name; Assert.NotNull(expectedUserName); - var expectedSiteRole = addResponse?.Item?.SiteRole; + var expectedSiteRole = addResponse.Item.SiteRole; Assert.NotNull(expectedSiteRole); - var expectedAuthSetting = addResponse?.Item?.AuthSetting; + var expectedAuthSetting = addResponse.Item.AuthSetting; Assert.NotNull(expectedAuthSetting); + addResponse.Item.IdpConfigurationId = null; + MockHttpClient.SetupResponse(new MockHttpResponseMessage(addResponse)); //Act - var result = await UsersApiClient.AddUserAsync(expectedUserName, expectedSiteRole, expectedAuthSetting, Cancel); + var result = await UsersApiClient.AddUserAsync(expectedUserName, expectedSiteRole, UserAuthenticationType.ForAuthenticationType(expectedAuthSetting), Cancel); //Test Assert.True(result.Success); @@ -243,7 +258,8 @@ public async Task Success() Assert.Equal(expectedUserId, addUserResult.Id); Assert.Equal(expectedUserName, addUserResult.Name); Assert.Equal(expectedSiteRole, addUserResult.SiteRole); - Assert.Equal(expectedAuthSetting, addUserResult.AuthSetting); + Assert.Equal(expectedAuthSetting, addUserResult.Authentication.AuthenticationType); + Assert.Null(addUserResult.Authentication.IdpConfigurationId); } [Fact] @@ -253,7 +269,7 @@ public async Task Failure() MockHttpClient.SetupResponse(new MockHttpResponseMessage(HttpStatusCode.InternalServerError, null)); //Act - var result = await UsersApiClient.AddUserAsync("testUser", "testSiteRole", null, Cancel); + var result = await UsersApiClient.AddUserAsync("testUser", "testSiteRole", UserAuthenticationType.Default, Cancel); //Test Assert.False(result.Success); @@ -291,7 +307,7 @@ public async Task Succeeds_when_user_exists() MockHttpClient.SetupResponse(getAllUsersResponse); // Act - var result = await UsersApiClient.AddUserAsync(existingUser.Name!, existingUser.SiteRole!, existingUser.AuthSetting, Cancel); + var result = await UsersApiClient.AddUserAsync(existingUser.Name!, existingUser.SiteRole!, UserAuthenticationType.ForAuthenticationType(existingUser.AuthSetting!), Cancel); // Test Assert.True(result.Success); @@ -301,12 +317,16 @@ public async Task Succeeds_when_user_exists() Assert.Equal(existingUser.Id, addUserResult.Id); Assert.Equal(existingUser.Name, addUserResult.Name); Assert.Equal(existingUser.SiteRole, addUserResult.SiteRole); - Assert.Equal(existingUser.AuthSetting, addUserResult.AuthSetting); + Assert.Equal(existingUser.AuthSetting, addUserResult.Authentication.AuthenticationType); + Assert.Null(addUserResult.Authentication.IdpConfigurationId); } - } - public class UpdateUserAsync : UsersApiClientTest + #endregion + + #region - UpdateUserAsync - + + public sealed class UpdateUserAsync : UsersApiClientTest { [Fact] public async Task Success_With_Required_Params() @@ -343,7 +363,7 @@ public async Task Success_With_Required_Params() Assert.Equal(expectedFullName, addUserResult.FullName); Assert.Equal(expectedEmail, addUserResult.Email); Assert.Equal(expectedSiteRole, addUserResult.SiteRole); - Assert.Equal(expectedAuthSetting, addUserResult.AuthSetting); + Assert.Equal(expectedAuthSetting, addUserResult.Authentication.AuthenticationType); } [Fact] @@ -376,7 +396,7 @@ public async Task Success_With_All_Params() newfullName: expectedFullName, newEmail: expectedEmail, newPassword: "Old-McD0nald-H@d-a-F@rm", - newAuthSetting: expectedAuthSetting); + newAuthentication: UserAuthenticationType.ForAuthenticationType(expectedAuthSetting)); //Test Assert.True(result.Success); @@ -387,7 +407,7 @@ public async Task Success_With_All_Params() Assert.Equal(expectedFullName, addUserResult.FullName); Assert.Equal(expectedEmail, addUserResult.Email); Assert.Equal(expectedSiteRole, addUserResult.SiteRole); - Assert.Equal(expectedAuthSetting, addUserResult.AuthSetting); + Assert.Equal(expectedAuthSetting, addUserResult.Authentication.AuthenticationType); } [Fact] public async Task Failure() @@ -402,7 +422,7 @@ public async Task Failure() newfullName: "Jack Sparrow", newEmail: "jsparrow@example.com", newPassword: "Why-Is-Th3-Rum-Gone?", - newAuthSetting: "local"); + newAuthentication: UserAuthenticationType.ForAuthenticationType("local")); //Test Assert.False(result.Success); @@ -412,7 +432,11 @@ public async Task Failure() } } - public class DeleteUserAsync : UsersApiClientTest + #endregion + + #region - DeleteUserAsync - + + public sealed class DeleteUserAsync : UsersApiClientTest { [Fact] public async Task Success() @@ -457,5 +481,209 @@ public async Task Failure() } } + #endregion + + #region - RetrieveUserSavedCredentialsAsync - + + public sealed class RetrieveUserSavedCredentialsAsync : UsersApiClientTest + { + [Fact] + public async Task Returns_success() + { + var userId = Create(); + var options = Create(); + var response = AutoFixture.CreateResponse(); + + var mockResponse = new MockHttpResponseMessage(response); + + MockHttpClient.SetupResponse(mockResponse); + + var result = await ApiClient.RetrieveUserSavedCredentialsAsync(userId, options, Cancel); + + Assert.True(result.Success); + + var request = MockHttpClient.AssertSingleRequest(); + + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/users/{userId}/retrieveSavedCreds"); + + var requestContent = Assert.IsType(request.Content); + + var requestModel = await HttpContentSerializer.Instance.DeserializeAsync(requestContent, Cancel); + + Assert.NotNull(requestModel); + Assert.Equal(options.ContentUrl, requestModel.DestinationSiteUrlNamespace); + Assert.Equal(options.SiteUrl, requestModel.DestinationServerUrl); + Assert.Equal(options.SiteId, requestModel.DestinationSiteLuid); + } + + [Fact] + public async Task Returns_failure() + { + var userId = Create(); + var exception = new Exception(); + + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError, null); + mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); + + MockHttpClient.SetupResponse(mockResponse); + + var result = await ApiClient.RetrieveUserSavedCredentialsAsync(userId, Create(), Cancel); + + Assert.False(result.Success); + + var error = Assert.Single(result.Errors); + + Assert.Same(exception, error); + + var request = MockHttpClient.AssertSingleRequest(); + + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/users/{userId}/retrieveSavedCreds"); + } + } + + #endregion + + #region - UploadUserSavedCredentialsAsync - + + public sealed class UploadUserSavedCredentialsAsync : UsersApiClientTest + { + [Fact] + public async Task ErrorAsync() + { + var exception = new Exception(); + + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError); + mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); + MockHttpClient.SetupResponse(mockResponse); + + var userId = Create(); + var encryptedKeychains = Create>(); + + var result = await ApiClient.UploadUserSavedCredentialsAsync(userId, encryptedKeychains, Cancel); + + result.AssertFailure(); + + var resultError = Assert.Single(result.Errors); + Assert.Same(exception, resultError); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/users/{userId}/uploadSavedCreds"); + } + + [Fact] + public async Task SuccessAsync() + { + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.OK); + MockHttpClient.SetupResponse(mockResponse); + + var userId = Create(); + var encryptedKeychains = Create>(); + + var result = await ApiClient.UploadUserSavedCredentialsAsync(userId, encryptedKeychains, Cancel); + + result.AssertSuccess(); + + var request = MockHttpClient.AssertSingleRequest(); + request.AssertRelativeUri($"/api/{TableauServerVersion.RestApiVersion}/sites/{SiteId}/users/{userId}/uploadSavedCreds"); + } + } + + #endregion + + #region - PublishAsync - + + public sealed class PublishAsync : UsersApiClientTest + { + [Fact] + public async Task AddsAndUpdatesServerAsync() + { + InstanceType = TableauInstanceType.Server; + + var user = Create(); + + var addUserResponse = AutoFixture.CreateResponse(); + var updateUserResponse = AutoFixture.CreateResponse(); + + MockHttpClient.SetupResponse(new MockHttpResponseMessage(HttpStatusCode.Created, addUserResponse)); + MockHttpClient.SetupResponse(new MockHttpResponseMessage(updateUserResponse)); + + var result = await ApiClient.PublishAsync(user, Cancel); + + result.AssertSuccess(); + + Assert.Equal(addUserResponse.Item!.Id, result.Value!.Id); + } + + [Fact] + public async Task AddsAndUpdatesCloudAsync() + { + InstanceType = TableauInstanceType.Cloud; + + var user = Create(); + + var addUserResponse = AutoFixture.CreateResponse(); + var updateUserResponse = AutoFixture.CreateResponse(); + + MockHttpClient.SetupResponse(new MockHttpResponseMessage(HttpStatusCode.Created, addUserResponse)); + MockHttpClient.SetupResponse(new MockHttpResponseMessage(updateUserResponse)); + + var result = await ApiClient.PublishAsync(user, Cancel); + + result.AssertSuccess(); + + Assert.Equal(addUserResponse.Item!.Id, result.Value!.Id); + } + + [Fact] + public async Task InsufficientLicensesAsync() + { + var user = Create(); + + var addUserResponse = AutoFixture.CreateResponse(); + var updateUserResponse = AutoFixture.CreateResponse(); + updateUserResponse.Item!.SiteRole = SiteRoles.Unlicensed; + + MockHttpClient.SetupResponse(new MockHttpResponseMessage(HttpStatusCode.Created, addUserResponse)); + MockHttpClient.SetupResponse(new MockHttpResponseMessage(updateUserResponse)); + + var result = await ApiClient.PublishAsync(user, Cancel); + + result.AssertFailure(); + } + + [Fact] + public async Task AddFailsAsync() + { + var user = Create(); + + var addUserResponse = AutoFixture.CreateErrorResponse(); + var updateUserResponse = AutoFixture.CreateResponse(); + + MockHttpClient.SetupResponse(new MockHttpResponseMessage(addUserResponse)); + MockHttpClient.SetupResponse(new MockHttpResponseMessage(updateUserResponse)); + + var result = await ApiClient.PublishAsync(user, Cancel); + + result.AssertFailure(); + } + + [Fact] + public async Task UpdateFailsAsync() + { + var user = Create(); + + var addUserResponse = AutoFixture.CreateResponse(); + var updateUserResponse = AutoFixture.CreateErrorResponse(); + + MockHttpClient.SetupResponse(new MockHttpResponseMessage(addUserResponse)); + MockHttpClient.SetupResponse(new MockHttpResponseMessage(updateUserResponse)); + + var result = await ApiClient.PublishAsync(user, Cancel); + + result.AssertFailure(); + } + } + + #endregion } } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/ViewsApiClientTest.cs b/tests/Tableau.Migration.Tests/Unit/Api/ViewsApiClientTest.cs index c02eb1cf..efa51aad 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ViewsApiClientTest.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ViewsApiClientTest.cs @@ -16,76 +16,173 @@ // using System; +using System.Net; +using System.Net.Http; using System.Threading.Tasks; using Moq; using Tableau.Migration.Api; +using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Content.Permissions; +using Tableau.Migration.Tests.Unit.Api.Permissions; using Xunit; namespace Tableau.Migration.Tests.Unit.Api { - public class ViewsApiClientTest : AutoFixtureTestBase + public class ViewsApiClientTests { - internal readonly Mock MockPermissionsClient; + public class ViewsApiClientTest : PermissionsApiClientTestBase + { + #region - Test Helpers - + public void SetupGetPermissionsAsync(bool success, IPermissions? permissions = null) + { + var setup = MockPermissionsClient + .Setup(c => c.GetPermissionsAsync( + It.IsAny(), + Cancel)); - public ViewsApiClientTest() - { - MockPermissionsClient = new Mock(); - } + if (success) + { + Assert.NotNull(permissions); - #region - Test Helpers - + setup.Returns( + Task.FromResult>( + Result.Create(Result.Succeeded(), permissions))); + return; + } - public void SetupGetPermissionsAsync(bool success, IPermissions? permissions = null) - { - var setup = MockPermissionsClient - .Setup(c => c.Permissions.GetPermissionsAsync( - It.IsAny(), - Cancel)); + setup.Returns(Task.FromResult>(Result.Failed(new Exception()))); + } - if (success) + public void VerifyGetPermissionsAsync(Times times) { - Assert.NotNull(permissions); - - setup.Returns( - Task.FromResult>( - Result.Create(Result.Succeeded(), permissions))); - return; + MockPermissionsClient + .Verify(c => c.GetPermissionsAsync( + It.IsAny(), + Cancel), + times); } - setup.Returns(Task.FromResult>(Result.Failed(new Exception()))); - } + #endregion - public void VerifyGetPermissionsAsync(Times times) - { - MockPermissionsClient - .Verify(c => c.Permissions.GetPermissionsAsync( - It.IsAny(), - Cancel), - times); - } + #region - Get - - #endregion + public class GetViewAsync : ViewsApiClientTest + { + [Fact] + public async Task ErrorAsync() + { + var exception = new Exception(); - public class GetPermissionsAsync : ViewsApiClientTest - { - [Fact] - public async Task Success() + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.InternalServerError, null); + mockResponse.Setup(r => r.EnsureSuccessStatusCode()).Throws(exception); + MockHttpClient.SetupResponse(mockResponse); + + var contentId = Guid.NewGuid(); + + var result = await ApiClient.GetByIdAsync(contentId, Cancel); + + result.AssertFailure(); + + var resultError = Assert.Single(result.Errors); + Assert.Same(exception, resultError); + + MockHttpClient.AssertSingleRequest(r => + { + r.AssertHttpMethod(HttpMethod.Get); + }); + } + + [Fact] + public async Task FailureResponseAsync() + { + var mockResponse = new MockHttpResponseMessage(HttpStatusCode.NotFound, null); + MockHttpClient.SetupResponse(mockResponse); + + var contentId = Guid.NewGuid(); + + var result = await ApiClient.GetByIdAsync(contentId, Cancel); + + result.AssertFailure(); + + Assert.Null(result.Value); + Assert.Single(result.Errors); + + MockHttpClient.AssertSingleRequest(r => + { + r.AssertHttpMethod(HttpMethod.Get); + }); + } + + [Fact] + public async Task SuccessAsync() + { + // Arrange + // View Response + var viewResponse = AutoFixture.CreateResponse(); + viewResponse.Item!.Project = Create(); + viewResponse.Item!.Workbook = Create(); + + // The mock workbook of the view + var workbook = Create(); + MockWorkbookFinder.Setup(x => x.FindByIdAsync(viewResponse.Item.Workbook.Id, Cancel)) + .ReturnsAsync(workbook); + + // The mock project of the view + var project = Create(); + MockProjectFinder.Setup(x => x.FindByIdAsync(viewResponse.Item.Project.Id, Cancel)) + .ReturnsAsync(project); + + // The mock response message + var mockResponse = new MockHttpResponseMessage(viewResponse); + MockHttpClient.SetupResponse(mockResponse); + + var contentId = Guid.NewGuid(); + + // Act + var result = await ApiClient.GetByIdAsync( + contentId, + Cancel); + + // Assert + result.AssertSuccess(); + Assert.NotNull(result.Value); + + MockHttpClient.AssertSingleRequest(r => + { + r.AssertHttpMethod(HttpMethod.Get); + }); + + Assert.Same(workbook, result.Value.ParentWorkbook); + } + } + + #endregion - Get - + + #region - Permissions - + + public class GetPermissionsAsync : ViewsApiClientTest { - var sourcePermissions = Create(); - var destinationPermissions = Create(); + [Fact] + public async Task Success() + { + var sourcePermissions = Create(); + var destinationPermissions = Create(); - SetupGetPermissionsAsync(true, destinationPermissions); + SetupGetPermissionsAsync(true, destinationPermissions); - var result = await MockPermissionsClient.Object.Permissions.GetPermissionsAsync( - Guid.NewGuid(), - Cancel); + var result = await MockPermissionsClient.Object.GetPermissionsAsync( + Guid.NewGuid(), + Cancel); - Assert.True(result.Success); + Assert.True(result.Success); - // Get permissions is called once. - VerifyGetPermissionsAsync(Times.Once()); + // Get permissions is called once. + VerifyGetPermissionsAsync(Times.Once()); + } } + + #endregion } } } \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Content/AuthenticationConfigurationTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/AuthenticationConfigurationTests.cs new file mode 100644 index 00000000..5c44bd2e --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/AuthenticationConfigurationTests.cs @@ -0,0 +1,63 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Content +{ + public sealed class AuthenticationConfigurationTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void FromResponseItem() + { + var responseItem = Create(); + + var item = new AuthenticationConfiguration(responseItem); + + Assert.Equal(responseItem.AuthSetting, item.AuthSetting); + Assert.Equal(responseItem.KnownProviderAlias, item.KnownProviderAlias); + Assert.Equal(responseItem.IdpConfigurationName, item.IdpConfigurationName); + Assert.Equal(responseItem.IdpConfigurationId, item.Id); + Assert.Equal(responseItem.Enabled, item.Enabled); + Assert.Equal(responseItem.IdpConfigurationName, item.Location.ToString()); + } + + [Fact] + public void RequiresAuthSetting() + { + var responseItem = Create(); + responseItem.AuthSetting = null; + + Assert.Throws(() => new AuthenticationConfiguration(responseItem)); + } + + [Fact] + public void RequiresIdpConfigurationName() + { + var responseItem = Create(); + responseItem.IdpConfigurationName = null; + + Assert.Throws(() => new AuthenticationConfiguration(responseItem)); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/EmbeddedCredentialKeychainResultTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/EmbeddedCredentialKeychainResultTests.cs new file mode 100644 index 00000000..e1b03c9a --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/EmbeddedCredentialKeychainResultTests.cs @@ -0,0 +1,40 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Responses; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Content +{ + public class EmbeddedCredentialKeychainResultTests + { + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void FromApiResponse() + { + var response = Create(); + + var result = new EmbeddedCredentialKeychainResult(response); + + Assert.Equal(response.EncryptedKeychainList, result.EncryptedKeychains); + Assert.Equal(response.AssociatedUserLuidList, result.AssociatedUserIds); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Files/ContentFileHandleTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Files/ContentFileHandleTests.cs index e9c40d02..474d97fb 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Files/ContentFileHandleTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Files/ContentFileHandleTests.cs @@ -70,16 +70,15 @@ public class GetXmlStreamAsync : ContentFileHandleTest public async Task OpensFromStoreAsync() { var editorStream = Freeze(); - //var storeEditor = Freeze(); var stream = await Handle.GetXmlStreamAsync(Cancel); Assert.Same(editorStream, stream); - MockFileStore.Verify(x => x.GetTableauFileEditorAsync(Handle, Cancel, null), Times.Once); + MockFileStore.Verify(x => x.GetTableauFileEditorAsync(Handle, Cancel), Times.Once); } } - public class DisposAsync : ContentFileHandleTest + public class DisposeAsync : ContentFileHandleTest { [Fact] public async Task DeletesFromStoreAsync() @@ -98,5 +97,21 @@ public async Task DisposeTwiceCallDeleteOnce() MockFileStore.Verify(x => x.DeleteAsync(Handle, default), Times.Once); } } + + public class HasZipFilePath : ContentFileHandleTest + { + [Theory] + [InlineData("test.twbx", "", true)] + [InlineData("test.twb", "", false)] + [InlineData("", "test.twbx", true)] + [InlineData("", "test.twb", false)] + [InlineData("testtest", "test", null)] + public void ZipOriginalFilename(string path, string originalFileName, bool? expectedResult) + { + IContentFileHandle h = new ContentFileHandle(MockFileStore.Object, path, originalFileName, null); + Assert.Equal(expectedResult, h.HasZipFilePath); + } + + } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Files/DirectoryContentFileStoreTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Files/DirectoryContentFileStoreTests.cs index 21ef8422..ebdddbaf 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Files/DirectoryContentFileStoreTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Files/DirectoryContentFileStoreTests.cs @@ -49,11 +49,12 @@ public async Task InitializesAndTracksFileAsync() var relPath = Create(); var originalFileName = Create(); - await using var file = FileStore.Create(relPath, originalFileName); + await using var file = FileStore.Create(relPath, originalFileName, true); Assert.Same(FileStore, file.Store); Assert.Equal(Path.Combine(ExpectedBasePath, relPath), file.Path); Assert.Equal(originalFileName, file.OriginalFileName); + Assert.True(file.IsZipFile); Assert.Contains(file.Path, TrackedFilePaths); } @@ -68,11 +69,12 @@ public async Task InitializesAndTracksFileWithContentItemAsync() MockPathResolver.Setup(x => x.ResolveRelativePath(contentItem, originalFileName)) .Returns(generatedPath); - await using var file = FileStore.Create(contentItem, originalFileName); + await using var file = FileStore.Create(contentItem, originalFileName, true); Assert.Same(FileStore, file.Store); Assert.Equal(Path.Combine(ExpectedBasePath, generatedPath), file.Path); Assert.Equal(originalFileName, file.OriginalFileName); + Assert.True(file.IsZipFile); Assert.Contains(file.Path, TrackedFilePaths); diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileHandleTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileHandleTests.cs index a091e1dc..2d50464d 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileHandleTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileHandleTests.cs @@ -35,6 +35,7 @@ public void CopiesInnerValues() var handle = new EncryptedFileHandle(mockStore.Object, mockInner.Object); Assert.Equal(mockInner.Object.Path, handle.Path); Assert.Equal(mockInner.Object.OriginalFileName, handle.OriginalFileName); + Assert.Equal(mockInner.Object.IsZipFile, handle.IsZipFile); } } diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileStoreTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileStoreTests.cs index 6a28796b..246ac461 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileStoreTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Files/EncryptedFileStoreTests.cs @@ -104,14 +104,14 @@ public void UsesInnerStore() var path = Create(); var originalFileName = Create(); - var handle = FileStore.Create(path, originalFileName); + var handle = FileStore.Create(path, originalFileName, true); var encryptedHandle = Assert.IsType(handle); Assert.Equal(path, encryptedHandle.Path); Assert.Equal(originalFileName, encryptedHandle.OriginalFileName); - MockInnerFileStore.Verify(x => x.Create(path, originalFileName), Times.Once); + MockInnerFileStore.Verify(x => x.Create(path, originalFileName, true), Times.Once); } [Fact] @@ -120,13 +120,13 @@ public void UsesInnerStoreWithContentItem() var contentItem = Create(); var originalFileName = Create(); - var handle = FileStore.Create(contentItem, originalFileName); + var handle = FileStore.Create(contentItem, originalFileName, true); var encryptedHandle = Assert.IsType(handle); Assert.Equal(originalFileName, encryptedHandle.OriginalFileName); - MockInnerFileStore.Verify(x => x.Create(contentItem, originalFileName), Times.Once); + MockInnerFileStore.Verify(x => x.Create(contentItem, originalFileName, true), Times.Once); } } @@ -139,7 +139,7 @@ public class DeleteAsync : EncryptedFileStoreTest [Fact] public async Task CallsInnerStoreAsync() { - var innerHandle = new ContentFileHandle(MockInnerFileStore.Object, Create(), Create()); + var innerHandle = new ContentFileHandle(MockInnerFileStore.Object, Create(), Create(), null); await FileStore.DeleteAsync(innerHandle, Cancel); @@ -159,11 +159,11 @@ public async Task CallsInnerStoreAsync() var path = Create(); await using var file = FileStore.Create(path, Create()); - var innerHandle = new ContentFileHandle(MockInnerFileStore.Object, path, Create()); + var innerHandle = new ContentFileHandle(MockInnerFileStore.Object, path, Create(), null); await FileStore.GetTableauFileEditorAsync(innerHandle, Cancel); - MockInnerFileStore.Verify(x => x.GetTableauFileEditorAsync(innerHandle, Cancel, null), Times.Once); + MockInnerFileStore.Verify(x => x.GetTableauFileEditorAsync(innerHandle, Cancel), Times.Once); } } @@ -176,7 +176,7 @@ public class CloseTableauFileEditorAsync : EncryptedFileStoreTest [Fact] public async Task CallsInnerStoreAsync() { - var innerHandle = new ContentFileHandle(MockInnerFileStore.Object, Create(), Create()); + var innerHandle = new ContentFileHandle(MockInnerFileStore.Object, Create(), Create(), null); await FileStore.CloseTableauFileEditorAsync(innerHandle, Cancel); diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Files/IContentFileStoreTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Files/IContentFileStoreTests.cs index 07c7db2d..d0226eb6 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Files/IContentFileStoreTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Files/IContentFileStoreTests.cs @@ -41,8 +41,8 @@ public async Task CreatesAndWritesAsync() CallBase = true }; - var handle = new ContentFileHandle(mockFileStore.Object, path, originalFileName); - mockFileStore.Setup(x => x.Create(path, originalFileName)).Returns(handle); + var handle = new ContentFileHandle(mockFileStore.Object, path, originalFileName, null); + mockFileStore.Setup(x => x.Create(path, originalFileName, true)).Returns(handle); var writeStream = new MemoryStream(); mockFileStore.Setup(x => x.OpenWriteAsync(handle, cancel)) @@ -50,9 +50,9 @@ public async Task CreatesAndWritesAsync() using var initialStream = new MemoryStream(Constants.DefaultEncoding.GetBytes(content)); - var result = await mockFileStore.Object.CreateAsync(path, originalFileName, initialStream, cancel); + var result = await mockFileStore.Object.CreateAsync(path, originalFileName, initialStream, cancel, true); - mockFileStore.Verify(x => x.Create(path, originalFileName), Times.Once); + mockFileStore.Verify(x => x.Create(path, originalFileName, true), Times.Once); mockFileStore.Verify(x => x.OpenWriteAsync(handle, cancel), Times.Once); Assert.Equal(content, Constants.DefaultEncoding.GetString(writeStream.ToArray())); @@ -71,8 +71,8 @@ public async Task CreatesAndWritesContentItemAsync() CallBase = true }; - var handle = new ContentFileHandle(mockFileStore.Object, "generatedPath", originalFileName); - mockFileStore.Setup(x => x.Create(contentItem, originalFileName)).Returns(handle); + var handle = new ContentFileHandle(mockFileStore.Object, "generatedPath", originalFileName, null); + mockFileStore.Setup(x => x.Create(contentItem, originalFileName, true)).Returns(handle); var writeStream = new MemoryStream(); mockFileStore.Setup(x => x.OpenWriteAsync(handle, cancel)) @@ -80,9 +80,9 @@ public async Task CreatesAndWritesContentItemAsync() using var initialStream = new MemoryStream(Constants.DefaultEncoding.GetBytes(content)); - var result = await mockFileStore.Object.CreateAsync(contentItem, originalFileName, initialStream, cancel); + var result = await mockFileStore.Object.CreateAsync(contentItem, originalFileName, initialStream, cancel, true); - mockFileStore.Verify(x => x.Create(contentItem, originalFileName), Times.Once); + mockFileStore.Verify(x => x.Create(contentItem, originalFileName, true), Times.Once); mockFileStore.Verify(x => x.OpenWriteAsync(handle, cancel), Times.Once); Assert.Equal(content, Constants.DefaultEncoding.GetString(writeStream.ToArray())); diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Files/MemoryContentFileStore.cs b/tests/Tableau.Migration.Tests/Unit/Content/Files/MemoryContentFileStore.cs index 3af10e4b..b150655a 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Files/MemoryContentFileStore.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Files/MemoryContentFileStore.cs @@ -45,14 +45,14 @@ internal MemoryContentFileStore() public byte[] GetFileData(string path) => _fileData[path]; - public virtual IContentFileHandle Create(string relativePath, string originalFileName) + public virtual IContentFileHandle Create(string relativePath, string originalFileName, bool? zipFormatOverride = null) { _fileData.GetOrAdd(relativePath, Array.Empty()); - return new ContentFileHandle(this, relativePath, originalFileName); + return new ContentFileHandle(this, relativePath, originalFileName, zipFormatOverride); } - public virtual IContentFileHandle Create(TContent contentItem, string originalFileName) - => Create(Guid.NewGuid().ToString(), originalFileName); + public virtual IContentFileHandle Create(TContent contentItem, string originalFileName, bool? zipFormatOverride = null) + => Create(Guid.NewGuid().ToString(), originalFileName, zipFormatOverride); public virtual Task DeleteAsync(IContentFileHandle handle, CancellationToken cancel) { @@ -82,9 +82,9 @@ public virtual Task OpenWriteAsync(IContentFileHandle handle } public virtual async Task GetTableauFileEditorAsync(IContentFileHandle handle, - CancellationToken cancel, bool? zipFormatOverride = null) + CancellationToken cancel) => await _editors.GetOrAddAsync(handle.Path, async (path) => - await TableauFileEditor.OpenAsync(handle, _memoryStreamManager, cancel, zipFormatOverride).ConfigureAwait(false)).ConfigureAwait(false); + await TableauFileEditor.OpenAsync(handle, _memoryStreamManager, cancel).ConfigureAwait(false)).ConfigureAwait(false); public virtual async Task CloseTableauFileEditorAsync(IContentFileHandle handle, CancellationToken cancel) { diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Files/TableauFileEditorTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Files/TableauFileEditorTests.cs index b476482a..2b291ac7 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Files/TableauFileEditorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Files/TableauFileEditorTests.cs @@ -49,6 +49,7 @@ public TableauFileEditorTest() WrittenFileData = MemoryStreamManager.GetStream(); MockWriteFileStream = CreateTestFileStream(WrittenFileData); + MockFile.SetupGet(x => x.IsZipFile).Returns((bool?)null); MockFile.Setup(x => x.OpenWriteAsync(Cancel)) .ReturnsAsync((CancellationToken c) => { @@ -202,7 +203,30 @@ public async Task ZipReturnsXmlEntryFileStream() public class OpenAsync : TableauFileEditorTest { [Fact] - public async Task OpensXmlFileAsync() + public async Task OpensXmlFileFromFileOverrideAsync() + { + var dataStream = CreateMemoryStream(TEST_XML); + + var mockFileStream = CreateTestFileStream(dataStream); + + MockFile.SetupGet(x => x.IsZipFile).Returns(false); + MockFile.Setup(x => x.OpenReadAsync(Cancel)) + .ReturnsAsync(mockFileStream.Object); + + await using var editor = await TableauFileEditor.OpenAsync(MockFile.Object, MemoryStreamManager, Cancel); + + Assert.NotSame(dataStream, editor.Content); + Assert.Equal(0, editor.Content.Position); //stream is ready to read. + + Assert.Equal(dataStream.ToArray(), editor.Content.ToArray()); + + mockFileStream.Verify(x => x.DisposeAsync(), Times.Once); + + Assert.Null(editor.Archive); + } + + [Fact] + public async Task OpensXmlFileFromFileNameAsync() { var dataStream = CreateMemoryStream(TEST_XML); @@ -225,7 +249,52 @@ public async Task OpensXmlFileAsync() } [Fact] - public async Task OpensZipFileAsync() + public async Task OpensXmlFileFromStreamDetectionAsync() + { + var dataStream = CreateMemoryStream(TEST_XML); + + var mockFileStream = CreateTestFileStream(dataStream); + + MockFile.Setup(x => x.OpenReadAsync(Cancel)) + .ReturnsAsync(mockFileStream.Object); + + await using var editor = await TableauFileEditor.OpenAsync(MockFile.Object, MemoryStreamManager, Cancel); + + Assert.NotSame(dataStream, editor.Content); + Assert.Equal(0, editor.Content.Position); //stream is ready to read. + + Assert.Equal(dataStream.ToArray(), editor.Content.ToArray()); + + mockFileStream.Verify(x => x.DisposeAsync(), Times.Once); + + Assert.Null(editor.Archive); + } + + [Fact] + public async Task OpensZipFileFromFileOverrideAsync() + { + var data = BundleXmlIntoZipFile(TEST_XML); + var dataStream = CreateMemoryStream(data); + + var mockFileStream = CreateTestFileStream(dataStream); + + MockFile.SetupGet(x => x.IsZipFile).Returns(true); + MockFile.Setup(x => x.OpenReadAsync(Cancel)) + .ReturnsAsync(mockFileStream.Object); + + await using var editor = await TableauFileEditor.OpenAsync(MockFile.Object, MemoryStreamManager, Cancel); + + Assert.NotSame(dataStream, editor.Content); + Assert.Equal(dataStream.ToArray(), editor.Content.ToArray()); + + mockFileStream.Verify(x => x.DisposeAsync(), Times.Once); + + Assert.NotNull(editor.Archive); + Assert.Equal(ZipArchiveMode.Update, editor.Archive.Mode); + } + + [Fact] + public async Task OpensZipFileFromFileNameAsync() { var data = BundleXmlIntoZipFile(TEST_XML); var dataStream = CreateMemoryStream(data); @@ -246,6 +315,28 @@ public async Task OpensZipFileAsync() Assert.NotNull(editor.Archive); Assert.Equal(ZipArchiveMode.Update, editor.Archive.Mode); } + + [Fact] + public async Task OpensZipFileFromStreamDetectionAsync() + { + var data = BundleXmlIntoZipFile(TEST_XML); + var dataStream = CreateMemoryStream(data); + + var mockFileStream = CreateTestFileStream(dataStream); + + MockFile.Setup(x => x.OpenReadAsync(Cancel)) + .ReturnsAsync(mockFileStream.Object); + + await using var editor = await TableauFileEditor.OpenAsync(MockFile.Object, MemoryStreamManager, Cancel); + + Assert.NotSame(dataStream, editor.Content); + Assert.Equal(dataStream.ToArray(), editor.Content.ToArray()); + + mockFileStream.Verify(x => x.DisposeAsync(), Times.Once); + + Assert.NotNull(editor.Archive); + Assert.Equal(ZipArchiveMode.Update, editor.Archive.Mode); + } } #endregion diff --git a/tests/Tableau.Migration.Tests/Unit/Content/IConnectionsContentTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/IConnectionsContentTests.cs new file mode 100644 index 00000000..3e4ec23a --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/IConnectionsContentTests.cs @@ -0,0 +1,167 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Immutable; +using Moq; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Content +{ + public class IConnectionsContentTests + { + public abstract class IConnectionsContentTest : AutoFixtureTestBase + { + protected static IConnectionsContent CreateConnectionsContent(params IConnection[] connections) + { + var mockContent = new Mock() { CallBase = true }; + mockContent.SetupGet(c => c.Connections).Returns(ImmutableArray.Create(connections)); + return mockContent.Object; + } + } + + public class HasEmbeddedPassword : IConnectionsContentTest + { + protected IConnection CreateConnection(bool embedPassword) + => Create>(m => + { + m.SetupGet(c => c.EmbedPassword).Returns(embedPassword); + }) + .Object; + + [Fact] + public void Returns_true_when_any_EmbedPassword_is_true() + { + var content = CreateConnectionsContent( + CreateConnection(true)); + + Assert.True(content.HasEmbeddedPassword); + } + + [Fact] + public void Returns_false_when_connections_empty() + { + var content = CreateConnectionsContent(); + + Assert.False(content.HasEmbeddedPassword); + } + + [Fact] + public void Returns_false_when_EmbedPassword_is_false() + { + var content = CreateConnectionsContent( + CreateConnection(false)); + + Assert.False(content.HasEmbeddedPassword); + } + } + + public class HasEmbeddedOAuthManagedKeychain : IConnectionsContentTest + { + protected IConnection CreateConnection(bool embedPassword, bool useOAuthManagedKeychain) + => Create>(m => + { + m.SetupGet(c => c.EmbedPassword).Returns(embedPassword); + m.SetupGet(c => c.UseOAuthManagedKeychain).Returns(useOAuthManagedKeychain); + }) + .Object; + + [Fact] + public void Returns_true_when_any_EmbedPassword_is_true_and_UseOAuthManagedKeychain_is_true() + { + var content = CreateConnectionsContent( + CreateConnection(true, true), + CreateConnection(false, false)); + + Assert.True(content.HasEmbeddedOAuthManagedKeychain); + } + + [Fact] + public void Returns_false_when_connections_empty() + { + var content = CreateConnectionsContent(); + + Assert.False(content.HasEmbeddedOAuthManagedKeychain); + } + + [Fact] + public void Returns_false_when_EmbedPassword_is_false_and_UseOAuthManagedKeychain_is_true() + { + var content = CreateConnectionsContent( + CreateConnection(false, true)); + + Assert.False(content.HasEmbeddedOAuthManagedKeychain); + } + + [Fact] + public void Returns_false_when_EmbedPassword_is_true_and_UseOAuthManagedKeychain_is_false() + { + var content = CreateConnectionsContent( + CreateConnection(true, false)); + + Assert.False(content.HasEmbeddedOAuthManagedKeychain); + } + } + + public class HasEmbeddedOAuthCredentials : IConnectionsContentTest + { + protected IConnection CreateConnection(bool embedPassword, string? authenticationType) + => Create>(m => + { + m.SetupGet(c => c.EmbedPassword).Returns(embedPassword); + m.SetupGet(c => c.AuthenticationType).Returns(authenticationType); + }) + .Object; + + [Fact] + public void Returns_true_when_any_EmbedPassword_is_true_and_UseOAuthManagedKeychain_is_true() + { + var content = CreateConnectionsContent( + CreateConnection(true, "oauth"), + CreateConnection(false, "not-oauth")); + + Assert.True(content.HasEmbeddedOAuthCredentials); + } + + [Fact] + public void Returns_false_when_connections_empty() + { + var content = CreateConnectionsContent(); + + Assert.False(content.HasEmbeddedOAuthCredentials); + } + + [Fact] + public void Returns_false_when_EmbedPassword_is_false_and_UseOAuthManagedKeychain_is_OAuth() + { + var content = CreateConnectionsContent( + CreateConnection(false, "oauth")); + + Assert.False(content.HasEmbeddedOAuthCredentials); + } + + [Fact] + public void Returns_false_when_EmbedPassword_is_true_and_AuthenticationType_is_not_OAuth() + { + var content = CreateConnectionsContent( + CreateConnection(true, "not-oauth")); + + Assert.False(content.HasEmbeddedOAuthCredentials); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/IUserTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/IUserTests.cs new file mode 100644 index 00000000..ea2e766a --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/IUserTests.cs @@ -0,0 +1,55 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Content +{ + public sealed class IUserTests + { + public sealed class AuthenticationType : AutoFixtureTestBase + { + [Fact] + public void SetsAuthType() + { + IUser user = new User(Create()); + + var type = Create(); + user.AuthenticationType = type; + + Assert.Null(user.Authentication.IdpConfigurationId); + Assert.Equal(type, user.Authentication.AuthenticationType); + Assert.Equal(user.AuthenticationType, user.Authentication.AuthenticationType); + } + + [Fact] + public void NullDoesNotOverwriteIdpId() + { + IUser user = new User(Create()); + + var id = user.Authentication.IdpConfigurationId; + user.AuthenticationType = null; + + Assert.Equal(id, user.Authentication.IdpConfigurationId); + Assert.Null(user.Authentication.AuthenticationType); + Assert.Null(user.AuthenticationType); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs index 60dbabf6..2e654d74 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs @@ -30,6 +30,13 @@ namespace Tableau.Migration.Tests.Unit.Content.Schedules.Cloud { public class CloudScheduleValidatorTests { + protected const string TIME_12_00_00 = "12:00:00"; + protected const string TIME_13_00_00 = "13:00:00"; + protected const string TIME_13_30_00 = "13:30:00"; + protected const string TIME_12_00 = "12:00"; + protected const string TIME_13_00 = "13:00"; + protected const string WEEKDAY_INVALID = "Thanksgiving"; + private readonly Mock> _loggerMock; private readonly ISharedResourcesLocalizer _localizer; private readonly CloudScheduleValidator _validator; @@ -76,11 +83,11 @@ public void Validate_DoesNotThrow() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; // Act & Assert - var schedule = CreateMockSchedule(frequency, start: "12:00", end: "13:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00, end: TIME_13_00, intervals); _validator.Validate(schedule.Object); } @@ -91,10 +98,10 @@ public void Validate_MissingStart() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: null, end: "13:00", new List { new Mock().Object }); + var schedule = CreateMockSchedule(frequency, start: null, end: TIME_13_00, new List { new Mock().Object }); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -108,10 +115,10 @@ public void Validate_MissingEnd() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: null, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -125,10 +132,10 @@ public void Validate_InvalidStartEndDifference() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:30:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_30_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -143,10 +150,10 @@ public void Validate_HoursAndMinutesSet() { Interval.WithHours(1), Interval.WithMinutes(60), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -160,10 +167,10 @@ public void Validate_InvalidHourSet() var intervals = new List() { Interval.WithHours(2), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -177,10 +184,10 @@ public void Validate_MinutesSet() var intervals = new List() { Interval.WithMinutes(30), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -196,7 +203,7 @@ public void Validate_NoWeekdaySet() Interval.WithHours(1), }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -210,10 +217,10 @@ public void Validate_InvalidWeekday() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Thanksgiving") + Interval.WithWeekday(WEEKDAY_INVALID) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -232,11 +239,11 @@ public void Validate_DoesNotThrow() var intervals = new List() { Interval.WithHours(2), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; // Act & Assert - var schedule = CreateMockSchedule(frequency, start: "12:00", end: "13:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00, end: TIME_13_00, intervals); _validator.Validate(schedule.Object); } @@ -247,10 +254,10 @@ public void Validate_MissingStart() var intervals = new List() { Interval.WithHours(2), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: null, end: "13:00", new List { new Mock().Object }); + var schedule = CreateMockSchedule(frequency, start: null, end: TIME_13_00, new List { new Mock().Object }); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -266,7 +273,7 @@ public void Validate_NoWeekdaySet() Interval.WithHours(2), }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -280,10 +287,10 @@ public void Validate_InvalidWeekday() var intervals = new List() { Interval.WithHours(2), - Interval.WithWeekday("Thanksgiving") + Interval.WithWeekday(WEEKDAY_INVALID) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -297,10 +304,10 @@ public void Validate_InvalidHour() var intervals = new List() { Interval.WithHours(3), - Interval.WithWeekday("Tuesday") + Interval.WithWeekday(WeekDays.Tuesday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -314,10 +321,10 @@ public void Validate_MissingEnd() var intervals = new List() { Interval.WithHours(2), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: null, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -334,10 +341,10 @@ public void Validate_MissingEnd_With24Hours() var intervals = new List() { Interval.WithHours(24), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: null, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -351,10 +358,10 @@ public void Validate_InvalidStartEndDifference() var intervals = new List() { Interval.WithHours(2), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:30:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_30_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -372,11 +379,11 @@ public void Validate_DoesNotThrow() // Arrange var intervals = new List() { - Interval.WithWeekday("Wednesday") + Interval.WithWeekday(WeekDays.Wednesday) }; // Act & Assert - var schedule = CreateMockSchedule(frequency, start: "12:00", end: null, intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00, end: null, intervals); _validator.Validate(schedule.Object); } @@ -386,11 +393,11 @@ public void Validate_InvalidWeekdayCount() // Arrange var intervals = new List() { - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Wednesday") + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Wednesday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: null, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -404,10 +411,10 @@ public void Validate_InvalidWeekday() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Thanksgiving") + Interval.WithWeekday(WEEKDAY_INVALID) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: null, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); @@ -421,10 +428,10 @@ public void Validate_HasEndTime() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; - var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + var schedule = CreateMockSchedule(frequency, start: TIME_12_00_00, end: TIME_13_00_00, intervals); // Act & Assert var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs index dbcb0ad9..c6871209 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs @@ -76,7 +76,7 @@ public void Validate_DoesNotThrow() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); @@ -92,7 +92,7 @@ public void Validate_MissingStart() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; var schedule = CreateMockSchedule(frequency, start: null, end: "13:00:00", intervals); @@ -109,7 +109,7 @@ public void Validate_MissingEnd() var intervals = new List() { Interval.WithHours(1), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); @@ -156,7 +156,7 @@ public void Validate_InvalidHour() var intervals = new List() { Interval.WithHours(3), - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); @@ -219,7 +219,7 @@ public void Validate_DoesNotThrow() // Arrange var intervals = new List() { - Interval.WithWeekday("Monday") + Interval.WithWeekday(WeekDays.Monday) }; var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ServerToCloudExtractRefreshTaskConverterTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ServerToCloudExtractRefreshTaskConverterTests.cs deleted file mode 100644 index 7c22cf2d..00000000 --- a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ServerToCloudExtractRefreshTaskConverterTests.cs +++ /dev/null @@ -1,391 +0,0 @@ -// -// Copyright (c) 2025, Salesforce, Inc. -// SPDX-License-Identifier: Apache-2 -// -// Licensed under the Apache License, Version 2.0 (the "License") -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -using System; -using System.Collections.Generic; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Moq; -using Tableau.Migration.Content.Schedules; -using Tableau.Migration.Content.Schedules.Cloud; -using Tableau.Migration.Content.Schedules.Server; -using Tableau.Migration.Resources; -using Xunit; - -namespace Tableau.Migration.Tests.Unit.Content.Schedules -{ - public class ServerToCloudExtractRefreshTaskConverterTests - { - private readonly ServerToCloudExtractRefreshTaskConverter _converter; - private readonly Mock> _mockServerScheduleValidatorLogger; - private readonly Mock> _mockCloudScheduleValidatorLogger; - private readonly Mock> _mockConverterLogger; - private readonly ISharedResourcesLocalizer _localizer; - - //private static readonly List> ValidTasks = new List>(); - public static TheoryData ValidTasks = new(); - - /// - /// Static constructor to create the test data - /// - static ServerToCloudExtractRefreshTaskConverterTests() - { - CreateValidExtractTasks(); - } - - /// - /// Non-Static constructor to build the test object - /// - public ServerToCloudExtractRefreshTaskConverterTests() - { - // Create the real localizer - var services = new ServiceCollection(); - services.AddTableauMigrationSdk(); - var container = services.BuildServiceProvider(); - - _mockServerScheduleValidatorLogger = new Mock>(); - _mockCloudScheduleValidatorLogger = new Mock>(); - _mockConverterLogger = new Mock>(); - _localizer = container.GetRequiredService(); - - var serverValidator = new ServerScheduleValidator(_mockServerScheduleValidatorLogger.Object, _localizer); - var cloudValidator = new CloudScheduleValidator(_mockCloudScheduleValidatorLogger.Object, _localizer); - _converter = new ServerToCloudExtractRefreshTaskConverter(serverValidator, cloudValidator, _mockConverterLogger.Object, _localizer); - } - - - - - [Theory] - [MemberData(nameof(ValidTasks))] - public void ValidSchedule(IServerExtractRefreshTask input, ICloudExtractRefreshTask expectedCloudExtractTask) - { - // Act - var result = _converter.Convert(input); - - // Assert - Assert.Equal(expectedCloudExtractTask.Schedule, result.Schedule, new ScheduleComparers()); - } - - #region - Helper methods - - private static void CreateValidExtractTasks() - { - // Hourly - - // 1 - hourly on the 30, turns into hourly with all weekdays set - ValidTasks.Add( - CreateServerExtractTask("Hourly", "00:30:00", "23:30:00", - [ - Interval.WithHours(1) - ]), - CreateCloudExtractTask("Hourly", "00:30:00", "23:30:00", - [ - Interval.WithHours(1), - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - // 2 - Every 2 hours on the 30 turns into daily with all weekdays set - ValidTasks.Add( - CreateServerExtractTask("Hourly", "00:30:00", "00:30:00", - [ - Interval.WithHours(2) - ]), - CreateCloudExtractTask("Daily", "00:30:00", "00:30:00", - [ - Interval.WithHours(2), - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - // 3 - Every hour on :15 turns into hourly with all weekdays set - ValidTasks.Add( - CreateServerExtractTask("Hourly", "00:15:00", "23:15:00", - [ - Interval.WithHours(1) - ]), - CreateCloudExtractTask("Hourly", "00:15:00", "23:15:00", - [ - Interval.WithHours(1), - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - // 4 - Hourly every .5 hours on :00, :30 which can't be done. Turns into hourly every 60 minutes - ValidTasks.Add( - CreateServerExtractTask("Hourly", "00:00:00", "00:00:00", - [ - Interval.WithMinutes(30) - ]), - CreateCloudExtractTask("Hourly", "00:00:00", "00:00:00", - [ - Interval.WithMinutes(60), - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - // 5 - Hourly every 4 hours, turns into daily - ValidTasks.Add( - CreateServerExtractTask("Hourly", "03:45:00", "23:45:00", - [ - Interval.WithHours(4) - ]), - CreateCloudExtractTask("Daily", "03:45:00", "23:45:00", - [ - Interval.WithHours(4), - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - - // Daily - // Daily Server Schedule can not have any intervals. They are all trimmed by the RestAPI - - // 6 - Daily requires weekday intervals - ValidTasks.Add( - CreateServerExtractTask("Daily", "02:00:00", null, null), - CreateCloudExtractTask("Daily", "02:00:00", null, - [ - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - // 7 - Daily on the :30. End time must be removed from cloud because no hours intervals exist - ValidTasks.Add( - CreateServerExtractTask("Daily", "23:30:00", "00:30:00", null), - CreateCloudExtractTask("Daily", "23:30:00", null, - [ - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - // 8 - Daily on the :15. End time must be removed from cloud because no hours intervals exist - ValidTasks.Add( - CreateServerExtractTask("Daily", "12:15:00", "00:15:00", null), - CreateCloudExtractTask("Daily", "12:15:00", null, - [ - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - Interval.WithWeekday("Saturday"), - Interval.WithWeekday("Sunday") - ]) - ); - - // Weekly - // Weekly Server Schedule can not have end time. It is removed by the RestAPI - - // 9 - Weekly with more than 1 weekday is not allowed, must be daily - ValidTasks.Add( - CreateServerExtractTask("Weekly", "06:00:00", null, - [ - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - ]), - // This is what I expected base on docs - // But Daily can't have end time, without having a hours interval - //CreateCloudExtractTask("Daily", "06:00:00", "06:00:00", - //[ - // Interval.WithWeekday("Monday"), - // Interval.WithWeekday("Tuesday"), - // Interval.WithWeekday("Wednesday"), - // Interval.WithWeekday("Thursday"), - // Interval.WithWeekday("Friday"), - // Interval.WithHours(24) - //]) - CreateCloudExtractTask("Daily", "06:00:00", null, - [ - Interval.WithWeekday("Monday"), - Interval.WithWeekday("Tuesday"), - Interval.WithWeekday("Wednesday"), - Interval.WithWeekday("Thursday"), - Interval.WithWeekday("Friday"), - ]) - ); - - // 10 - Weekly on Saturday only - ValidTasks.Add( - CreateServerExtractTask("Weekly", "23:00:00", null, - [ - Interval.WithWeekday("Saturday") - ]), - CreateCloudExtractTask("Weekly", "23:00:00", null, - [ - Interval.WithWeekday("Saturday") - ]) - ); - - - // Monthly - // Monthly Server Schedule can not have end time. It is removed by the RestAPI - - // 11 - Last of the month - ValidTasks.Add( - CreateServerExtractTask("Monthly", "23:00:00", null, - [ - Interval.WithMonthDay("1") - ]), - CreateCloudExtractTask("Monthly", "23:00:00", null, - [ - Interval.WithMonthDay("1") - ]) - ); - - // 12 - First day of month - ValidTasks.Add( - CreateServerExtractTask("Monthly", "01:30:00", null, - [ - Interval.WithMonthDay("1") - ]), - CreateCloudExtractTask("Monthly", "01:30:00", null, - [ - Interval.WithMonthDay("1") - ]) - ); - - // 13 - Multiple days - ValidTasks.Add( - CreateServerExtractTask("Monthly", "13:55:00", null, - [ - Interval.WithMonthDay("1"), - Interval.WithMonthDay("2"), - Interval.WithMonthDay("3") - ]), - CreateCloudExtractTask("Monthly", "13:55:00", null, - [ - Interval.WithMonthDay("1"), - Interval.WithMonthDay("2"), - Interval.WithMonthDay("3") - ]) - ); - - - } - - private static IServerExtractRefreshTask CreateServerExtractTask(string frequency, string? start, string? end, IList? intervals) - { - if (intervals is null) - { - intervals = new List(); - } - - var mockFreqDetails = new Mock(); - mockFreqDetails.Setup(f => f.StartAt).Returns(ConvertStringToTimeOnly(start)); - mockFreqDetails.Setup(f => f.EndAt).Returns(ConvertStringToTimeOnly(end)); - mockFreqDetails.Setup(f => f.Intervals).Returns(intervals); - - var mockSchedule = new Mock(); - mockSchedule.Setup(s => s.Frequency).Returns(frequency); - mockSchedule.Setup(s => s.FrequencyDetails).Returns(mockFreqDetails.Object); - - var mockTask = new Mock(); - mockTask.Setup(t => t.Schedule).Returns((IServerSchedule)mockSchedule.Object); - - //return new ServerExtractRefreshTask(mockTask.Object.Id, mockTask.Object.Type, mockTask.Object.ContentType, mockTask.Object.Content, mockTask.Object.Schedule); - - return mockTask.Object; - } - - private static ICloudExtractRefreshTask CreateCloudExtractTask(string frequency, string? start, string? end, IList? intervals) - { - if (intervals is null) - { - intervals = new List(); - } - - //var mockFreqDetails = new Mock(); - //mockFreqDetails.Setup(f => f.StartAt).Returns(ConvertStringToTimeOnly(start)); - //mockFreqDetails.Setup(f => f.EndAt).Returns(ConvertStringToTimeOnly(end)); - //mockFreqDetails.Setup(f => f.Intervals).Returns(intervals); - - var freqDetails = new FrequencyDetails(ConvertStringToTimeOnly(start), ConvertStringToTimeOnly(end), intervals); - - //var mockSchedule = new Mock(); - //mockSchedule.Setup(s => s.Frequency).Returns(frequency); - //mockSchedule.Setup(s => s.FrequencyDetails).Returns(mockFreqDetails.Object); - - var cloudSchedule = new CloudSchedule(frequency, freqDetails); - - var mockTask = new Mock(); - mockTask.Setup(t => t.Schedule).Returns(cloudSchedule); - - // Returning concrete object so the ToString message works for debugging purposes - //return new CloudExtractRefreshTask(mockTask.Object.Id, mockTask.Object.Type, mockTask.Object.ContentType, mockTask.Object.Content, mockTask.Object.Schedule); - - return mockTask.Object; - } - - private static TimeOnly? ConvertStringToTimeOnly(string? input) - { - if (input is null) - { - return null; - } - - return TimeOnly.Parse(input); - } - #endregion - } - -} \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Content/UserAuthenticationTypeTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/UserAuthenticationTypeTests.cs new file mode 100644 index 00000000..421cdf4f --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/UserAuthenticationTypeTests.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Content; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Content +{ + public sealed class UserAuthenticationTypeTests + { + public sealed class Default + { + [Fact] + public void HasNullValues() + { + Assert.Null(UserAuthenticationType.Default.AuthenticationType); + Assert.Null(UserAuthenticationType.Default.IdpConfigurationId); + } + } + + public sealed class Ctor : AutoFixtureTestBase + { + [Fact] + public void AuthType() + { + var authType = Create(); + + var t = new UserAuthenticationType(authType, null); + + Assert.Equal(authType, t.AuthenticationType); + Assert.Null(t.IdpConfigurationId); + } + + [Fact] + public void IdpConfigurationId() + { + var id = Guid.NewGuid(); + + var t = new UserAuthenticationType(null, id); + + Assert.Equal(id, t.IdpConfigurationId); + Assert.Null(t.AuthenticationType); + } + } + + public sealed class ForAuthenticationType : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var authType = Create(); + + var t = UserAuthenticationType.ForAuthenticationType(authType); + + Assert.Equal(authType, t.AuthenticationType); + Assert.Null(t.IdpConfigurationId); + } + } + + public sealed class ForConfigurationId : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var id = Guid.NewGuid(); + + var t = UserAuthenticationType.ForConfigurationId(id); + + Assert.Equal(id, t.IdpConfigurationId); + Assert.Null(t.AuthenticationType); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/UserTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/UserTests.cs index 923a9ec1..2a9dd7eb 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/UserTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/UserTests.cs @@ -16,6 +16,8 @@ // using System; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Content; using Xunit; @@ -93,6 +95,16 @@ public void BuildsLocation() Assert.Equal(ContentLocation.ForUsername(response.Domain?.Name!, response.Name!), user.Location); } + [Fact] + public void ExposeFullAuthInfo() + { + var response = CreateTestResponse(); + response.IdpConfigurationId = Guid.NewGuid().ToString(); + var user = new User(response); + + Assert.Equal(response.GetAuthenticationType(), user.Authentication); + } + [Theory] [NullEmptyWhiteSpaceData] public void AuthTypeOptional(string? authSetting) @@ -102,7 +114,38 @@ public void AuthTypeOptional(string? authSetting) var user = new User(response); - Assert.Equal(authSetting, user.AuthenticationType); + Assert.Equal(authSetting, user.Authentication.AuthenticationType); + } + + [Fact] + public void IdpConfigurationId() + { + var id = Guid.NewGuid(); + + var response = CreateTestResponse(); + response.AuthSetting = null; + response.IdpConfigurationId = id.ToString(); + + var user = new User(response); + + Assert.Null(user.Authentication.AuthenticationType); + Assert.Equal(id, user.Authentication.IdpConfigurationId); + } + + [Fact] + public void UpdateUserResult() + { + var id = Guid.NewGuid(); + var result = Create(); + + var user = new User(id, result); + + Assert.Equal(id, user.Id); + Assert.Empty(user.Domain); + Assert.Equal(result.Email, user.Email); + Assert.Equal(result.FullName, user.FullName); + Assert.Equal(result.SiteRole, user.SiteRole); + Assert.Equal(result.Authentication, user.Authentication); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/ContentClients/ApiContentClientFactoryTests.cs b/tests/Tableau.Migration.Tests/Unit/ContentClients/ApiContentClientFactoryTests.cs new file mode 100644 index 00000000..a420856a --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/ContentClients/ApiContentClientFactoryTests.cs @@ -0,0 +1,87 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Api; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.ContentClients; +using Tableau.Migration.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.ContentClients +{ + public class ApiContentClientFactoryTests + + { + private readonly Mock _mockLoggerFactory; + private readonly Mock _mockLocalizer; + private readonly Mock _mockSitesApiClient; + private readonly ApiContentClientFactory _factory; + + public ApiContentClientFactoryTests() + { + _mockLoggerFactory = new Mock(); + _mockLocalizer = new Mock(); + _mockSitesApiClient = new Mock(); + _factory = new ApiContentClientFactory(_mockSitesApiClient.Object, _mockLoggerFactory.Object, _mockLocalizer.Object); + } + + [Fact] + public void GetContentClient_ReturnsWorkbookClient() + { + // Arrange + var mockWorkbooksApiClient = new Mock(); + _mockSitesApiClient.Setup(x => x.Workbooks).Returns(mockWorkbooksApiClient.Object); + + var mockLogger = new Mock>(); + _mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + + // Act + var client = _factory.GetContentClient(); + + // Assert + Assert.NotNull(client); + Assert.IsAssignableFrom(client); + } + + [Fact] + public void GetContentClient_ReturnsViewClient() + { + // Arrange + var mockViewsApiClient = new Mock(); + _mockSitesApiClient.Setup(x => x.Views).Returns(mockViewsApiClient.Object); + + var mockLogger = new Mock>(); + _mockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())).Returns(mockLogger.Object); + + // Act + var client = _factory.GetContentClient(); + + // Assert + Assert.NotNull(client); + Assert.IsAssignableFrom(client); + } + + [Fact] + public void GetContentClient_ThrowsInvalidOperationException_ForUnsupportedType() + { + Assert.Throws(() => _factory.GetContentClient()); + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/ContentClients/ViewsContentClientTests.cs b/tests/Tableau.Migration.Tests/Unit/ContentClients/ViewsContentClientTests.cs new file mode 100644 index 00000000..21c01a0f --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/ContentClients/ViewsContentClientTests.cs @@ -0,0 +1,80 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Api; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.ContentClients; +using Tableau.Migration.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.ContentClients +{ + public class ViewsContentClientTests + { + private readonly Mock _mockViewsApiClient; + private readonly Mock> _mockLogger; + private readonly Mock _mockLocalizer; + private readonly ViewsContentClient _viewsContentClient; + + public ViewsContentClientTests() + { + _mockViewsApiClient = new Mock(); + _mockLogger = new Mock>(); + _mockLocalizer = new Mock(); + _viewsContentClient = new ViewsContentClient(_mockViewsApiClient.Object, _mockLogger.Object, _mockLocalizer.Object); + } + + [Fact] + public async Task GetByIdAsync_Success() + { + // Arrange + var viewId = Guid.NewGuid(); + var mockView = new Mock(); + mockView.Setup(x => x.Id).Returns(viewId); + var viewResult = Result.Succeeded(Mock.Of()); + _mockViewsApiClient.Setup(x => x.GetByIdAsync(viewId, It.IsAny())).ReturnsAsync(viewResult); + + // Act + var result = await _viewsContentClient.GetByIdAsync(viewId, CancellationToken.None); + + // Assert + Assert.Equal(viewResult, result); + _mockViewsApiClient.Verify(x => x.GetByIdAsync(viewId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetByIdAsync_Error() + { + // Arrange + var viewId = Guid.NewGuid(); + var viewResult = Result.Failed(new Exception("Failed")); + _mockViewsApiClient.Setup(x => x.GetByIdAsync(viewId, It.IsAny())).ReturnsAsync(viewResult); + + // Act + var result = await _viewsContentClient.GetByIdAsync(viewId, CancellationToken.None); + + // Assert + Assert.Equal(viewResult, result); + _mockViewsApiClient.Verify(x => x.GetByIdAsync(viewId, It.IsAny()), Times.Once); + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/ContentClients/WorkbooksContentClientTests.cs b/tests/Tableau.Migration.Tests/Unit/ContentClients/WorkbooksContentClientTests.cs new file mode 100644 index 00000000..f1b625d6 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/ContentClients/WorkbooksContentClientTests.cs @@ -0,0 +1,86 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Api; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.ContentClients; +using Tableau.Migration.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.ContentClients +{ + public class WorkbooksContentClientTests + { + private readonly Mock _mockWorkbooksApiClient; + private readonly Mock> _mockLogger; + private readonly Mock _mockLocalizer; + private readonly WorkbooksContentClient _workbooksContentClient; + + public WorkbooksContentClientTests() + { + _mockWorkbooksApiClient = new Mock(); + _mockLogger = new Mock>(); + _mockLocalizer = new Mock(); + _workbooksContentClient = new WorkbooksContentClient(_mockWorkbooksApiClient.Object, _mockLogger.Object, _mockLocalizer.Object); + } + + [Fact] + public async Task GetViewsForWorkbookIdAsync_Success() + { + // Arrange + var workbookId = Guid.NewGuid(); + var mockWorkbook = new Mock(); + mockWorkbook.Setup(x => x.Id).Returns(workbookId); + var mockViews = ImmutableList.Create(Mock.Of()); + mockWorkbook.Setup(x => x.Views).Returns(mockViews); + + var workbookResult = Result.Succeeded(mockWorkbook.Object); + + _mockWorkbooksApiClient.Setup(x => x.GetWorkbookAsync(workbookId, It.IsAny())).ReturnsAsync(workbookResult); + + // Act + var result = await _workbooksContentClient.GetViewsForWorkbookIdAsync(workbookId, CancellationToken.None); + + // Assert + Assert.True(result.Success); + Assert.Equal(mockViews, result.Value); + _mockWorkbooksApiClient.Verify(x => x.GetWorkbookAsync(workbookId, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetViewsForWorkbookIdAsync_Error() + { + // Arrange + var workbookId = Guid.NewGuid(); + var workbookResult = Result.Failed(new Exception("Failed")); + _mockWorkbooksApiClient.Setup(x => x.GetWorkbookAsync(workbookId, It.IsAny())).ReturnsAsync(workbookResult); + + // Act + var result = await _workbooksContentClient.GetViewsForWorkbookIdAsync(workbookId, CancellationToken.None); + + // Assert + Assert.False(result.Success); + _mockWorkbooksApiClient.Verify(x => x.GetWorkbookAsync(workbookId, It.IsAny()), Times.Once); + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Actions/MigrateContentActionTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Actions/MigrateContentActionTests.cs index 3900cc9e..52680ff6 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Actions/MigrateContentActionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Actions/MigrateContentActionTests.cs @@ -17,10 +17,12 @@ using System; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Engine.Actions; using Tableau.Migration.Engine.Migrators; using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Actions @@ -33,18 +35,23 @@ public class MigrateContentActionTest : AutoFixtureTestBase { protected readonly Mock MockPipeline; protected readonly Mock> MockContentMigrator; + protected readonly Mock MockCapabilities; + protected readonly Mock>> MockLogger; + protected readonly Mock MockSharedResourcesLocalizer; protected readonly MigrateContentAction Action; public MigrateContentActionTest() { MockContentMigrator = Create>>(); - + MockCapabilities = Create>(); MockPipeline = Create>(); MockPipeline.Setup(x => x.GetMigrator()) .Returns(MockContentMigrator.Object); + MockLogger = new Mock>>(); + MockSharedResourcesLocalizer = new Mock(); - Action = new(MockPipeline.Object); + Action = new(MockPipeline.Object, MockCapabilities.Object, MockLogger.Object, MockSharedResourcesLocalizer.Object); } } @@ -88,6 +95,34 @@ public async Task MigratorFailsAsync() result.AssertFailure(); Assert.Equal(failure.Errors, result.Errors); } + + [Fact] + public async Task Skips_migration_when_disabled() + { + MockCapabilities.Setup(x => x.ContentTypesDisabledAtDestination) + .Returns([typeof(TestContentType)]); + + MockContentMigrator.Setup(x => x.MigrateAsync(Cancel)).ReturnsAsync(Result.Succeeded()); + + var result = await Action.ExecuteAsync(Cancel); + + result.AssertSuccess(); + MockLogger.VerifyWarnings(Times.Once); + } + + [Fact] + public async Task Migrates_when_not_disabled() + { + MockCapabilities.Setup(x => x.ContentTypesDisabledAtDestination) + .Returns([]); + + MockContentMigrator.Setup(x => x.MigrateAsync(Cancel)).ReturnsAsync(Result.Succeeded()); + + var result = await Action.ExecuteAsync(Cancel); + + result.AssertSuccess(); + MockLogger.VerifyWarnings(Times.Never); + } } #endregion diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/DirectContentItemConverterTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/DirectContentItemConverterTests.cs new file mode 100644 index 00000000..16b9d39c --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/DirectContentItemConverterTests.cs @@ -0,0 +1,64 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading.Tasks; +using Tableau.Migration.Engine.Conversion; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Conversion +{ + public sealed class DirectContentItemConverterTests + { + public sealed class ConvertAsync : AutoFixtureTestBase + { + [Fact] + public async Task ReturnsItemAsync() + { + var item = Create(); + + var converter = Create>(); + + var result = await converter.ConvertAsync(item, Cancel); + + Assert.Same(item, result); + } + + [Fact] + public async Task ValidCastAsync() + { + var item = Create(); + + var converter = Create>(); + + var result = await converter.ConvertAsync(item, Cancel); + + Assert.Same(item, result); + } + + [Fact] + public async Task InvalidCastAsync() + { + var item = Create(); + + var converter = Create>(); + + await Assert.ThrowsAsync(() => converter.ConvertAsync(item, Cancel)); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/ExtractRefreshTasks/ServerToCloudExtractRefreshTaskConverterTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/ExtractRefreshTasks/ServerToCloudExtractRefreshTaskConverterTests.cs new file mode 100644 index 00000000..5966ce43 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/ExtractRefreshTasks/ServerToCloudExtractRefreshTaskConverterTests.cs @@ -0,0 +1,104 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.ContentConverters.Schedules; +using Tableau.Migration.Engine.Conversion.Schedules; +using Tableau.Migration.Tests.Unit.Engine.Conversion.Schedules; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.ContentConverters.Schedules +{ + public class ServerToCloudExtractRefreshTaskConverterTests : AutoFixtureTestBase + { + private readonly ServerToCloudExtractRefreshTaskConverter _converter; + private readonly Mock> _mockScheduleConverter = new(); + + public static TheoryData ValidTasks = new(); + + /// + /// Static constructor to create the test data + /// + static ServerToCloudExtractRefreshTaskConverterTests() + { + CreateValidExtractTasks(); + } + + /// + /// Non-Static constructor to build the test object + /// + public ServerToCloudExtractRefreshTaskConverterTests() + { + // Create the real localizer + var services = new ServiceCollection(); + services.AddTableauMigrationSdk(); + var container = services.BuildServiceProvider(); + + _converter = new ServerToCloudExtractRefreshTaskConverter(_mockScheduleConverter.Object); + } + + [Theory] + [MemberData(nameof(ValidTasks))] + public async Task ValidConversionAsync(IServerExtractRefreshTask input, ICloudExtractRefreshTask expectedCloudExtractTask) + { + // Setup + _mockScheduleConverter.Setup(c => c.ConvertAsync(input.Schedule, Cancel)).ReturnsAsync(expectedCloudExtractTask.Schedule); + + // Act + var result = await _converter.ConvertAsync(input, Cancel); + + // Assert + Assert.Equal(expectedCloudExtractTask.Type, result.Type); + Assert.Equal(expectedCloudExtractTask.ContentType, result.ContentType); + Assert.Equal(expectedCloudExtractTask.Content, result.Content); + Assert.Equal(expectedCloudExtractTask.Schedule, result.Schedule, new ScheduleComparers()); + } + + #region - Helper Methods - + + private static void CreateValidExtractTasks() + { + foreach (var scheduleMapping in ServerToCloudScheduleConverterTests.CreateValidScheduleMappings()) + { + ValidTasks.Add(CreateServerExtractTask(scheduleMapping.Key), CreateCloudExtractTask(scheduleMapping.Value)); + } + } + + private static IServerExtractRefreshTask CreateServerExtractTask(IServerSchedule schedule) + { + var mockTask = new Mock(); + mockTask.Setup(t => t.Schedule).Returns(schedule); + + return mockTask.Object; + } + + private static ICloudExtractRefreshTask CreateCloudExtractTask(ICloudSchedule schedule) + { + var mockTask = new Mock(); + mockTask.Setup(t => t.Schedule).Returns(schedule); + + return mockTask.Object; + } + + #endregion + } +} \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/Schedules/ServerToCloudScheduleConverterTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/Schedules/ServerToCloudScheduleConverterTests.cs new file mode 100644 index 00000000..56cf81e3 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/Schedules/ServerToCloudScheduleConverterTests.cs @@ -0,0 +1,366 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.ContentConverters.Schedules; +using Tableau.Migration.Engine.Conversion.Schedules; +using Tableau.Migration.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Conversion.Schedules +{ + public class ServerToCloudScheduleConverterTests : AutoFixtureTestBase + { + private readonly Mock> _mockServerScheduleValidatorLogger; + private readonly Mock> _mockCloudScheduleValidatorLogger; + private readonly Mock> _mockConverterLogger; + private readonly Mock> _mockScheduleConverterLogger; + private readonly ISharedResourcesLocalizer _localizer; + + private readonly IScheduleConverter Converter; + public static TheoryData ValidSchedules = new(); + + /// + /// Static constructor to create the test data + /// + static ServerToCloudScheduleConverterTests() + { + CreateValidSchedules(); + } + + public ServerToCloudScheduleConverterTests() + { + CreateValidSchedules(); + + // Create the real localizer + var services = new ServiceCollection(); + services.AddTableauMigrationSdk(); + var container = services.BuildServiceProvider(); + + _mockServerScheduleValidatorLogger = new Mock>(); + _mockCloudScheduleValidatorLogger = new Mock>(); + _mockConverterLogger = new Mock>(); + _mockScheduleConverterLogger = new Mock>(); + _localizer = container.GetRequiredService(); + + var serverValidator = new ServerScheduleValidator(_mockServerScheduleValidatorLogger.Object, _localizer); + var cloudValidator = new CloudScheduleValidator(_mockCloudScheduleValidatorLogger.Object, _localizer); + Converter = new ServerToCloudScheduleConverter(serverValidator, cloudValidator, _mockScheduleConverterLogger.Object, _localizer); + } + + [Theory] + [MemberData(nameof(ValidSchedules))] + public async Task ValidScheduleAsync(IServerSchedule input, ICloudSchedule expectedOutput) + { + // Act + var result = await Converter.ConvertAsync(input, Cancel); + + // Assert + Assert.Equal(expectedOutput, result, new ScheduleComparers()); + } + + #region - Helper Methods - + + internal static void CreateValidSchedules() + { + foreach (var scheduleMapping in CreateValidScheduleMappings()) + { + ValidSchedules.Add(scheduleMapping.Key, scheduleMapping.Value); + } + } + + private static ICloudSchedule CreateCloudSchedule( + string frequency, + string? start, + string? end, + IList? intervals) + { + intervals ??= []; + + var freqDetails = new FrequencyDetails(ConvertStringToTimeOnly(start), ConvertStringToTimeOnly(end), intervals); + + var cloudSchedule = new CloudSchedule(frequency, freqDetails); + return cloudSchedule; + } + + private static IServerSchedule CreateServerSchedule( + string frequency, + string? start, + string? end, + IList? intervals) + { + intervals ??= []; + + var mockFreqDetails = new Mock(); + mockFreqDetails.Setup(f => f.StartAt).Returns(ConvertStringToTimeOnly(start)); + mockFreqDetails.Setup(f => f.EndAt).Returns(ConvertStringToTimeOnly(end)); + mockFreqDetails.Setup(f => f.Intervals).Returns(intervals); + + var mockSchedule = new Mock(); + mockSchedule.Setup(s => s.Frequency).Returns(frequency); + mockSchedule.Setup(s => s.FrequencyDetails).Returns(mockFreqDetails.Object); + return mockSchedule.Object; + } + + private static TimeOnly? ConvertStringToTimeOnly(string? input) + => input is null ? null : TimeOnly.Parse(input); + + internal static Dictionary CreateValidScheduleMappings() => new() + { + // Hourly + + // 1 - hourly on the 30, turns into hourly with all weekdays set + { + CreateServerSchedule(ScheduleFrequencies.Hourly, "00:30:00", "23:30:00", + [ + Interval.WithHours(1) + ]), + CreateCloudSchedule(ScheduleFrequencies.Hourly, "00:30:00", "23:30:00", + [ + Interval.WithHours(1), + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + // 2 - Every 2 hours on the 30 turns into daily with all weekdays set + { + CreateServerSchedule(ScheduleFrequencies.Hourly, "00:30:00", "00:30:00", + [ + Interval.WithHours(2) + ]), + CreateCloudSchedule(ScheduleFrequencies.Daily, "00:30:00", "00:30:00", + [ + Interval.WithHours(2), + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + // 3 - Every hour on :15 turns into hourly with all weekdays set + { + CreateServerSchedule(ScheduleFrequencies.Hourly, "00:15:00", "23:15:00", + [ + Interval.WithHours(1) + ]), + CreateCloudSchedule(ScheduleFrequencies.Hourly, "00:15:00", "23:15:00", + [ + Interval.WithHours(1), + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + // 4 - Hourly every .5 hours on :00, :30 which can't be done. Turns into hourly every 60 minutes + { + CreateServerSchedule(ScheduleFrequencies.Hourly, "00:00:00", "00:00:00", + [ + Interval.WithMinutes(30) + ]), + CreateCloudSchedule(ScheduleFrequencies.Hourly, "00:00:00", "00:00:00", + [ + Interval.WithMinutes(60), + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + // 5 - Hourly every 4 hours, turns into daily + { + CreateServerSchedule(ScheduleFrequencies.Hourly, "03:45:00", "23:45:00", + [ + Interval.WithHours(4) + ]), + CreateCloudSchedule(ScheduleFrequencies.Daily, "03:45:00", "23:45:00", + [ + Interval.WithHours(4), + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + + // Daily + // Daily Server Schedule can not have any intervals. They are all trimmed by the RestAPI + + // 6 - Daily requires weekday intervals + { + CreateServerSchedule(ScheduleFrequencies.Daily, "02:00:00", null, null), + CreateCloudSchedule(ScheduleFrequencies.Daily, "02:00:00", null, + [ + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + // 7 - Daily on the :30. End time must be removed from cloud because no hours intervals exist + { + CreateServerSchedule(ScheduleFrequencies.Daily, "23:30:00", "00:30:00", null), + CreateCloudSchedule(ScheduleFrequencies.Daily, "23:30:00", null, + [ + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + // 8 - Daily on the :15. End time must be removed from cloud because no hours intervals exist + { + CreateServerSchedule(ScheduleFrequencies.Daily, "12:15:00", "00:15:00", null), + CreateCloudSchedule(ScheduleFrequencies.Daily, "12:15:00", null, + [ + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday), + Interval.WithWeekday(WeekDays.Saturday), + Interval.WithWeekday(WeekDays.Sunday) + ]) + }, + + // Weekly + // Weekly Server Schedule can not have end time. It is removed by the RestAPI + + // 9 - Weekly with more than 1 weekday is not allowed, must be daily + { + CreateServerSchedule(ScheduleFrequencies.Weekly, "06:00:00", null, + [ + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday), + Interval.WithWeekday(WeekDays.Wednesday), + Interval.WithWeekday(WeekDays.Thursday), + Interval.WithWeekday(WeekDays.Friday) + ]), + // This is what I expected base on docs + // But Daily can't have end time, without having a hours interval + //CreateCloudSchedule(ScheduleFrequencies.Daily, "06:00:00", "06:00:00", + //[ + // Interval.WithWeekday(WeekDays.Monday), + // Interval.WithWeekday(WeekDays.Tuesday), + // Interval.WithWeekday(WeekDays.Wednesday), + // Interval.WithWeekday(WeekDays.Thursday), + // Interval.WithWeekday(WeekDays.Friday), + // Interval.WithHours(24) + //]) + CreateCloudSchedule(ScheduleFrequencies.Daily, "06:00:00", null, + [Interval.WithWeekday(WeekDays.Monday),Interval.WithWeekday(WeekDays.Tuesday),Interval.WithWeekday(WeekDays.Wednesday),Interval.WithWeekday(WeekDays.Thursday),Interval.WithWeekday(WeekDays.Friday)]) + }, + + // 10 - Weekly on Saturday only + { + CreateServerSchedule(ScheduleFrequencies.Weekly, "23:00:00", null, + [ + Interval.WithWeekday(WeekDays.Saturday) + ]), + CreateCloudSchedule(ScheduleFrequencies.Weekly, "23:00:00", null, + [ + Interval.WithWeekday(WeekDays.Saturday) + ]) + }, + + + // Monthly + // Monthly Server Schedule can not have end time. It is removed by the RestAPI + + // 11 - Last of the month + { + CreateServerSchedule(ScheduleFrequencies.Monthly, "23:00:00", null, + [ + Interval.WithMonthDay("1") + ]), + CreateCloudSchedule(ScheduleFrequencies.Monthly, "23:00:00", null, + [ + Interval.WithMonthDay("1") + ]) + }, + + // 12 - First day of month + { + CreateServerSchedule(ScheduleFrequencies.Monthly, "01:30:00", null, + [ + Interval.WithMonthDay("1") + ]), + CreateCloudSchedule(ScheduleFrequencies.Monthly, "01:30:00", null, + [ + Interval.WithMonthDay("1") + ]) + }, + + // 13 - Multiple days + { + CreateServerSchedule(ScheduleFrequencies.Monthly, "13:55:00", null, + [ + Interval.WithMonthDay("1"), + Interval.WithMonthDay("2"), + Interval.WithMonthDay("3") + ]), + CreateCloudSchedule(ScheduleFrequencies.Monthly, "13:55:00", null, + [ + Interval.WithMonthDay("1"), + Interval.WithMonthDay("2"), + Interval.WithMonthDay("3") + ]) + } + }; + + #endregion + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/Subscriptions/ServerToCloudSubscriptionConverterTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/Subscriptions/ServerToCloudSubscriptionConverterTests.cs new file mode 100644 index 00000000..b11555d3 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Conversion/Subscriptions/ServerToCloudSubscriptionConverterTests.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.Engine.Conversion.Schedules; +using Tableau.Migration.Engine.Conversion.Subscriptions; +using Tableau.Migration.Tests.Unit.Engine.Conversion.Schedules; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Conversion.Subscriptions +{ + public class ServerToCloudSubscriptionConverterTests : AutoFixtureTestBase + { + private readonly ServerToCloudSubscriptionConverter _converter; + private readonly Mock> _mockScheduleConverter = new(); + + public static TheoryData ValidSubscriptions = new(); + + /// + /// Static constructor to create the test data + /// + static ServerToCloudSubscriptionConverterTests() + { + CreateValidSubscriptions(); + } + + /// + /// Non-Static constructor to build the test object + /// + public ServerToCloudSubscriptionConverterTests() + { + // Create the real localizer + var services = new ServiceCollection(); + services.AddTableauMigrationSdk(); + var container = services.BuildServiceProvider(); + + _converter = new ServerToCloudSubscriptionConverter(_mockScheduleConverter.Object); + } + + [Theory] + [MemberData(nameof(ValidSubscriptions))] + public async Task ValidConversionAsync(IServerSubscription input, ICloudSubscription expectedSubscription) + { + // Setup + _mockScheduleConverter.Setup(c => c.ConvertAsync(input.Schedule, Cancel)).ReturnsAsync(expectedSubscription.Schedule); + + // Act + var result = await _converter.ConvertAsync(input, Cancel); + + // Assert + Assert.Equal(expectedSubscription.Content, result.Content); + Assert.Equal(expectedSubscription.Schedule, result.Schedule, new ScheduleComparers()); + } + + #region - Helper Methods - + + private static void CreateValidSubscriptions() + { + foreach (var scheduleMapping in ServerToCloudScheduleConverterTests.CreateValidScheduleMappings()) + { + ValidSubscriptions.Add(CreateServerSubscription(scheduleMapping.Key), CreateCloudSubscription(scheduleMapping.Value)); + } + } + + private static IServerSubscription CreateServerSubscription(IServerSchedule schedule) + { + var mockTask = new Mock(); + mockTask.Setup(t => t.Schedule).Returns(schedule); + mockTask.Setup(t => t.Id).Returns(Guid.NewGuid()); + mockTask.Setup(t => t.Subject).Returns($"{nameof(ICloudSubscription.Subject)}{Guid.NewGuid()}"); + + return mockTask.Object; + } + + private static ICloudSubscription CreateCloudSubscription(ICloudSchedule schedule) + { + var mockTask = new Mock(); + mockTask.Setup(t => t.Schedule).Returns(schedule); + mockTask.Setup(t => t.Id).Returns(Guid.NewGuid()); + mockTask.Setup(t => t.Subject).Returns($"{nameof(ICloudSubscription.Subject)}{Guid.NewGuid()}"); + + return mockTask.Object; + } + + #endregion + } +} \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/MigrationEndpointFactoryTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/MigrationEndpointFactoryTests.cs index 69c14424..4ce337ca 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/MigrationEndpointFactoryTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/MigrationEndpointFactoryTests.cs @@ -18,6 +18,7 @@ using System; using AutoFixture; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api; using Tableau.Migration.Content.Files; @@ -50,6 +51,7 @@ public MigrationEndpointFactoryTest() Create(), Create(), Create(), + Create(), Create() ); } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/BulkApiAuthenticationConfigurationsCacheTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/BulkApiAuthenticationConfigurationsCacheTests.cs new file mode 100644 index 00000000..3e9ba42a --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/BulkApiAuthenticationConfigurationsCacheTests.cs @@ -0,0 +1,81 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Tableau.Migration.Api; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Engine.Endpoints.Search; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Endpoints.Search +{ + public sealed class BulkApiAuthenticationConfigurationsCacheTests + { + public sealed class GetAllAsync : AutoFixtureTestBase + { + private readonly Mock _mockApiClient; + private readonly BulkApiAuthenticationConfigurationsCache _cache; + + private List AuthenticationConfigurations { get; set; } + + public GetAllAsync() + { + AuthenticationConfigurations = CreateMany().ToList(); + + _mockApiClient = Freeze>(); + _mockApiClient.Setup(x => x.GetAllAsync(AuthenticationConfigurationsApiClient.MAX_CONFIGURATIONS, Cancel)) + .ReturnsAsync(() => Result>.Succeeded(AuthenticationConfigurations.ToImmutableArray())); + + var mockEndpoint = Create>(); + mockEndpoint.SetupGet(x => x.SiteApi.AuthenticationConfigurations).Returns(_mockApiClient.Object); + + _cache = new BulkApiAuthenticationConfigurationsCache(mockEndpoint.Object); + } + + [Fact] + public async Task LoadsAndGetsCacheAsync() + { + var configs = await _cache.GetAllAsync(Cancel); + Assert.Equal(AuthenticationConfigurations, configs); + + configs = await _cache.GetAllAsync(Cancel); + Assert.Equal(AuthenticationConfigurations, configs); + + _mockApiClient.Verify(x => x.GetAllAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task LoadFailureAsync() + { + _mockApiClient.Setup(x => x.GetAllAsync(AuthenticationConfigurationsApiClient.MAX_CONFIGURATIONS, Cancel)) + .ReturnsAsync(() => Result>.Failed(new Exception())); + + var configs = await _cache.GetAllAsync(Cancel); + Assert.Empty(configs); + + _mockApiClient.Verify(x => x.GetAllAsync(It.IsAny(), It.IsAny()), Times.Once); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/UserSavedCredentialsCacheTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/UserSavedCredentialsCacheTests.cs new file mode 100644 index 00000000..b3939d75 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/UserSavedCredentialsCacheTests.cs @@ -0,0 +1,85 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Engine.Endpoints.Search; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Endpoints.Search +{ + public class UserSavedCredentialsCacheTests : AutoFixtureTestBase + { + protected readonly IUserSavedCredentialsCache Cache; + + protected readonly Guid UserIdWithData; + + protected readonly IEmbeddedCredentialKeychainResult SavedCredentials; + + protected readonly Guid UserIdWithoutData; + + public UserSavedCredentialsCacheTests() + { + UserIdWithData = Guid.NewGuid(); + UserIdWithoutData = Guid.NewGuid(); + SavedCredentials = Create(); + + Cache = new UserSavedCredentialsCache(); + Cache.AddOrUpdate(UserIdWithData, SavedCredentials); + } + public class Get : UserSavedCredentialsCacheTests + { + [Fact] + public void Null_on_no_match() + { + var result = Cache.Get(Guid.NewGuid()); + Assert.Null(result); + } + + [Fact] + public void Returns_value() + { + var result = Cache.Get(UserIdWithData); + Assert.Equal(result, SavedCredentials); + } + + [Fact] + public void Returns_value_after_addition() + { + var userId = Guid.NewGuid(); + var savedCreds = Create(); + Cache.AddOrUpdate(userId, savedCreds); + + var result = Cache.Get(userId); + Assert.Equal(result, savedCreds); + } + } + + public class AddOrUpdate : UserSavedCredentialsCacheTests + { + [Fact] + public void Adds_successfully() + { + var userId = Guid.NewGuid(); + var savedCreds = Create(); + + var result = Cache.AddOrUpdate(userId, savedCreds); + Assert.Equal(result, savedCreds); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiDestinationEndpointTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiDestinationEndpointTests.cs index 6aba7fb2..c4515e55 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiDestinationEndpointTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiDestinationEndpointTests.cs @@ -15,9 +15,9 @@ // limitations under the License. // -using System.Net; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api; using Tableau.Migration.Content; @@ -41,6 +41,7 @@ public TableauApiDestinationEndpointTest() Create(), Create(), Create(), + Create(), Create() ); } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiEndpointBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiEndpointBaseTests.cs index 4d2f67ba..1840edd3 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiEndpointBaseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiEndpointBaseTests.cs @@ -19,6 +19,7 @@ using System.Threading.Tasks; using AutoFixture; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api; using Tableau.Migration.Content; @@ -47,8 +48,9 @@ public TestApiEndpoint(IServiceScopeFactory serviceScopeFactory, ITableauApiEndpointConfiguration config, IContentReferenceFinderFactory finderFactory, IContentFileStore fileStore, + ILoggerFactory loggerFactory, ISharedResourcesLocalizer localizer) - : base(serviceScopeFactory, config, finderFactory, fileStore, localizer) + : base(serviceScopeFactory, config, finderFactory, fileStore, loggerFactory, localizer) { } } @@ -62,6 +64,7 @@ public TableauApiEndpointBaseTest() Create(), Create(), Create(), + Create(), Create() ); } @@ -88,9 +91,10 @@ public void CreatesApiClient() var config = Create(); var mockFinderFactory = Create(); var mockFileStore = Create(); + var mockLoggerFactory = Create(); var mockLocalizer = Create(); - var endpoint = new TestApiEndpoint(serviceScopeFactory, config, mockFinderFactory, mockFileStore, mockLocalizer); + var endpoint = new TestApiEndpoint(serviceScopeFactory, config, mockFinderFactory, mockFileStore, mockLoggerFactory, mockLocalizer); Assert.Same(apiClient, endpoint.ServerApi); } @@ -110,9 +114,10 @@ public void ApiClientScopeFileStoreMatchesMigrationFileStore() var config = Create(); var mockFinderFactory = Create(); var mockFileStore = Create(); + var mockLoggerFactory = Create(); var mockLocalizer = Create(); - var endpoint = new TestApiEndpoint(serviceScopeFactory, config, mockFinderFactory, mockFileStore, mockLocalizer); + var endpoint = new TestApiEndpoint(serviceScopeFactory, config, mockFinderFactory, mockFileStore, mockLoggerFactory, mockLocalizer); Assert.Same(mockFileStore, endpoint.EndpointScope.ServiceProvider.GetService()); } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiSourceEndpointTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiSourceEndpointTests.cs index 052634bd..064c2b15 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiSourceEndpointTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/TableauApiSourceEndpointTests.cs @@ -17,6 +17,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api; using Tableau.Migration.Content.Files; @@ -39,6 +40,7 @@ public TableauApiSourceEndpointTest() Create(), Create(), Create(), + Create(), Create() ); } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/ActionCompleted/SubscriptionsEnabledActionCompletedHookTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/ActionCompleted/SubscriptionsEnabledActionCompletedHookTests.cs new file mode 100644 index 00000000..6e90fe1f --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/ActionCompleted/SubscriptionsEnabledActionCompletedHookTests.cs @@ -0,0 +1,119 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Hooks.ActionCompleted; +using Tableau.Migration.Engine.Pipelines; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Hooks.ActionCompleted +{ + public class SubscriptionsEnabledActionCompletedHookTests : AutoFixtureTestBase + { + protected readonly Mock MockSubCapManager; + protected readonly Mock MockPipelineRunner; + protected readonly SubscriptionsEnabledActionCompletedHook Hook; + + public SubscriptionsEnabledActionCompletedHookTests() + { + MockPipelineRunner = new Mock(); + MockSubCapManager = new Mock(); + MockSubCapManager.Setup(scm => scm.SetMigrationCapabilityAsync(It.IsAny())) + .Returns(Task.FromResult(Result.Succeeded())); + MockSubCapManager.Setup(scm => scm.IsMigrationCapabilityDisabled()) + .Returns(false); + + Hook = new SubscriptionsEnabledActionCompletedHook(MockPipelineRunner.Object, MockSubCapManager.Object); + } + + public class ExecuteAsync : SubscriptionsEnabledActionCompletedHookTests + { + [Fact] + public async Task Skips_for_non_workbook() + { + var migrateContentAction = Create>(); + MockPipelineRunner.Setup(pr => pr.CurrentAction).Returns(migrateContentAction); + + var context = Create(); + + var result = await Hook.ExecuteAsync(context, new CancellationToken()); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Same(context, result); + MockSubCapManager.Verify(x => x.IsMigrationCapabilityDisabled(), Times.Never); + MockSubCapManager.Verify( + x => x.SetMigrationCapabilityAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Runs_for_workbook() + { + var migrateContentAction = Create>(); + MockPipelineRunner.Setup(pr => pr.CurrentAction).Returns(migrateContentAction); + + var context = Create(); + + var result = await Hook.ExecuteAsync(context, new CancellationToken()); + + Assert.NotNull(result); + Assert.True(result.Success); + Assert.Same(context, result); + MockSubCapManager.Verify(x => x.IsMigrationCapabilityDisabled(), Times.Once); + MockSubCapManager.Verify( + x => x.SetMigrationCapabilityAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Handles_failure() + { + var migrateContentAction = Create>(); + MockPipelineRunner.Setup(pr => pr.CurrentAction).Returns(migrateContentAction); + + var errors = CreateMany(); + MockSubCapManager.Setup(scm => scm.SetMigrationCapabilityAsync(It.IsAny())) + .Returns(Task.FromResult(Result.Failed(errors))); + + var context = Create(); + + var result = await Hook.ExecuteAsync(context, new CancellationToken()); + + Assert.NotNull(result); + Assert.False(result.Success); + Assert.NotSame(context, result); + Assert.Equal(errors.Count(), result.Errors.Count); + MockSubCapManager.Verify( + x => x.IsMigrationCapabilityDisabled(), + Times.Once); + + MockSubCapManager.Verify( + x => x.SetMigrationCapabilityAsync(It.IsAny()), + Times.Once); + } + } + + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/EmbeddedCredentialsCapabilityManagerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/EmbeddedCredentialsCapabilityManagerTests.cs new file mode 100644 index 00000000..64f95f5f --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/EmbeddedCredentialsCapabilityManagerTests.cs @@ -0,0 +1,159 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Resources; +using Xunit; + + +namespace Tableau.Migration.Tests.Unit.Engine.Hooks +{ + public class EmbeddedCredentialsCapabilityManagerTests : AutoFixtureTestBase + { + private readonly Mock _mockLocalizer = new(); + internal readonly Mock> MockLogger = new(); + internal Mock MockMigrationCapabilities; + protected readonly Mock MockMigration; + + internal readonly EmbeddedCredentialsCapabilityManager EmbeddedCredsCapabilityManager; + + public EmbeddedCredentialsCapabilityManagerTests() + { + MockMigrationCapabilities = new Mock { CallBase = true }; + MockMigration = new Mock(); + + MockMigration.SetupGet(m => m.Plan.Destination).Returns(Create()); + MockMigration.Setup(m => m.Destination.GetSessionAsync(It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded(Create()))); + + EmbeddedCredsCapabilityManager = new EmbeddedCredentialsCapabilityManager( + _mockLocalizer.Object, + MockLogger.Object, + MockMigrationCapabilities.Object, + MockMigration.Object); + } + + private void SetupRetrieveKeychainAsync(IEmbeddedCredentialKeychainResult keychainResult) + { + MockMigration.Setup(m => m.Source.RetrieveKeychainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + Task.FromResult( + (IResult)Result + .Succeeded(keychainResult))); + } + + private void SetupRetrieveKeychainAsyncFail(Exception error) + { + MockMigration.Setup(m => m.Source.RetrieveKeychainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + Task.FromResult( + (IResult)Result + .Failed(error))); + } + + protected void SetupRetrieveKeychainAsync(IEnumerable encryptedKeychains) + { + var response = new RetrieveKeychainResponse(encryptedKeychains, null); + SetupRetrieveKeychainAsync(new EmbeddedCredentialKeychainResult(response)); + } + + public class SetMigrationCapabilityAsync : EmbeddedCredentialsCapabilityManagerTests + { + [Fact] + public async Task Doesnot_disable_when_embedded_creds_setup() + { + // Arrange + SetupRetrieveKeychainAsync(CreateMany()); + + // Act + var result = await EmbeddedCredsCapabilityManager.SetMigrationCapabilityAsync(new CancellationToken()); + + // Assert + Assert.True(result.Success); + Assert.False(MockMigrationCapabilities.Object.EmbeddedCredentialsDisabled); + MockLogger.VerifyWarnings(Times.Never()); + } + + [Fact] + public async Task Disables_when_embedded_creds_not_setup() + { + // Arrange + var restException = CreateRestException(RestErrorCodes.FEATURE_DISABLED); + SetupRetrieveKeychainAsyncFail(restException); + + // Act + var result = await EmbeddedCredsCapabilityManager.SetMigrationCapabilityAsync(new CancellationToken()); + + // Assert + Assert.True(result.Success); + Assert.True(MockMigrationCapabilities.Object.EmbeddedCredentialsDisabled); + MockLogger.VerifyWarnings(Times.Once); + } + + [Fact] + public async Task Doesnot_disable_on_other_errors() + { + // Arrange + var restException = CreateRestException(RestErrorCodes.BAD_REQUEST); + SetupRetrieveKeychainAsyncFail(restException); + + // Act + var result = await EmbeddedCredsCapabilityManager.SetMigrationCapabilityAsync(new CancellationToken()); + + // Assert + Assert.True(result.Success); + Assert.False(MockMigrationCapabilities.Object.EmbeddedCredentialsDisabled); + MockLogger.VerifyWarnings(Times.Never()); + } + + private RestException CreateRestException(string errorCode) + { + var error = AutoFixture.Build() + .With(e => e.Code, errorCode) + .Create(); + + var restException = new RestException( + httpMethod: HttpMethod.Get, + Create(), + Create(), + error, Create(), Create()); + return restException; + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterBaseTests.cs index 22909096..7b15a4f2 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterBaseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterBaseTests.cs @@ -19,13 +19,12 @@ using System.Collections.Immutable; using System.Linq; using System.Threading.Tasks; -using Tableau.Migration.Engine; -using Tableau.Migration.Engine.Hooks.Filters; using Microsoft.Extensions.Logging; using Moq; -using Xunit; -using Polly; +using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Hooks.Filters; using Tableau.Migration.Resources; +using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Filters { @@ -35,7 +34,7 @@ public class TestFilter : ContentFilterBase { public TestFilter( ISharedResourcesLocalizer localizer, - ILogger> logger) + ILogger> logger) : base(localizer, logger) { } public bool PublicDisabled @@ -76,8 +75,8 @@ public async Task AppliesFilterAsync() var allItems = CreateMany>().ToImmutableList(); MockLogger.Setup(x => x.IsEnabled(LogLevel.Debug)).Returns(true); - - var filter = new TestFilter(MockLocalizer.Object,MockLogger.Object) + + var filter = new TestFilter(MockLocalizer.Object, MockLogger.Object) { FilterCallback = i => allItems.IndexOf(i) % 2 == 0 }; @@ -86,8 +85,6 @@ public async Task AppliesFilterAsync() Assert.NotSame(allItems, results); Assert.Equal(allItems.Where(filter.FilterCallback), results); - - MockLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterRunnerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterRunnerTests.cs index ab7c54ab..c8d55dfa 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterRunnerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Filters/ContentFilterRunnerTests.cs @@ -22,11 +22,13 @@ using System.Threading; using System.Threading.Tasks; using AutoFixture; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine; using Tableau.Migration.Engine.Hooks; using Tableau.Migration.Engine.Hooks.Filters; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Filters @@ -39,6 +41,14 @@ private interface ITestMigrationItems : IEnumerable> private class TestMigrationItems : IEnumerable> { + public TestMigrationItems() + { } + + public TestMigrationItems(IEnumerable> items) + { + migrationItems.AddRange(items); + } + private readonly List> migrationItems = new(); public IEnumerator> GetEnumerator() @@ -83,6 +93,9 @@ public class ExecuteAsync : AutoFixtureTestBase private readonly IMigrationPlan _plan; + private readonly Mock _mockLocalizer = new(); + private readonly Mock> _mockLogger = new(); + private readonly ContentFilterRunner _runner; public ExecuteAsync() @@ -97,7 +110,8 @@ public ExecuteAsync() mockPlan.SetupGet(x => x.Filters).Returns(mockFilters.Object); _plan = mockPlan.Object; Assert.NotNull(_plan.Filters); - _runner = new(_plan, new Mock().Object); + + _runner = new(_plan, new Mock().Object, _mockLocalizer.Object, _mockLogger.Object); } private void AddFilterWithResult(TestMigrationItems? result) @@ -113,16 +127,17 @@ public async Task AllowsOrderedContextOverwriteAsync() // // Setup 3 different results for the 3 different filters. // The filters don't do anything other then return the result it was given (for testing purposes) - var result1 = new TestMigrationItems(); - var result2 = new TestMigrationItems(); - var result3 = new TestMigrationItems(); + var result1 = new TestMigrationItems(AutoFixture.CreateMany>()); + var result2 = new TestMigrationItems(AutoFixture.CreateMany>()); + var result3 = new TestMigrationItems(AutoFixture.CreateMany>()); AddFilterWithResult(result1); AddFilterWithResult(result2); AddFilterWithResult(result3); // Setup the input context that will be used to verify the filters - var input = new TestMigrationItems(); + var input = new TestMigrationItems(AutoFixture.CreateMany>()); + Assert.NotEmpty(input); // Act var result = await _runner.ExecuteAsync(input, default); @@ -137,6 +152,9 @@ public async Task AllowsOrderedContextOverwriteAsync() // input context order is saved back to _transformerExecutionContexts because TestFilter saves them // just to verify that the order is correct Assert.Equal(new[] { input, result1, result2 }, _filterExecutionContexts); + + // Verify logging was called at least once + _mockLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); } [Fact] @@ -151,7 +169,7 @@ public async Task NullResultReturnsInputAsync() AddFilterWithResult(null); AddFilterWithResult(null); - var input = new TestMigrationItems(); + var input = new TestMigrationItems(AutoFixture.CreateMany>()); // Act var result = await _runner.ExecuteAsync(input, default); @@ -168,6 +186,9 @@ public async Task NullResultReturnsInputAsync() // just to verify that the order is correct // In this case, none of the filters updated the input context, so they should all be the same Assert.Equal(new[] { input, input, input }, _filterExecutionContexts); + + // Verify logging was not called as the filters don't filter anything + _mockLogger.VerifyLogging(LogLevel.Debug, Times.Never()); } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigration/Default/EmbeddedCredentialsPreflightCheckTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigration/Default/EmbeddedCredentialsPreflightCheckTests.cs new file mode 100644 index 00000000..c8671d8f --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigration/Default/EmbeddedCredentialsPreflightCheckTests.cs @@ -0,0 +1,92 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Hooks.InitializeMigration.Default; +using Tableau.Migration.Resources; +using Xunit; + + +namespace Tableau.Migration.Tests.Unit.Engine.Hooks.InitializeMigration.Default +{ + public class EmbeddedCredentialsPreflightCheckTests : AutoFixtureTestBase + { + private readonly Mock _mockLocalizer; + private readonly Mock> _mockLogger; + internal Mock MockCapabilities; + internal readonly Mock MockCapabilityManager = new(); + + internal readonly EmbeddedCredentialsPreflightCheck Hook; + + public EmbeddedCredentialsPreflightCheckTests() + { + _mockLocalizer = new Mock(); + _mockLogger = new Mock>(); + MockCapabilities = new Mock { CallBase = true }; + + Hook = new EmbeddedCredentialsPreflightCheck( + _mockLocalizer.Object, + _mockLogger.Object, + MockCapabilities.Object, + MockCapabilityManager.Object); + } + + public class ExecuteCheckAsync : EmbeddedCredentialsPreflightCheckTests + { + [Fact] + public async Task Calls_capability_manager() + { + var ctx = Create(); + + MockCapabilityManager.Setup(cm => cm.SetMigrationCapabilityAsync(It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded())); + + var result = await Hook.ExecuteCheckAsync(ctx, new CancellationToken()); + + Assert.NotNull(result); + Assert.True(result.Success); + + MockCapabilityManager.Verify( + x => x.SetMigrationCapabilityAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Catches_errors() + { + var ctx = Create(); + + MockCapabilityManager.Setup(cm => cm.SetMigrationCapabilityAsync(It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Failed(CreateMany()))); + + var result = await Hook.ExecuteCheckAsync(ctx, new CancellationToken()); + + Assert.NotNull(result); + Assert.False(result.Success); + + MockCapabilityManager.Verify( + x => x.SetMigrationCapabilityAsync(It.IsAny()), + Times.Once); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigration/Default/InitializeMigrationCapabilityHookBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigration/Default/InitializeMigrationCapabilityHookBaseTests.cs new file mode 100644 index 00000000..4752af3e --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigration/Default/InitializeMigrationCapabilityHookBaseTests.cs @@ -0,0 +1,118 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Hooks.InitializeMigration; +using Tableau.Migration.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Hooks.InitializeMigration +{ + public class InitializeMigrationCapabilityHookBaseTests + { + private readonly Mock _mockLocalizer; + private readonly Mock> _mockLogger; + private readonly Mock _mockCapabilities; + + public InitializeMigrationCapabilityHookBaseTests() + { + _mockLocalizer = new Mock(); + _mockLogger = new Mock>(); + _mockCapabilities = new Mock(); + } + + [Fact] + public async Task ExecuteAsync_ShouldLogChanges_WhenCapabilitiesChange() + { + // Arrange + var hook = new TestInitializeWithChangesMigrationHook(_mockLocalizer.Object, _mockLogger.Object, _mockCapabilities.Object); + var ctx = Mock.Of(); + var cancelToken = new CancellationToken(); + + // Act + await hook.ExecuteAsync(ctx, cancelToken); + + // Assert + _mockLogger.Verify( + static x => x.Log( + It.Is(l => l == LogLevel.Debug), + It.IsAny(), + It.Is((v, t) => v != null), + It.IsAny(), + It.Is>((v, t) => true)), + Times.AtLeastOnce); + } + + [Fact] + public async Task ExecuteAsync_ShouldLogChanges_WhenCapabilitiesDidNotChange() + { + // Arrange + var hook = new TestInitializeWithoutChangesMigrationHook(_mockLocalizer.Object, _mockLogger.Object, _mockCapabilities.Object); + var ctx = Mock.Of(); + var cancelToken = new CancellationToken(); + + // Act + await hook.ExecuteAsync(ctx, cancelToken); + + // Assert + _mockLogger.Verify( + static x => x.Log( + It.Is(l => l == LogLevel.Debug), + It.IsAny(), + It.Is((v, t) => v != null), + It.IsAny(), + It.Is>((v, t) => true)), + Times.Once); + } + + private class TestInitializeWithChangesMigrationHook : InitializeMigrationCapabilityHookBase + { + public TestInitializeWithChangesMigrationHook( + ISharedResourcesLocalizer? localizer, + ILogger? logger, + IMigrationCapabilitiesEditor capabilities) + : base(localizer, logger, capabilities) + { } + + public override Task ExecuteCheckAsync(IInitializeMigrationHookResult ctx, CancellationToken cancel) + { + Capabilities.PreflightCheckExecuted = !Capabilities.PreflightCheckExecuted; + return Task.FromResult(ctx); + } + } + + private class TestInitializeWithoutChangesMigrationHook : InitializeMigrationCapabilityHookBase + { + public TestInitializeWithoutChangesMigrationHook( + ISharedResourcesLocalizer? localizer, + ILogger? logger, + IMigrationCapabilitiesEditor capabilities) + : base(localizer, logger, capabilities) + { } + + public override Task ExecuteCheckAsync(IInitializeMigrationHookResult ctx, CancellationToken cancel) + { + return Task.FromResult(ctx); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingBaseTests.cs index 43714452..5ff7035f 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingBaseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingBaseTests.cs @@ -15,15 +15,14 @@ // limitations under the License. // -using System; using System.Threading; using System.Threading.Tasks; -using Tableau.Migration.Content; -using Tableau.Migration.Engine.Hooks.Mappings; using Microsoft.Extensions.Logging; -using Xunit; using Moq; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Hooks.Mappings; using Tableau.Migration.Resources; +using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Mappings @@ -36,10 +35,10 @@ internal class StubUserMapping : ContentMappingBase private readonly ContentMappingContext _replaceLocation; public StubUserMapping( - ContentMappingContext searchLocation, + ContentMappingContext searchLocation, ContentMappingContext replaceLocation, ISharedResourcesLocalizer localizer, - ILogger logger) + ILogger logger) : base(localizer, logger) { _searchLocation = searchLocation; @@ -79,9 +78,6 @@ public async Task Map() // Asserts Assert.Equal(replaceLoc, mappedResult); Assert.Equal(unmappedLoc, unmappedResult); - - // Verify we got at least 1 debug log message - mockLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingRunnerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingRunnerTests.cs index 3a7c5ab0..ccb399a5 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingRunnerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/ContentMappingRunnerTests.cs @@ -21,10 +21,12 @@ using System.Threading; using System.Threading.Tasks; using AutoFixture; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine.Hooks; using Tableau.Migration.Engine.Hooks.Mappings; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Mappings @@ -36,9 +38,9 @@ public class ContentMappingRunnerTests private class TestMapping : IContentMapping { private readonly List> _contexts; - private readonly ContentMappingContext _result; + private readonly ContentMappingContext? _result; - public TestMapping(List> contexts, ContentMappingContext result) + public TestMapping(List> contexts, ContentMappingContext? result) { _contexts = contexts; _result = result; @@ -63,6 +65,8 @@ public class ExecuteAsync : AutoFixtureTestBase private readonly IMigrationPlan _plan; + private readonly Mock> _mockLogger = new(); + private readonly ContentMappingRunner _runner; public ExecuteAsync() @@ -86,10 +90,12 @@ public ExecuteAsync() _runner = new( _plan, - new Mock().Object); + new Mock().Object, + new Mock().Object, + _mockLogger.Object); } - private void AddMappingWithResult(ContentMappingContext result) + private void AddMappingWithResult(ContentMappingContext? result) { var mapping = new TestMapping(_mappingExecutionContexts, result); @@ -116,6 +122,28 @@ public async Task AllowsOrderedContextOverwriteAsync() // Asserts Assert.Equal(ctx3, result); Assert.Equal(new[] { input, ctx1, ctx2 }, _mappingExecutionContexts); + + _mockLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); + } + + [Fact] + public async Task NullResultReturnsInputAsync() + { + // Arrange + AddMappingWithResult(null); + AddMappingWithResult(null); + AddMappingWithResult(null); + + var input = Create>(); + + // Act + var result = await _runner.ExecuteAsync(input, default); + + // Asserts + Assert.Same(input, result); + + // Verify logging was never called as the test mapping doesn't do anything + _mockLogger.VerifyLogging(LogLevel.Debug, Times.Never()); } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMappingTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMappingTests.cs index a7c24b03..c9da07c1 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMappingTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/AuthenticationTypeDomainMappingTests.cs @@ -16,11 +16,13 @@ // using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine.Hooks.Mappings; using Tableau.Migration.Engine.Hooks.Mappings.Default; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Mappings.Default @@ -29,6 +31,9 @@ public class AuthenticationTypeDomainMappingTests { public class ExecuteAsync : AutoFixtureTestBase { + private readonly Mock _mockLocalizer = new(); + private readonly Mock> _mockLogger = new(); + [Fact] public async Task MapsUserDomainAsync() { @@ -38,7 +43,7 @@ public async Task MapsUserDomainAsync() UserDomain = "userDomain" }); - var mapping = new AuthenticationTypeDomainMapping(mockOptions.Object); + var mapping = new AuthenticationTypeDomainMapping(mockOptions.Object, _mockLocalizer.Object, _mockLogger.Object); var ctx = Create>(); var result = await mapping.ExecuteAsync(ctx, new()); @@ -60,7 +65,7 @@ public async Task MapsGroupDomainAsync() GroupDomain = "groupDomain" }); - var mapping = new AuthenticationTypeDomainMapping(mockOptions.Object); + var mapping = new AuthenticationTypeDomainMapping(mockOptions.Object, _mockLocalizer.Object, _mockLogger.Object); var ctx = Create>(); var result = await mapping.ExecuteAsync(ctx, new()); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMappingTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMappingTests.cs index 2114987a..b2416042 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMappingTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/CallbackAuthenticationTypeDomainMappingTests.cs @@ -17,9 +17,12 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine.Hooks.Mappings; using Tableau.Migration.Engine.Hooks.Mappings.Default; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Mappings.Default @@ -30,13 +33,16 @@ public class ExecuteAsync : AutoFixtureTestBase { private readonly CancellationToken _cancel = new(); + private readonly Mock _mockLocalizer = new(); + private readonly Mock> _mockLogger = new(); + [Fact] public async Task MapsDomainAsync() { var callback = (ContentMappingContext ctx, CancellationToken cancel) => Task.FromResult("myDomain"); - var mapper = new CallbackAuthenticationTypeDomainMapping(callback); + var mapper = new CallbackAuthenticationTypeDomainMapping(callback, _mockLocalizer.Object, _mockLogger.Object); var ctx = Create>(); var result = await mapper.ExecuteAsync(ctx, _cancel); @@ -55,7 +61,7 @@ public async Task CallbackReturnsNullAsync() var callback = (ContentMappingContext ctx, CancellationToken cancel) => Task.FromResult((string?)null); - var mapper = new CallbackAuthenticationTypeDomainMapping(callback); + var mapper = new CallbackAuthenticationTypeDomainMapping(callback, _mockLocalizer.Object, _mockLogger.Object); var ctx = Create>(); var result = await mapper.ExecuteAsync(ctx, _cancel); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/TableauCloudUsernameMappingTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/TableauCloudUsernameMappingTests.cs index 41856e13..242ca028 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/TableauCloudUsernameMappingTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Mappings/Default/TableauCloudUsernameMappingTests.cs @@ -17,11 +17,14 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine.Hooks.Mappings; using Tableau.Migration.Engine.Hooks.Mappings.Default; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Mappings.Default @@ -30,6 +33,9 @@ public class TableauCloudUsernameMappingTests { public class ExecuteAsync : AutoFixtureTestBase { + private Mock MockLocalizer = new(); + private Mock> MockLogger = new(); + private string MailDomain { get; set; } = string.Empty; private bool UseExistingEmail { get; set; } = true; @@ -47,7 +53,7 @@ protected TableauCloudUsernameMapping BuildMapping() var mockOptionsProvider = Create>>(); mockOptionsProvider.Setup(x => x.Get()).Returns(opts); - return new TableauCloudUsernameMapping(mockOptionsProvider.Object); + return new TableauCloudUsernameMapping(mockOptionsProvider.Object, MockLocalizer.Object, MockLogger.Object); } [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/PostPublish/Default/EmbeddedCredentialsItemPostPublishHookTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/PostPublish/Default/EmbeddedCredentialsItemPostPublishHookTests.cs new file mode 100644 index 00000000..6f119223 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/PostPublish/Default/EmbeddedCredentialsItemPostPublishHookTests.cs @@ -0,0 +1,397 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Engine.Endpoints.Search; +using Tableau.Migration.Engine.Hooks.PostPublish; +using Tableau.Migration.Engine.Hooks.PostPublish.Default; +using Tableau.Migration.Engine.Manifest; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Hooks.PostPublish.Default +{ + public class EmbeddedCredentialsItemPostPublishHookTests + { + public interface IEmbeddedCredentialsContentType : IContentReference, IRequiresEmbeddedCredentialMigration, IConnectionsContent + { } + public interface ITestContentType : IWithEmbeddedCredentials + { } + public class EmbeddedCredentialsContentType : TestContentType, IEmbeddedCredentialsContentType, ITestContentType + { + public EmbeddedCredentialsContentType(IImmutableList connections) + { + Connections = connections; + } + + public IImmutableList Connections { get; set; } = ImmutableList.Create(); + } + + public class EmbeddedCredentialsItemPostPublishHookTest : AutoFixtureTestBase + { + protected readonly Mock MockMigration = new(); + protected readonly Mock MockDestinationEndpoint = new(); + protected readonly Mock MockSourceEndpoint = new(); + protected readonly Mock> MockUserContentFinder = new(); + protected readonly Mock MockUserSavedCredentialsCache = new(); + protected readonly Mock>> MockLogger = new(); + protected readonly MockSharedResourcesLocalizer MockLocalizer = new(); + protected readonly Mock MockMigrationCapabilities = new(); + protected readonly EmbeddedCredentialsItemPostPublishHook Hook; + + public EmbeddedCredentialsItemPostPublishHookTest() + { + MockMigration.SetupGet(m => m.Destination).Returns(MockDestinationEndpoint.Object); + MockMigration.SetupGet(m => m.Source).Returns(MockSourceEndpoint.Object); + MockMigration.SetupGet(m => m.Plan).Returns(Create()); + MockMigration.SetupGet(m => m.Plan.Destination).Returns(Create()); + MockDestinationEndpoint.Setup(m => m.GetSessionAsync(It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded(Create()))); + + var mockDestinationFinderFactory = new Mock(); + mockDestinationFinderFactory.Setup(dff => dff.ForDestinationContentType()).Returns(MockUserContentFinder.Object); + + MockUserContentFinder.Setup(ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(Create())); + + var mockLoggerFactory = new Mock(); + mockLoggerFactory + .Setup(lf => lf.CreateLogger(It.IsAny())) + .Returns(MockLogger.Object); + MockMigrationCapabilities.SetupGet(mc => mc.EmbeddedCredentialsDisabled).Returns(false); + + Hook = new( + MockMigration.Object, + mockDestinationFinderFactory.Object, + MockUserSavedCredentialsCache.Object, + mockLoggerFactory.Object, + MockLocalizer.Object, + MockMigrationCapabilities.Object); + } + + protected ContentItemPostPublishContext CreateContext( + IEmbeddedCredentialsContentType sourceItem, + EmbeddedCredentialsContentType destinationItem) + { + var manifestEntry = new MigrationManifestEntry( + Create(), + new ContentReferenceStub(sourceItem)) + .SetMigrated(); + + Assert.Equal(MigrationManifestEntryStatus.Migrated, manifestEntry.Status); + Assert.Empty(manifestEntry.Errors); + + + var context = new ContentItemPostPublishContext( + manifestEntry, + sourceItem, + destinationItem); + + Assert.NotNull(context); + + return context; + } + + protected IEmbeddedCredentialsContentType CreateSourceItem( + bool embedPassword = false, + bool useOAuthManagedKeychain = false) + { + var sourceConnections = AutoFixture + .Build() + .With(c => c.EmbedPassword, () => embedPassword) + .With(c => c.UseOAuthManagedKeychain, () => useOAuthManagedKeychain) + .CreateMany() + .Select(c => (IConnection)c) + .ToImmutableList(); + + Assert.NotNull(sourceConnections); + + var sourceItem = (IEmbeddedCredentialsContentType)new EmbeddedCredentialsContentType(sourceConnections); + return sourceItem; + } + + protected void SetupApplyKeychainAsync() + { + MockDestinationEndpoint.Setup( + m => m.ApplyKeychainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded())); + } + + protected void SetupRetrieveKeychainAsync( + IEnumerable encryptedKeychains, + IEnumerable? associatedUserIds = null) + { + var response = new RetrieveKeychainResponse(encryptedKeychains, associatedUserIds); + SetupRetrieveKeychainAsync(new EmbeddedCredentialKeychainResult(response)); + } + + protected void SetupRetrieveKeychainAsync() => SetupRetrieveKeychainAsync(Create()); + + protected void SetupRetrieveUserSavedCredentialsAsync() + => SetupRetrieveUserSavedCredentialsAsync(Create()); + + private void SetupRetrieveKeychainAsync(IEmbeddedCredentialKeychainResult keychainResult) + { + MockSourceEndpoint.Setup(m => m.RetrieveKeychainsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns( + Task.FromResult( + (IResult)Result + .Succeeded(keychainResult))); + } + + private void SetupRetrieveUserSavedCredentialsAsync(IEmbeddedCredentialKeychainResult keychainResult) + => MockSourceEndpoint.Setup(m => m.RetrieveUserSavedCredentialsAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded(keychainResult))); + + protected void SetupUploadUserSavedCredentialsAsync() + => MockDestinationEndpoint.Setup(m => m.UploadUserSavedCredentialsAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded())); + protected static void AssertSuccess(ContentItemPostPublishContext context, ContentItemPostPublishContext? result) + { + Assert.Same(context, result); + Assert.NotNull(result); + Assert.Equal(MigrationManifestEntryStatus.Migrated, result.ManifestEntry.Status); + Assert.Empty(result.ManifestEntry.Errors); + } + } + + public class ExecuteAsync : EmbeddedCredentialsItemPostPublishHookTest + { + [Fact] + public async Task Skips_when_no_embedded_creds() + { + var manifestEntry = Create(); + + var sourceItem = CreateSourceItem(embedPassword: false); + + var context = CreateContext(sourceItem, Create()); + + SetupRetrieveKeychainAsync(); + SetupApplyKeychainAsync(); + + var result = await Hook.ExecuteAsync(context, Cancel); + + AssertSuccess(context, result); + + MockDestinationEndpoint.Verify(de => de.GetSessionAsync(It.IsAny()), Times.Never); + MockUserContentFinder.Verify(ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Skips_when_cabaility_disabled() + { + var manifestEntry = Create(); + + var sourceItem = CreateSourceItem(embedPassword: true); + + var context = CreateContext(sourceItem, Create()); + + MockMigrationCapabilities.SetupGet(mc => mc.EmbeddedCredentialsDisabled).Returns(true); + SetupRetrieveKeychainAsync(CreateMany()); + SetupApplyKeychainAsync(); + + var result = await Hook.ExecuteAsync(context, Cancel); + + Assert.True(context.PublishedItem.HasEmbeddedPassword); + AssertSuccess(context, result); + + MockDestinationEndpoint.Verify(de => de.GetSessionAsync(It.IsAny()), Times.Never); + MockUserContentFinder.Verify(ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Migrates_non_OAuth_embedded_creds() + { + var sourceItem = CreateSourceItem(embedPassword: true); + + var context = CreateContext(sourceItem, Create()); + + SetupRetrieveKeychainAsync(CreateMany()); + SetupApplyKeychainAsync(); + + + var result = await Hook.ExecuteAsync(context, Cancel); + + AssertSuccess(context, result); + + MockDestinationEndpoint.VerifyAll(); + MockUserContentFinder.Verify(ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Warns_on_managed_OAuth_embedded_creds() + { + var sourceItem = CreateSourceItem(embedPassword: true, useOAuthManagedKeychain: true); + + var context = CreateContext(sourceItem, Create()); + + var associatedUserIds = CreateMany(); + SetupRetrieveKeychainAsync(CreateMany(), associatedUserIds); + SetupApplyKeychainAsync(); + SetupRetrieveUserSavedCredentialsAsync(); + SetupUploadUserSavedCredentialsAsync(); + + var result = await Hook.ExecuteAsync(context, Cancel); + + AssertSuccess(context, result); + + MockDestinationEndpoint.VerifyAll(); + MockLogger.VerifyWarnings(Times.Once); + + MockUserContentFinder.Verify( + ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny()), + Times.Exactly(associatedUserIds.Count())); + } + + [Fact] + public async Task Migrates_OAuth_embedded_creds() + { + var sourceItem = CreateSourceItem(embedPassword: true); + + var context = CreateContext(sourceItem, Create()); + var associatedUserIds = CreateMany(); + + SetupRetrieveKeychainAsync(CreateMany(), associatedUserIds); + SetupApplyKeychainAsync(); + SetupRetrieveUserSavedCredentialsAsync(); + SetupUploadUserSavedCredentialsAsync(); + + var result = await Hook.ExecuteAsync(context, Cancel); + + AssertSuccess(context, result); + + MockDestinationEndpoint.VerifyAll(); + var userCount = associatedUserIds.Count(); + + MockUserContentFinder.Verify( + ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny()), Times.Exactly(userCount)); + MockDestinationEndpoint.Verify( + mde => mde.UploadUserSavedCredentialsAsync(It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Exactly(userCount)); + + MockSourceEndpoint.Verify( + mse => mse.RetrieveUserSavedCredentialsAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Exactly(userCount)); + } + + + [Fact] + public async Task Migrates_OAuth_embedded_creds_using_cache() + { + var sourceItem = CreateSourceItem(embedPassword: true); + + var context = CreateContext(sourceItem, Create()); + var associatedUserIds = CreateMany(); + + SetupRetrieveKeychainAsync(CreateMany(), associatedUserIds); + SetupApplyKeychainAsync(); + SetupRetrieveUserSavedCredentialsAsync(); + SetupUploadUserSavedCredentialsAsync(); + + foreach (var item in associatedUserIds) + { + MockUserSavedCredentialsCache.Setup(x => x.Get(item)).Returns(Create()); + } + var result = await Hook.ExecuteAsync(context, Cancel); + + AssertSuccess(context, result); + + MockDestinationEndpoint.VerifyAll(); + var userCount = associatedUserIds.Count(); + + MockUserContentFinder.Verify( + ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny()), Times.Exactly(userCount)); + MockDestinationEndpoint.Verify( + mde => mde.UploadUserSavedCredentialsAsync(It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Exactly(userCount)); + + MockSourceEndpoint.Verify( + mse => mse.RetrieveUserSavedCredentialsAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + + MockUserSavedCredentialsCache.Verify( + musc => musc.AddOrUpdate(It.IsAny(), It.IsAny()) + , Times.Never); + + } + + [Fact] + public async Task Migrates_OAuth_embedded_creds_and_updates_cache() + { + var sourceItem = CreateSourceItem(embedPassword: true); + + var context = CreateContext(sourceItem, Create()); + var associatedUserIds = CreateMany(); + + SetupRetrieveKeychainAsync(CreateMany(), associatedUserIds); + SetupApplyKeychainAsync(); + SetupRetrieveUserSavedCredentialsAsync(); + SetupUploadUserSavedCredentialsAsync(); + + var result = await Hook.ExecuteAsync(context, Cancel); + + AssertSuccess(context, result); + + MockDestinationEndpoint.VerifyAll(); + var userCount = associatedUserIds.Count(); + + MockUserContentFinder.Verify( + ucf => ucf.FindBySourceIdAsync(It.IsAny(), It.IsAny()), Times.Exactly(userCount)); + MockDestinationEndpoint.Verify( + mde => mde.UploadUserSavedCredentialsAsync(It.IsAny(), It.IsAny>(), It.IsAny()), + Times.Exactly(userCount)); + + MockSourceEndpoint.Verify( + mse => mse.RetrieveUserSavedCredentialsAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Exactly(userCount)); + + MockUserSavedCredentialsCache.Verify( + musc => musc.Get(It.IsAny()), + Times.Exactly(userCount)); + + MockUserSavedCredentialsCache.Verify( + muscc => muscc.AddOrUpdate(It.IsAny(), It.IsAny()), + Times.Exactly(userCount)); + + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/SubscriptionsCapabilityManagerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/SubscriptionsCapabilityManagerTests.cs new file mode 100644 index 00000000..1789021e --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/SubscriptionsCapabilityManagerTests.cs @@ -0,0 +1,144 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Paging; +using Tableau.Migration.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Hooks +{ + public class SubscriptionsCapabilityManagerTests : AutoFixtureTestBase + { + internal readonly SubscriptionsCapabilityManager SubscriptionsCapabilityManager; + internal readonly MigrationCapabilities MigrationCapabilities = new(); + internal readonly Mock> MockScheduleValidator = new(); + internal readonly Mock MockDestinationEndpoint = new(); + internal readonly Mock MockLocalizer = new(); + internal readonly Mock> MockLogger = new(); + + public SubscriptionsCapabilityManagerTests() + { + SubscriptionsCapabilityManager = new SubscriptionsCapabilityManager( + MockDestinationEndpoint.Object, MockScheduleValidator.Object, MigrationCapabilities, + MockLocalizer.Object, MockLogger.Object); + } + + protected void SetupDeleteCloudSubscription() + { + MockDestinationEndpoint.Setup(de + => de.DeleteAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded())); + } + + protected void SetupCreateSubscriptionSuccess(ICloudSubscription subscription) + { + MockDestinationEndpoint.Setup(de + => de.PublishAsync( + It.IsAny(), It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Succeeded(subscription))); + } + + protected void SetupCreateSubscriptionFailure(Exception exception) + { + MockDestinationEndpoint.Setup(de + => de.PublishAsync( + It.IsAny(), It.IsAny())) + .Returns(Task.FromResult((IResult)Result.Failed(exception))); + } + + protected void SetupDestinationPager(ImmutableArray workbooks) + { + MockDestinationEndpoint.Setup(de => de.GetPager(It.IsAny())) + .Returns((int pageSize) => new MemoryPager(workbooks, pageSize)); + } + + private void SetupScheduleValidator() + { + MockScheduleValidator.Setup(sv => sv.Validate(It.IsAny())).Verifiable(); + } + + public class SetMigrationCapabilityAsync : SubscriptionsCapabilityManagerTests + { + [Fact] + public async Task True_when_subscriptions_enabled() + { + var workbooks = AutoFixture.CreateMany().ToImmutableArray(); + var subscription = AutoFixture.Create(); + + SetupScheduleValidator(); + + SetupDestinationPager(workbooks); + + SetupCreateSubscriptionSuccess(subscription); + + SetupDeleteCloudSubscription(); + + var result = await SubscriptionsCapabilityManager.SetMigrationCapabilityAsync(new CancellationToken()); + + Assert.NotNull(result); + Assert.Empty(MigrationCapabilities.ContentTypesDisabledAtDestination); + Assert.DoesNotContain(typeof(IServerSubscription), MigrationCapabilities.ContentTypesDisabledAtDestination); + + } + + [Fact] + public async Task False_when_subscriptions_disabled() + { + var workbooks = AutoFixture.CreateMany().ToImmutableArray(); + var subscription = AutoFixture.Create(); + + SetupScheduleValidator(); + + SetupDestinationPager(workbooks); + + var error = new RestException( + HttpMethod.Post, + new Uri("http://dummy/uri"), + Guid.NewGuid().ToString(), + new Migration.Api.Rest.Models.Error() { Code = RestErrorCodes.GENERIC_CREATE_SUBSCRIPTION_ERROR }, + string.Empty, + "Subscriptions not enabled"); + + SetupCreateSubscriptionFailure(error); + + SetupDeleteCloudSubscription(); + + var result = await SubscriptionsCapabilityManager.SetMigrationCapabilityAsync(new CancellationToken()); + + Assert.NotNull(result); + Assert.NotEmpty(MigrationCapabilities.ContentTypesDisabledAtDestination); + Assert.Contains(typeof(IServerSubscription), MigrationCapabilities.ContentTypesDisabledAtDestination); + + } + + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/ContentTransformerRunnerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/ContentTransformerRunnerTests.cs index 76483534..d33e533f 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/ContentTransformerRunnerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/ContentTransformerRunnerTests.cs @@ -39,8 +39,8 @@ public class ContentTransformerRunnerTests public class TestTransformer : ContentTransformerBase { public TestTransformer( - ISharedResourcesLocalizer localizer, - ILogger logger) + ISharedResourcesLocalizer localizer, + ILogger logger) : base(localizer, logger) { } @@ -55,7 +55,7 @@ public class ExceptionTransformer : ContentTransformerBase { public ExceptionTransformer( ISharedResourcesLocalizer localizer, - ILogger logger) + ILogger logger) : base(localizer, logger) { } public override Task TransformAsync(TestContentType itemToTransform, CancellationToken cancel) @@ -79,7 +79,8 @@ public class ExecuteAsync : AutoFixtureTestBase private readonly ContentTransformerRunner _runner; private readonly MockSharedResourcesLocalizer _mockLocalizer = new(); - private readonly Mock> _mockLogger = new(); + private readonly Mock> _mockTransformerLogger = new(); + private readonly Mock> _mockRunnerLogger = new(); public ExecuteAsync() { @@ -94,9 +95,10 @@ public ExecuteAsync() mockPlan.SetupGet(x => x.Transformers).Returns(mockTransformers.Object); _plan = mockPlan.Object; Assert.NotNull(_plan.Transformers); - _runner = new(_plan, new Mock().Object); - _mockLogger.Setup(x => x.IsEnabled(LogLevel.Debug)).Returns(true); + _mockTransformerLogger.Setup(x => x.IsEnabled(LogLevel.Debug)).Returns(true); + + _runner = new(_plan, new Mock().Object, _mockLocalizer.Object, _mockRunnerLogger.Object); } [Fact] @@ -105,7 +107,7 @@ public async Task SingleTransformerSingleItem() // Arrange var input = AutoFixture.Create(); - _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockLogger.Object))); + _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockTransformerLogger.Object))); // Act var result = await _runner.ExecuteAsync(input, default); @@ -115,13 +117,7 @@ public async Task SingleTransformerSingleItem() Assert.Single(Regex.Matches(result.ContentUrl, "Transformed")); // Verify we got at least 1 debug log message - _mockLogger.Verify( - x => x.Log( - It.Is(l => l == LogLevel.Debug), - It.IsAny(), - It.Is((v, t) => true), - It.IsAny(), - It.Is>((v, t) => true))); + _mockRunnerLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); } [Fact] @@ -130,8 +126,8 @@ public async Task MultipleTransformerSingleItem() // Arrange var input = AutoFixture.Create(); - _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockLogger.Object))); - _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockLogger.Object))); + _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockTransformerLogger.Object))); + _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockTransformerLogger.Object))); // Act var result = await _runner.ExecuteAsync(input, default); @@ -141,13 +137,7 @@ public async Task MultipleTransformerSingleItem() Assert.Equal(2, Regex.Matches(result.ContentUrl, "Transformed").Count); // Verify we got at least 1 debug log message - _mockLogger.Verify( - x => x.Log( - It.Is(l => l == LogLevel.Debug), - It.IsAny(), - It.Is((v, t) => true), - It.IsAny(), - It.Is>((v, t) => true))); + _mockRunnerLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); } [Fact] @@ -157,7 +147,7 @@ public async Task SingleTransformerMultipleItem() var input1 = AutoFixture.Create(); var input2 = AutoFixture.Create(); - _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockLogger.Object))); + _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockTransformerLogger.Object))); // Act var result1 = await _runner.ExecuteAsync(input1, default); @@ -170,13 +160,7 @@ public async Task SingleTransformerMultipleItem() Assert.Single(Regex.Matches(result2.ContentUrl, "Transformed")); // Verify we got at least 1 debug log message - _mockLogger.Verify( - x => x.Log( - It.Is(l => l == LogLevel.Debug), - It.IsAny(), - It.Is((v, t) => true), - It.IsAny(), - It.Is>((v, t) => true))); + _mockRunnerLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); } [Fact] @@ -186,8 +170,8 @@ public async Task MultipleTransformerMultipleItem() var input1 = AutoFixture.Create(); var input2 = AutoFixture.Create(); - _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockLogger.Object))); - _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockLogger.Object))); + _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockTransformerLogger.Object))); + _transformerFactories.Add(new MigrationHookFactory(s => new TestTransformer(_mockLocalizer.Object, _mockTransformerLogger.Object))); // Act var result1 = await _runner.ExecuteAsync(input1, default); @@ -200,13 +184,7 @@ public async Task MultipleTransformerMultipleItem() Assert.Equal(2, Regex.Matches(result1.ContentUrl, "Transformed").Count); // Verify we got at least 1 debug log message - _mockLogger.Verify( - x => x.Log( - It.Is(l => l == LogLevel.Debug), - It.IsAny(), - It.Is((v, t) => true), - It.IsAny(), - It.Is>((v, t) => true))); + _mockRunnerLogger.VerifyLogging(LogLevel.Debug, Times.AtLeastOnce()); } [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudAuthenticationTypeTransformerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudAuthenticationTypeTransformerTests.cs index 5368c326..c0a88c9a 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudAuthenticationTypeTransformerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudAuthenticationTypeTransformerTests.cs @@ -15,11 +15,15 @@ // limitations under the License. // +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api.Rest.Models.Types; using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers.Default; using Xunit; @@ -29,24 +33,161 @@ public class UserTableauCloudAuthenticationTypeTransformerTests { public class ExecuteAsync : OptionsHookTestBase { + private readonly Mock MockDestinationCache; + + private List AuthenticationConfigurations { get; set; } + + public ExecuteAsync() + { + AuthenticationConfigurations = new(); + + MockDestinationCache = Freeze>(); + MockDestinationCache.Setup(x => x.GetAllAsync(Cancel)) + .ReturnsAsync(() => AuthenticationConfigurations.ToImmutableArray()); + } + [Fact] public async Task SetsServerDefaultAuthType() { + AuthenticationConfigurations.Clear(); + + var t = Create(); + var mockUser = new Mock(); - var mockLogger = new Mock>(); - var mockLocalizer = new MockSharedResourcesLocalizer(); + var resultUser = await t.ExecuteAsync(mockUser.Object, Cancel); + + Assert.Same(resultUser, mockUser.Object); + mockUser.VerifySet(x => x.Authentication = UserAuthenticationType.ForAuthenticationType(AuthenticationTypes.ServerDefault), Times.Once); + + MockDestinationCache.Verify(x => x.GetAllAsync(Cancel), Times.Once); + } + [Fact] + public async Task SetsAuthSettingAsync() + { + AuthenticationConfigurations.Clear(); Options = new UserAuthenticationTypeTransformerOptions { AuthenticationType = AuthenticationTypes.TableauIdWithMfa }; - var t = new UserAuthenticationTypeTransformer(MockOptionsProvider.Object, mockLocalizer.Object, mockLogger.Object); + var t = Create(); + + var mockUser = new Mock(); + var resultUser = await t.ExecuteAsync(mockUser.Object, Cancel); + + Assert.Same(resultUser, mockUser.Object); + mockUser.VerifySet(x => x.Authentication = UserAuthenticationType.ForAuthenticationType(AuthenticationTypes.TableauIdWithMfa), Times.Once); + + MockDestinationCache.Verify(x => x.GetAllAsync(Cancel), Times.Once); + } + + [Fact] + public async Task MatchesIdpConfigurationNameAsync() + { + AuthenticationConfigurations = CreateMany().ToList(); + var authType = AuthenticationConfigurations.PickRandom(); + + Options = new UserAuthenticationTypeTransformerOptions + { + AuthenticationType = authType.IdpConfigurationName + }; + + var t = Create(); + + var mockUser = new Mock(); + var resultUser = await t.ExecuteAsync(mockUser.Object, Cancel); + + Assert.Same(resultUser, mockUser.Object); + mockUser.VerifySet(x => x.Authentication = UserAuthenticationType.ForConfigurationId(authType.Id), Times.Once); + + MockDestinationCache.Verify(x => x.GetAllAsync(Cancel), Times.Once); + } + + [Fact] + public async Task MatchesAuthSettingAsync() + { + AuthenticationConfigurations = CreateMany().ToList(); + var authType = AuthenticationConfigurations.PickRandom(); + + Options = new UserAuthenticationTypeTransformerOptions + { + AuthenticationType = authType.AuthSetting + }; + + var t = Create(); - var resultUser = await t.ExecuteAsync(mockUser.Object, default); + var mockUser = new Mock(); + var resultUser = await t.ExecuteAsync(mockUser.Object, Cancel); Assert.Same(resultUser, mockUser.Object); - mockUser.VerifySet(x => x.AuthenticationType = AuthenticationTypes.TableauIdWithMfa, Times.Once); + mockUser.VerifySet(x => x.Authentication = UserAuthenticationType.ForConfigurationId(authType.Id), Times.Once); + + MockDestinationCache.Verify(x => x.GetAllAsync(Cancel), Times.Once); + } + + [Fact] + public async Task MultipleIdpConfigurationNamesAsync() + { + AuthenticationConfigurations = CreateMany().ToList(); + var authType = AuthenticationConfigurations.PickRandom(); + + AuthenticationConfigurations.Add(authType); + Options = new UserAuthenticationTypeTransformerOptions + { + AuthenticationType = authType.IdpConfigurationName + }; + + var t = Create(); + + var mockUser = new Mock(); + await Assert.ThrowsAsync(() => t.ExecuteAsync(mockUser.Object, Cancel)); + + mockUser.VerifySet(x => x.Authentication = It.IsAny(), Times.Never); + + MockDestinationCache.Verify(x => x.GetAllAsync(Cancel), Times.Once); + } + + [Fact] + public async Task MultipleAuthSettingsAsync() + { + AuthenticationConfigurations = CreateMany().ToList(); + var authType = AuthenticationConfigurations.PickRandom(); + + AuthenticationConfigurations.Add(authType); + Options = new UserAuthenticationTypeTransformerOptions + { + AuthenticationType = authType.AuthSetting + }; + + var t = Create(); + + var mockUser = new Mock(); + await Assert.ThrowsAsync(() => t.ExecuteAsync(mockUser.Object, Cancel)); + + mockUser.VerifySet(x => x.Authentication = It.IsAny(), Times.Never); + + MockDestinationCache.Verify(x => x.GetAllAsync(Cancel), Times.Once); + } + + [Fact] + public async Task NoMatchAsync() + { + AuthenticationConfigurations = CreateMany().ToList(); + + Options = new UserAuthenticationTypeTransformerOptions + { + AuthenticationType = Guid.NewGuid().ToString() + }; + + var t = Create(); + + var mockUser = new Mock(); + await Assert.ThrowsAsync(() => t.ExecuteAsync(mockUser.Object, Cancel)); + + mockUser.VerifySet(x => x.Authentication = It.IsAny(), Times.Never); + + MockDestinationCache.Verify(x => x.GetAllAsync(Cancel), Times.Once); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudSiteRoleTransformerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudSiteRoleTransformerTests.cs index f9a6afb0..597e7fd7 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudSiteRoleTransformerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/UserTableauCloudSiteRoleTransformerTests.cs @@ -28,6 +28,7 @@ using Tableau.Migration.Engine.Hooks; using Tableau.Migration.Engine.Hooks.Transformers; using Tableau.Migration.Engine.Hooks.Transformers.Default; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Transformers.Default @@ -56,7 +57,7 @@ public UserTableauCloudSiteRoleTransformerTests() mockPlan.SetupGet(x => x.Transformers).Returns(mockTransformers.Object); _plan = mockPlan.Object; Assert.NotNull(_plan.Transformers); - _runner = new(_plan, new Mock().Object); + _runner = new(_plan, new Mock().Object, new Mock().Object, new Mock>().Object); } [Theory] diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/IServiceCollectionExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/IServiceCollectionExtensionsTests.cs index b63e6214..e523fd7b 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/IServiceCollectionExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/IServiceCollectionExtensionsTests.cs @@ -23,8 +23,16 @@ using Tableau.Migration.Api; using Tableau.Migration.Content; using Tableau.Migration.Content.Files; +using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Content.Search; +using Tableau.Migration.ContentConverters.Schedules; using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Conversion; +using Tableau.Migration.Engine.Conversion.ExtractRefreshTasks; +using Tableau.Migration.Engine.Conversion.Schedules; +using Tableau.Migration.Engine.Conversion.Subscriptions; using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks; @@ -61,6 +69,14 @@ protected override void ConfigureServices(IServiceCollection services) mockScopedClientFactory.Setup(x => x.Initialize(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(mockApiClient.Object); + var mockMigrationPlan = Freeze>(); + mockMigrationPlan.SetupGet(x => x.PipelineProfile).Returns(PipelineProfile.ServerToCloud); + AutoFixture.Register(() => mockMigrationPlan.Object); + + var mockMigrationManifest = Freeze>(); + mockMigrationManifest.SetupGet(x => x.PipelineProfile).Returns(PipelineProfile.ServerToCloud); + AutoFixture.Register(() => mockMigrationManifest.Object); + services.AddLogging() .AddLocalization() .AddSharedResourcesLocalization() @@ -87,7 +103,7 @@ protected async Task InitializeMigrationScopeAsync(IMigration migration.Source.InitializeAsync(Cancel), migration.Destination.InitializeAsync(Cancel) }; - + await Task.WhenAll(endpointInitTasks).ConfigureAwait(false); return scope; @@ -231,6 +247,26 @@ public async Task RegistersScopedContentMigratorAsync() AssertService>(scope, ServiceLifetime.Scoped); } + [Fact] + public async Task RegistersSingletonScheduleValidatorsAsync() + { + await using var scope = await InitializeMigrationScopeAsync(); + + AssertService, ServerScheduleValidator>(scope, ServiceLifetime.Singleton); + AssertService, CloudScheduleValidator>(scope, ServiceLifetime.Singleton); + } + + [Fact] + public async Task RegistersSingletonConvertersAsync() + { + await using var scope = await InitializeMigrationScopeAsync(); + + AssertService>(scope, ServiceLifetime.Singleton); + AssertService, ServerToCloudScheduleConverter>(scope, ServiceLifetime.Singleton); + AssertService, ServerToCloudExtractRefreshTaskConverter>(scope, ServiceLifetime.Singleton); + AssertService, ServerToCloudSubscriptionConverter>(scope, ServiceLifetime.Singleton); + } + [Fact] public async Task RegistersScopedSourcePreparerAsync() { @@ -244,7 +280,7 @@ public async Task RegistersScopedEndpointPreparerAsync() { await using var scope = await InitializeMigrationScopeAsync(); - AssertService>(scope, ServiceLifetime.Scoped); + AssertService>(scope, ServiceLifetime.Scoped); } [Fact] @@ -270,7 +306,7 @@ public async Task RegistersScopedBulkBatchMigratorAsync() await using var scope = await InitializeMigrationScopeAsync(); AssertService>(scope, ServiceLifetime.Scoped); - AssertService>(scope, ServiceLifetime.Scoped); + AssertService>(scope, ServiceLifetime.Scoped); } [Fact] @@ -360,6 +396,14 @@ public async Task RegistersScopedPreviouslyMigratedFilterAsync() AssertService>(scope, ServiceLifetime.Scoped); } + + [Fact] + public async Task RegistersScopedDestinationAuthenticationConfigurationCacheAsync() + { + await using var scope = await InitializeMigrationScopeAsync(); + + AssertService(scope, ServiceLifetime.Scoped); + } } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/LoggingMigrationManifestTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/LoggingMigrationManifestTests.cs new file mode 100644 index 00000000..887eaee5 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/LoggingMigrationManifestTests.cs @@ -0,0 +1,90 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Microsoft.Extensions.Logging; +using Moq; +using Tableau.Migration.Engine.Manifest; +using Tableau.Migration.Engine.Manifest.Logging; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Manifest +{ + public sealed class LoggingMigrationManifestTests + { + #region - Test Classes - + + public class LoggingMigrationManifestTest : AutoFixtureTestBase + { + protected readonly MockSharedResourcesLocalizer MockLocalizer; + protected readonly Mock MockLoggerFactory; + protected readonly Mock> MockLogger; + + protected readonly LoggingMigrationManifest Manifest; + + public LoggingMigrationManifestTest() + { + MockLocalizer = new(); + + MockLogger = Create>>(); + MockLoggerFactory = Freeze>(); + MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())) + .Returns(MockLogger.Object); + + Manifest = new(MockLocalizer.Object, MockLoggerFactory.Object, Guid.NewGuid(), Guid.NewGuid(), PipelineProfile.ServerToCloud); + } + } + + #endregion + + #region - AddErrors - + + public class AddErrors : LoggingMigrationManifestTest + { + [Fact] + public void AddSingleError() + { + var exception = new Exception(); + + var result = Manifest.AddErrors(exception); + + Assert.Same(result, Manifest); + var resultItem = Assert.Single(Manifest.Errors); + Assert.Same(exception, resultItem); + + MockLogger.VerifyErrors(Times.Once); + } + + [Fact] + public void AddMultipleErrors() + { + var exceptions = new[] { new Exception(), new Exception() }; + + var result = Manifest.AddErrors(exceptions); + + Assert.Same(result, Manifest); + Assert.Equal(2, Manifest.Errors.Count); + Assert.Contains(exceptions[0], Manifest.Errors); + Assert.Contains(exceptions[1], Manifest.Errors); + + MockLogger.VerifyErrors(Times.Exactly(2)); + } + } + + #endregion + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs index a5b3e011..647505dc 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs @@ -17,13 +17,11 @@ using System; using System.Collections; -using System.Collections.Concurrent; using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; using AutoFixture; -using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine; @@ -39,21 +37,15 @@ public class MigrationManifestContentTypePartitionTests public class MigrationManifestContentTypePartitionTest : AutoFixtureTestBase { - protected readonly MockSharedResourcesLocalizer MockLocalizer; - protected readonly Mock> MockLogger; - protected readonly MigrationManifestContentTypePartition Partition; public MigrationManifestContentTypePartitionTest() { - MockLocalizer = new(); - MockLogger = Freeze>>(); - Partition = CreateEmpty(); } protected MigrationManifestContentTypePartition CreateEmpty() - => new(typeof(T), MockLocalizer.Object, MockLogger.Object); + => new(typeof(T)); protected void AssertCorrectTotals() { @@ -79,7 +71,7 @@ public class Ctor : MigrationManifestContentTypePartitionTest public void Initializes() { var t = typeof(TestContentType); - var partition = new MigrationManifestContentTypePartition(t, MockLocalizer.Object, MockLogger.Object); + var partition = new MigrationManifestContentTypePartition(t); Assert.Same(t, partition.ContentType); } @@ -151,7 +143,7 @@ public void CreatesFromSourceItems() var sourceItems = CreateMany().ToImmutableArray(); var expectedTotalCount = 2 * sourceItems.Length; - var results = Partition.CreateEntries(sourceItems, + var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry), expectedTotalCount); Assert.Equal(sourceItems.Length, Partition.Count); @@ -180,7 +172,7 @@ public void CreatesFromSourceItemsWithEmptyContentUrl() var sourceItems = CreateMany().ToImmutableArray(); var expectedTotalCount = 2 * sourceItems.Length; - var results = Partition.CreateEntries(sourceItems, + var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry), expectedTotalCount); Assert.Equal(sourceItems.Length, Partition.Count); @@ -188,7 +180,7 @@ public void CreatesFromSourceItemsWithEmptyContentUrl() Assert.Equal(expectedTotalCount, Partition.ExpectedTotalCount); Assert.Empty(Partition.BySourceContentUrl); - + AssertCorrectTotals(); } @@ -208,7 +200,7 @@ public void UpdatesSourceReferenceFromPreviousManifest() return newSourceItem; }).ToImmutableArray(); - var results = Partition.CreateEntries(sourceItems, + var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry), previous.Length); Assert.All(sourceItems, i => @@ -250,7 +242,7 @@ public void UpdatesSourceReferenceFromPreviousManifestWithEmptyContentUrl() return newSourceItem; }).ToImmutableArray(); - var results = Partition.CreateEntries(sourceItems, + var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry), previous.Length); Assert.Empty(Partition.BySourceContentUrl); @@ -447,12 +439,14 @@ public void Skipped() var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry), 0); - results[0].Entry.SetSkipped(); + string reason = "Test Skip"; + results[0].Entry.SetSkipped(reason); var statusTotals = Partition.GetStatusTotals(); Assert.Equal(sourceItems.Length - 1, statusTotals[MigrationManifestEntryStatus.Pending]); Assert.Equal(1, statusTotals[MigrationManifestEntryStatus.Skipped]); + Assert.Same(reason, results[0].Entry.SkippedReason); } [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs index b771885d..c8af973d 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs @@ -20,7 +20,6 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; -using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine.Manifest; @@ -34,24 +33,14 @@ public class MigrationManifestEntryCollectionTests public class MigrationManifestEntryCollectionTest : AutoFixtureTestBase { - protected readonly MockSharedResourcesLocalizer MockLocalizer; - protected readonly Mock MockLoggerFactory; - protected readonly Mock> MockPartitionLogger; - protected readonly MigrationManifestEntryCollection Collection; public MigrationManifestEntryCollectionTest() { - MockLocalizer = new(); - - MockPartitionLogger = Freeze>>(); - MockLoggerFactory = Freeze>(); - Collection = CreateEmpty(); } - protected MigrationManifestEntryCollection CreateEmpty() - => new(MockLocalizer.Object, MockLoggerFactory.Object); + protected MigrationManifestEntryCollection CreateEmpty() => new(); } #endregion @@ -63,7 +52,7 @@ public sealed class Ctor : MigrationManifestEntryCollectionTest [Fact] public void IntializesEmpty() { - var c = new MigrationManifestEntryCollection(MockLocalizer.Object, MockLoggerFactory.Object); + var c = new MigrationManifestEntryCollection(); Assert.Empty(c); } @@ -79,7 +68,7 @@ public void CopiesFromPreviousEntries() e.GetOrCreatePartition(typeof(IWorkbook)).CreateEntries(previousEntries); }); - var c = new MigrationManifestEntryCollection(MockLocalizer.Object, MockLoggerFactory.Object, mockPreviousCollection.Object); + var c = new MigrationManifestEntryCollection(mockPreviousCollection.Object); mockPreviousCollection.Verify(x => x.CopyTo(c), Times.Once); Assert.Equal(previousEntries.Length, c.Count()); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs index bfc52ede..390059a0 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs @@ -224,11 +224,13 @@ public sealed class SetSkipped : MigrationManifestEntryTest public void SetsStatus() { var e = new MigrationManifestEntry(MockEntryBuilder.Object, Create()); + string reason = "Test Skip"; - var e2 = e.SetSkipped(); + var e2 = e.SetSkipped(reason); Assert.Same(e, e2); Assert.Equal(MigrationManifestEntryStatus.Skipped, e.Status); + Assert.Same(reason, e.SkippedReason); MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Pending), Times.Once); } @@ -490,15 +492,17 @@ public sealed class ResetStatus : MigrationManifestEntryTest [Fact] public void ResetsStatus() { + string reason = "Test Skip"; var sourceRef = Create(); var e = new MigrationManifestEntry(MockEntryBuilder.Object, sourceRef); - e.SetSkipped(); + e.SetSkipped(reason); var result = e.ResetStatus(); Assert.Same(e, result); Assert.Equal(MigrationManifestEntryStatus.Pending, e.Status); + Assert.Empty(e.SkippedReason); MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Skipped), Times.Once); } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestFactoryTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestFactoryTests.cs index 25b080b6..814f3255 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestFactoryTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestFactoryTests.cs @@ -18,7 +18,6 @@ using System; using System.Collections.Immutable; using System.Linq; -using AutoFixture; using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Engine; @@ -56,6 +55,7 @@ public Create() _planId = Guid.NewGuid(); _mockInput.SetupGet(x => x.Plan.PlanId).Returns(_planId); + _mockInput.SetupGet(x => x.Plan.PipelineProfile).Returns(PipelineProfile.ServerToCloud); _mockInput.SetupGet(x => x.MigrationId).Returns(_migrationId); } @@ -63,8 +63,7 @@ public Create() public void InitializesEmptyManifestWithInput() { // Customize the AutoFixture instance to remove the customization for IMigrationManifest that was created from AutoFixtureTestBase/FixtureFactory - AutoFixture.Customize(c => c.FromFactory(() => - new MigrationManifest(AutoFixture.Create(), AutoFixture.Create(), Guid.NewGuid(), Guid.NewGuid()))); + AutoFixture.Customize(c => c.FromFactory(() => new MigrationManifest(PipelineProfile.ServerToCloud))); var manifest = _factory.Create(_mockInput.Object, _migrationId); @@ -77,7 +76,7 @@ public void InitializesEmptyManifestWithInput() [Fact] public void InitializesEmptyManifestWithoutInput() { - var manifest = _factory.Create(_planId, _migrationId); + var manifest = _factory.Create(_planId, _migrationId, PipelineProfile.ServerToCloud); Assert.Equal(_planId, manifest.PlanId); Assert.Equal(_migrationId, manifest.MigrationId); @@ -97,8 +96,8 @@ public void CreatesInstancesWithInput() [Fact] public void CreatesInstancesWithoutInput() { - var manifest1 = _factory.Create(_planId, _migrationId); - var manifest2 = _factory.Create(_planId, _migrationId); + var manifest1 = _factory.Create(_planId, _migrationId, PipelineProfile.ServerToCloud); + var manifest2 = _factory.Create(_planId, _migrationId, PipelineProfile.ServerToCloud); Assert.NotSame(manifest1, manifest2); } @@ -108,7 +107,8 @@ public void CopiesFromPreviousManifest() { var previousEntries = CreateMany().ToImmutableArray(); - var previousManifest = Create(); + var previousManifest = new MigrationManifest(PipelineProfile.ServerToCloud); + previousManifest.Entries .GetOrCreatePartition() .CreateEntries(previousEntries); @@ -119,6 +119,22 @@ public void CopiesFromPreviousManifest() Assert.Equal(previousManifest.Entries.Count(), manifest.Entries.Count()); } + + [Fact] + public void ErrorWhenCopiesFromPreviousManifestWithWrongProfile() + { + var previousEntries = CreateMany().ToImmutableArray(); + + var previousManifest = new MigrationManifest(PipelineProfile.ServerToServer); + + previousManifest.Entries + .GetOrCreatePartition() + .CreateEntries(previousEntries); + + _mockInput.SetupGet(x => x.PreviousManifest).Returns(previousManifest); + + Assert.Throws(() => _factory.Create(_mockInput.Object, _migrationId)); + } } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/TestMigrationManifestSerializer.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestSerializerTests.cs similarity index 60% rename from tests/Tableau.Migration.Tests/Unit/Engine/Manifest/TestMigrationManifestSerializer.cs rename to tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestSerializerTests.cs index 63689ac1..15a283af 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/TestMigrationManifestSerializer.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestSerializerTests.cs @@ -23,15 +23,13 @@ using System.Threading; using System.Threading.Tasks; using AutoFixture; -using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Engine.Manifest; -using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Manifest { - public class TestMigrationManifestSerializer : AutoFixtureTestBase + public class MigrationManifestSerializerTests : AutoFixtureTestBase { // If you need to debug these tests and you need access to the file that is saved and loaded, // you need to make some temporary changes to this file. @@ -44,7 +42,7 @@ public class TestMigrationManifestSerializer : AutoFixtureTestBase // The actual tests also a the temp file to save the manifest to. You can change that to a real filepath // so it's easier to find during the actual debugging. - public TestMigrationManifestSerializer() + public MigrationManifestSerializerTests() { AutoFixture.Register(() => new MockFileSystem()); } @@ -55,7 +53,7 @@ public async Task ManifestSaveLoadAsync() // Arrange var manifest = Create(); - var tempFile = Path.GetTempFileName(); + using var tempFile = new TempFile(); // Verify that the test infra is working Assert.True(manifest.Entries.Any()); @@ -65,8 +63,8 @@ public async Task ManifestSaveLoadAsync() var cancel = new CancellationToken(); // Act - await serializer.SaveAsync(manifest, tempFile); - var loadedManifest = await serializer.LoadAsync(tempFile, cancel); + await serializer.SaveAsync(manifest, tempFile.FilePath); + var loadedManifest = await serializer.LoadAsync(tempFile.FilePath, cancel); // Assert Assert.NotNull(loadedManifest); @@ -76,20 +74,66 @@ public async Task ManifestSaveLoadAsync() [Fact] public async Task ManifestSaveLoad_DifferentVersionAsync() { - var localizer = Create(); - var logFactory = Create(); - - var mockManifest = new Mock(localizer, logFactory, Guid.NewGuid(), Guid.NewGuid(), null) { CallBase = true }; + var mockManifest = new Mock(Guid.NewGuid(), Guid.NewGuid(), PipelineProfile.ServerToCloud, null) { CallBase = true }; mockManifest.Setup(m => m.ManifestVersion).Returns(1); var serializer = Create(); var cancel = new CancellationToken(); // Save manifest V2, then try to load with the MigrationManifestSerializer that only supports V1 - var tempFile = Path.GetTempFileName(); - await serializer.SaveAsync(mockManifest.Object, tempFile); + using var tempFile = new TempFile(); + await serializer.SaveAsync(mockManifest.Object, tempFile.FilePath); + + await Assert.ThrowsAsync(() => serializer.LoadAsync(tempFile.FilePath, cancel)); + } + + [Fact] + public async Task ManifestSaveLoad_WithoutPipeline() + { + // Manifest without a pipeline profile + string emptyManifest = @" +{ + ""PlanId"": ""f036f386-3987-41ad-a5a8-d4e73a0e068f"", + ""MigrationId"": ""5823024a-c391-4294-8c47-eec4b729a4ee"", + ""Errors"": [ ], + ""Entries"": {}, + ""ManifestVersion"": 4 +}"; + + using var tempFile = new TempFile(); + + // Save the JSON string to the temp file + File.WriteAllText(tempFile.FilePath, emptyManifest); + + // Use a real file system for this specific test + var realFileSystem = new FileSystem(); + var serializer = new MigrationManifestSerializer(realFileSystem); + var cancel = new CancellationToken(); + + // Act + var loadedManifest = await serializer.LoadAsync(tempFile.FilePath, cancel); + + // Assert + Assert.NotNull(loadedManifest); + Assert.Equal(PipelineProfile.ServerToCloud, loadedManifest.PipelineProfile); + } + } - await Assert.ThrowsAsync(() => serializer.LoadAsync(tempFile, cancel)); + public class TempFile : IDisposable + { + public string FilePath { get; } + + public TempFile() + { + FilePath = Path.GetTempFileName(); + } + + public void Dispose() + { + if (File.Exists(FilePath)) + { + File.Delete(FilePath); + } } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestTests.cs index 823876fa..8a0b6c69 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestTests.cs @@ -16,7 +16,6 @@ // using System; -using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Engine.Manifest; using Xunit; @@ -29,22 +28,11 @@ public class MigrationManifestTests public class MigrationManifestTest : AutoFixtureTestBase { - protected readonly MockSharedResourcesLocalizer MockLocalizer; - protected readonly Mock MockLoggerFactory; - protected readonly Mock> MockLogger; - protected readonly MigrationManifest Manifest; public MigrationManifestTest() { - MockLocalizer = new(); - - MockLogger = Create>>(); - MockLoggerFactory = Freeze>(); - MockLoggerFactory.Setup(x => x.CreateLogger(It.IsAny())) - .Returns(MockLogger.Object); - - Manifest = new(MockLocalizer.Object, MockLoggerFactory.Object, Guid.NewGuid(), Guid.NewGuid()); + Manifest = new(PipelineProfile.ServerToCloud); } } @@ -60,7 +48,7 @@ public void NoPreviousManifest() var planId = Guid.NewGuid(); var migrationId = Guid.NewGuid(); - var m = new MigrationManifest(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, null); + var m = new MigrationManifest(planId, migrationId, PipelineProfile.ServerToCloud); Assert.Equal(planId, m.PlanId); Assert.Equal(migrationId, m.MigrationId); @@ -76,8 +64,9 @@ public void PreviousManifest() var migrationId = Guid.NewGuid(); var mockPreviousManifest = Create>(); + mockPreviousManifest.Setup(x => x.PipelineProfile).Returns(PipelineProfile.ServerToCloud); - var m = new MigrationManifest(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, mockPreviousManifest.Object); + var m = new MigrationManifest(planId, migrationId, PipelineProfile.ServerToCloud, mockPreviousManifest.Object); Assert.Equal(planId, m.PlanId); Assert.Equal(migrationId, m.MigrationId); @@ -116,8 +105,6 @@ public void AddSingleError() Assert.Same(result, Manifest); var resultItem = Assert.Single(Manifest.Errors); Assert.Same(exception, resultItem); - - MockLogger.VerifyErrors(Times.Once); } [Fact] @@ -131,8 +118,6 @@ public void AddMultipleErrors() Assert.Equal(2, Manifest.Errors.Count); Assert.Contains(exceptions[0], Manifest.Errors); Assert.Contains(exceptions[1], Manifest.Errors); - - MockLogger.VerifyErrors(Times.Exactly(2)); } } @@ -148,13 +133,13 @@ public void Equal() var planId = Guid.NewGuid(); var migrationId = Guid.NewGuid(); - var mockEntryCollection = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, null); + var mockEntryCollection = new Mock(null); mockEntryCollection.Setup(c => c.Equals(It.IsAny())).Returns(true); - var mockManifest1 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, null); + var mockManifest1 = new Mock(planId, migrationId, PipelineProfile.ServerToCloud, null); mockManifest1.Setup(m => m.Entries).Returns(mockEntryCollection.Object); - var mockManifest2 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, null); + var mockManifest2 = new Mock(planId, migrationId, PipelineProfile.ServerToCloud, null); mockManifest2.Setup(m => m.Entries).Returns(mockEntryCollection.Object); @@ -176,14 +161,14 @@ public void VersionsAreDifferent() var planId = Guid.NewGuid(); var migrationId = Guid.NewGuid(); - var mockEntryCollection = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, null); + var mockEntryCollection = new Mock(null); mockEntryCollection.Setup(c => c.Equals(It.IsAny())).Returns(true); - var mockManifest1 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, null); + var mockManifest1 = new Mock(planId, migrationId, PipelineProfile.ServerToCloud, null); mockManifest1.Setup(m => m.Entries).Returns(mockEntryCollection.Object); mockManifest1.Setup(m => m.ManifestVersion).Returns(1); - var mockManifest2 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, null); + var mockManifest2 = new Mock(planId, migrationId, PipelineProfile.ServerToCloud, null); mockManifest2.Setup(m => m.Entries).Returns(mockEntryCollection.Object); mockManifest1.Setup(m => m.ManifestVersion).Returns(2); @@ -203,13 +188,13 @@ public void EntriesAreDifferent() var planId = Guid.NewGuid(); var migrationId = Guid.NewGuid(); - var mockEntryCollection = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, null); + var mockEntryCollection = new Mock(null); mockEntryCollection.Setup(c => c.Equals(It.IsAny())).Returns(false); - var mockManifest1 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, null); + var mockManifest1 = new Mock(planId, migrationId, PipelineProfile.ServerToCloud, null); mockManifest1.Setup(m => m.Entries).Returns(mockEntryCollection.Object); - var mockManifest2 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, migrationId, null); + var mockManifest2 = new Mock(planId, migrationId, PipelineProfile.ServerToCloud, null); mockManifest2.Setup(m => m.Entries).Returns(mockEntryCollection.Object); Assert.False(mockManifest1.Object.Equals(mockManifest2.Object)); @@ -228,13 +213,13 @@ public void PlanIdIsDifferent() { var migrationId = Guid.NewGuid(); - var mockEntryCollection = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, null); + var mockEntryCollection = new Mock(null); mockEntryCollection.Setup(c => c.Equals(It.IsAny())).Returns(true); - var mockManifest1 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, Guid.NewGuid(), migrationId, null); + var mockManifest1 = new Mock(Guid.NewGuid(), migrationId, PipelineProfile.ServerToCloud, null); mockManifest1.Setup(m => m.Entries).Returns(mockEntryCollection.Object); - var mockManifest2 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, Guid.NewGuid(), migrationId, null); + var mockManifest2 = new Mock(Guid.NewGuid(), migrationId, PipelineProfile.ServerToCloud, null); mockManifest2.Setup(m => m.Entries).Returns(mockEntryCollection.Object); @@ -256,13 +241,41 @@ public void MigrationIdIsDifferent() { var planId = Guid.NewGuid(); - var mockEntryCollection = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, null); + var mockEntryCollection = new Mock(null); + mockEntryCollection.Setup(c => c.Equals(It.IsAny())).Returns(true); + + var mockManifest1 = new Mock(planId, Guid.NewGuid(), PipelineProfile.ServerToCloud, null); + mockManifest1.Setup(m => m.Entries).Returns(mockEntryCollection.Object); + + var mockManifest2 = new Mock(planId, Guid.NewGuid(), PipelineProfile.ServerToCloud, null); + mockManifest2.Setup(m => m.Entries).Returns(mockEntryCollection.Object); + + + Assert.True(mockManifest1.Object.Equals(mockManifest1.Object)); + + Assert.False(mockManifest1.Object.Equals(mockManifest2.Object)); + Assert.False(mockManifest2.Object.Equals(mockManifest1.Object)); + + Assert.False(mockManifest1.Object == mockManifest2.Object); + Assert.False(mockManifest2.Object == mockManifest1.Object); + + Assert.True(mockManifest1.Object != mockManifest2.Object); + Assert.True(mockManifest2.Object != mockManifest1.Object); + } + + [Fact] + public void PipelineProfileIsDifferent() + { + var planId = Guid.NewGuid(); + var migrationId = Guid.NewGuid(); + + var mockEntryCollection = new Mock(null); mockEntryCollection.Setup(c => c.Equals(It.IsAny())).Returns(true); - var mockManifest1 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, Guid.NewGuid(), null); + var mockManifest1 = new Mock(planId, migrationId, PipelineProfile.ServerToCloud, null); mockManifest1.Setup(m => m.Entries).Returns(mockEntryCollection.Object); - var mockManifest2 = new Mock(MockLocalizer.Object, MockLoggerFactory.Object, planId, Guid.NewGuid(), null); + var mockManifest2 = new Mock(planId, migrationId, PipelineProfile.ServerToServer, null); mockManifest2.Setup(m => m.Entries).Returns(mockEntryCollection.Object); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs index 262f55a3..cb560b66 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs @@ -20,6 +20,7 @@ using System.ComponentModel.DataAnnotations; using System.Linq; using AutoFixture; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api.Simulation; using Tableau.Migration.Content; @@ -27,6 +28,7 @@ using Tableau.Migration.Engine; using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Hooks.ActionCompleted; using Tableau.Migration.Engine.Hooks.Filters; using Tableau.Migration.Engine.Hooks.Filters.Default; using Tableau.Migration.Engine.Hooks.Mappings; @@ -44,6 +46,7 @@ public class MigrationPlanBuilderTests public class MigrationPlanBuilderTest : AutoFixtureTestBase { protected readonly Mock MockOptionsBuilder; + protected readonly Mock MockLoggerFactory; protected readonly Mock MockHookBuilder; protected readonly Mock MockMappingBuilder; protected readonly Mock MockFilterBuilder; @@ -52,6 +55,7 @@ public class MigrationPlanBuilderTest : AutoFixtureTestBase public MigrationPlanBuilderTest() { + MockLoggerFactory = new(); MockOptionsBuilder = Create>(); MockHookBuilder = new() { CallBase = true }; MockMappingBuilder = new() { CallBase = true }; @@ -60,6 +64,7 @@ public MigrationPlanBuilderTest() Builder = new( new TestSharedResourcesLocalizer(), + MockLoggerFactory.Object, Create>().Object, MockOptionsBuilder.Object, MockHookBuilder.Object, @@ -90,6 +95,8 @@ protected void AssertDefaultExtensions() MockTransformerBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); MockTransformerBuilder.Verify(x => x.Add(typeof(WorkbookReferenceTransformer<>), It.IsAny>()), Times.Once); MockTransformerBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); + MockTransformerBuilder.Verify(x => x.Add(typeof(EncryptExtractTransformer<>), It.IsAny>()), Times.Once); + MockTransformerBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); MockHookBuilder.Verify(x => x.Add(typeof(OwnerItemPostPublishHook<,>), It.IsAny>()), Times.Once); MockHookBuilder.Verify(x => x.Add(typeof(PermissionsItemPostPublishHook<,>), It.IsAny>()), Times.Once); @@ -97,6 +104,8 @@ protected void AssertDefaultExtensions() MockHookBuilder.Verify(x => x.Add(typeof(TagItemPostPublishHook<,>), It.IsAny>()), Times.Once); MockHookBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); MockHookBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); + MockHookBuilder.Verify(x => x.Add(typeof(EmbeddedCredentialsItemPostPublishHook<,>), It.IsAny>()), Times.Once); + MockHookBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); } protected void AssertDefaultServerToCloudExtensions() diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/BulkPublishContentBatchMigratorTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/BulkPublishContentBatchMigratorTests.cs index a0d8331e..0f42d879 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/BulkPublishContentBatchMigratorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/BulkPublishContentBatchMigratorTests.cs @@ -30,11 +30,11 @@ namespace Tableau.Migration.Tests.Unit.Engine.Migrators.Batch { public class BulkPublishContentBatchMigratorTests { - public class MigrateBatchAsync : ParallelContentBatchMigratorBatchTestBase + public class MigrateBatchAsync : ParallelContentBatchMigratorBatchTestBase { private readonly Mock _mockDestination; private readonly Mock _mockHookRunner; - private readonly BulkPublishContentBatchMigrator _migrator; + private readonly BulkPublishContentBatchMigrator _migrator; public MigrateBatchAsync() { @@ -47,7 +47,7 @@ public MigrateBatchAsync() It.IsAny>(), It.IsAny())) .ReturnsAsync((BulkPostPublishContext ctx, CancellationToken c) => ctx); - _migrator = Create>(); + _migrator = Create>(); } [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorBaseTests.cs index e910db2b..927c3a5f 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorBaseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorBaseTests.cs @@ -32,7 +32,7 @@ public class ContentBatchMigratorBaseTests { #region - Test Classes - - public class TestContentBatchMigrator : ContentBatchMigratorBase + public class TestContentBatchMigrator : ContentBatchMigratorBase { public Dictionary, IResult> PublishResultOverrides { get; } @@ -71,7 +71,7 @@ protected override Task MigratePreparedItemAsync(ContentMigrationItem + public class MigrateAsync : ContentBatchMigratorTestBase { private readonly TestContentBatchMigrator _batchMigrator; diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorTestBase.cs index 0866f288..b8bfc638 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ContentBatchMigratorTestBase.cs @@ -26,8 +26,9 @@ namespace Tableau.Migration.Tests.Unit.Engine.Migrators.Batch { - public class ContentBatchMigratorTestBase : AutoFixtureTestBase + public class ContentBatchMigratorTestBase : AutoFixtureTestBase where TContent : class + where TPrepare : class where TPublish : class { protected readonly Mock> MockPreparer; @@ -41,7 +42,7 @@ public ContentBatchMigratorTestBase() .ReturnsAsync(() => Result.Succeeded(Create())); var mockPipeline = Freeze>(); - mockPipeline.Setup(x => x.GetItemPreparer()) + mockPipeline.Setup(x => x.GetItemPreparer()) .Returns(MockPreparer.Object); MockManifestEntries = CreateMany>().ToImmutableArray(); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ItemPublishContentBatchMigratorTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ItemPublishContentBatchMigratorTests.cs index 105c12f8..a484b6e4 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ItemPublishContentBatchMigratorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ItemPublishContentBatchMigratorTests.cs @@ -28,7 +28,7 @@ namespace Tableau.Migration.Tests.Unit.Engine.Migrators.Batch { public class ItemPublishContentBatchMigratorTests { - public class MigratePreparedItemAsync : ParallelContentBatchMigratorBatchTestBase + public class MigratePreparedItemAsync : ParallelContentBatchMigratorBatchTestBase { private readonly Mock _mockDestination; private readonly ItemPublishContentBatchMigrator _migrator; diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchBaseTests.cs index 0dd89d6f..574422ad 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchBaseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchBaseTests.cs @@ -32,7 +32,7 @@ namespace Tableau.Migration.Tests.Unit.Engine.Migrators.Batch { public class ParallelContentBatchMigratorBatchBaseTests { - public class TestParallelContentBatchMigrator : ParallelContentBatchMigratorBatchBase + public class TestParallelContentBatchMigrator : ParallelContentBatchMigratorBatchBase { public ContentMigrationBatch? CurrentBatch { get; private set; } @@ -55,7 +55,7 @@ protected override Task MigratePreparedItemAsync(ContentMigrationItem + public class MigrateBatchAsync : ParallelContentBatchMigratorBatchTestBase { private readonly TestParallelContentBatchMigrator _batchMigrator; diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchTestBase.cs index ff4c3ca0..12783ee2 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/Batch/ParallelContentBatchMigratorBatchTestBase.cs @@ -20,8 +20,9 @@ namespace Tableau.Migration.Tests.Unit.Engine.Migrators.Batch { - public class ParallelContentBatchMigratorBatchTestBase : ContentBatchMigratorTestBase + public class ParallelContentBatchMigratorBatchTestBase : ContentBatchMigratorTestBase where TContent : class + where TPrepare : class where TPublish : class { protected readonly Mock MockConfigReader; diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs index afc3836f..373594c2 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs @@ -33,6 +33,7 @@ using Tableau.Migration.Engine.Migrators; using Tableau.Migration.Engine.Migrators.Batch; using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.Paging; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Migrators @@ -229,36 +230,6 @@ public async Task AppliesFiltersAsync() SourceContent.Count), Times.Exactly(NumSourcePages)); } - [Fact] - public async Task FilteredOutItemsMarkedAsSkipped() - { - MockFilterRunner.Setup(x => x.ExecuteAsync(It.IsAny>>(), Cancel)) - .ReturnsAsync((IEnumerable> items, CancellationToken c) => - { - return items.Skip(1); - }); - - var result = await Migrator.MigrateAsync(Cancel); - - result.AssertSuccess(); - - MockSourceEndpoint.Verify(x => x.GetPager(BatchSize), Times.Once); - MockManifestPartition.Verify(x => x.GetEntryBuilder(SourceContent.Count), Times.Once); - - Assert.Equal(2, NumSourcePages); - - MockBatchMigrator.Verify(x => x.MigrateAsync(It.Is>>(i => i.Length == 1), Cancel), Times.Exactly(NumSourcePages)); - - MockManifestEntryBuilder.Verify(x => x.CreateEntries(It.IsAny>(), - It.IsAny>>(), - SourceContent.Count), Times.Exactly(NumSourcePages)); - - Assert.Equal(MigrationManifestEntryStatus.Skipped, MigrationItems[0].ManifestEntry.Status); - Assert.NotEqual(MigrationManifestEntryStatus.Skipped, MigrationItems[1].ManifestEntry.Status); - Assert.Equal(MigrationManifestEntryStatus.Skipped, MigrationItems[2].ManifestEntry.Status); - Assert.NotEqual(MigrationManifestEntryStatus.Skipped, MigrationItems[3].ManifestEntry.Status); - } - [Fact] public async Task ContinueOnBatchFailureAsync() { diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/MigratorTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/MigratorTests.cs index f6975bbc..6434ecdd 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/MigratorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/MigratorTests.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -72,7 +73,7 @@ public Execute() _mockManifest = Create>(); _mockErrorManifest = Create>(); - _mockManifestFactory.Setup(x => x.Create(It.IsAny(), It.IsAny())) + _mockManifestFactory.Setup(x => x.Create(It.IsAny(), It.IsAny(), It.IsAny())) .Returns(_mockErrorManifest.Object); _mockPipelineFactory = Freeze>(); @@ -130,7 +131,7 @@ public async Task UncaughtExceptionIsFatalErrorAsync() var result = await _migrator.ExecuteAsync(_plan, _cancel); Assert.Equal(MigrationCompletionStatus.FatalError, result.Status); - _mockManifest.Verify(x => x.AddErrors(ex), Times.Once); + _mockManifest.Verify(x => x.AddErrors(It.Is(exs => exs.Single() == ex)), Times.Once); Assert.Same(_mockManifest.Object, result.Manifest); } @@ -144,7 +145,7 @@ public async Task CancellationExceptionIsCancellationAsync() var result = await _migrator.ExecuteAsync(_plan, _cancel); Assert.Equal(MigrationCompletionStatus.Canceled, result.Status); - _mockManifest.Verify(x => x.AddErrors(ex), Times.Never); + _mockManifest.Verify(x => x.AddErrors(It.IsAny>()), Times.Never); Assert.Same(_mockManifest.Object, result.Manifest); } @@ -160,11 +161,11 @@ public async Task CreatesManifestOnErrorBeforeManifestAsync() Assert.Equal(MigrationCompletionStatus.FatalError, result.Status); Assert.Same(_mockErrorManifest.Object, result.Manifest); - _mockManifest.Verify(x => x.AddErrors(ex), Times.Never); + _mockManifest.Verify(x => x.AddErrors(It.IsAny()), Times.Never); - _mockManifestFactory.Verify(x => x.Create(_plan.PlanId, It.IsAny()), Times.Once); + _mockManifestFactory.Verify(x => x.Create(_plan.PlanId, It.IsAny(), It.IsAny()), Times.Once); - _mockErrorManifest.Verify(x => x.AddErrors(ex), Times.Once); + _mockErrorManifest.Verify(x => x.AddErrors(It.Is(exs => exs.Single() == ex)), Times.Once); } [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/CustomMigrationPipelineFactoryTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/CustomMigrationPipelineFactoryTests.cs index e83298e6..4cd26a10 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/CustomMigrationPipelineFactoryTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/CustomMigrationPipelineFactoryTests.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using Moq; +using Tableau.Migration.Config; using Tableau.Migration.Engine.Actions; using Tableau.Migration.Engine.Pipelines; using Xunit; @@ -28,8 +29,8 @@ public sealed class CustomMigrationPipelineFactoryTests { public sealed class TestCustomPipeline : MigrationPipelineBase { - public TestCustomPipeline(IServiceProvider services) - : base(services) + public TestCustomPipeline(IServiceProvider services, IConfigReader configReader) + : base(services, configReader) { } protected override IEnumerable BuildPipeline() diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineBaseTests.cs index 828dffde..1810be6e 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineBaseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineBaseTests.cs @@ -17,13 +17,14 @@ using Moq; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Conversion; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Migrators; using Tableau.Migration.Engine.Migrators.Batch; +using Tableau.Migration.Engine.Pipelines; using Tableau.Migration.Engine.Preparation; using Xunit; @@ -31,6 +32,41 @@ namespace Tableau.Migration.Tests.Unit.Engine.Pipelines { public class MigrationPipelineBaseTests { + + #region - Verify First Party Pipelines - + + [Fact] + public void Verify_AllPipelinesHaveExpectedNumberOfActions() + { + MigrationPipelineTestBase ServerToServerPipeline = new(); + MigrationPipelineTestBase ServerToCloudPipeline = new(); + MigrationPipelineTestBase CloudToCloudPipeline = new(); + + var serverToServerActions = ServerToServerPipeline.Pipeline.BuildActions(); + var serverToCloudActions = ServerToCloudPipeline.Pipeline.BuildActions(); + var cloudToCloudActions = CloudToCloudPipeline.Pipeline.BuildActions(); + + Assert.NotEmpty(serverToServerActions); + Assert.NotEmpty(serverToCloudActions); + Assert.NotEmpty(cloudToCloudActions); + + Assert.Equal(serverToServerActions.Length, serverToCloudActions.Length); + Assert.Equal(serverToCloudActions.Length, cloudToCloudActions.Length); + } + + [Fact] + public void Verify_AllPipelinesHaveExpectedNumberOfContentTypes() + { + Assert.NotEmpty(ServerToServerMigrationPipeline.ContentTypes); + Assert.NotEmpty(ServerToCloudMigrationPipeline.ContentTypes); + Assert.NotEmpty(CloudToCloudMigrationPipeline.ContentTypes); + + Assert.Equal(ServerToServerMigrationPipeline.ContentTypes.Length, ServerToCloudMigrationPipeline.ContentTypes.Length); + Assert.Equal(ServerToCloudMigrationPipeline.ContentTypes.Length, CloudToCloudMigrationPipeline.ContentTypes.Length); + } + + #endregion + public class MigrationPipelineBaseTest : MigrationPipelineTestBase { } @@ -125,33 +161,58 @@ public class GetItemPreparer : MigrationPipelineBaseTest [Fact] public void CreatesDefaultSourceItemPreparer() { - var migrator = Pipeline.GetItemPreparer(); + var preparer = Pipeline.GetItemPreparer(); - Assert.IsType>(migrator); + Assert.IsType>(preparer); MockServices.Verify(x => x.GetService(typeof(SourceContentItemPreparer)), Times.Once); } - + + [Fact] + public void CreatesNonPullSourceItemPreparer() + { + var preparer = Pipeline.GetItemPreparer(); + + Assert.IsType>(preparer); + MockServices.Verify(x => x.GetService(typeof(SourceContentItemPreparer)), Times.Once); + } + [Fact] public void CreatesEndpointItemPreparer() { - var migrator = Pipeline.GetItemPreparer(); + var preparer = Pipeline.GetItemPreparer(); - Assert.IsType>(migrator); - MockServices.Verify(x => x.GetService(typeof(EndpointContentItemPreparer)), Times.Once); + Assert.IsType>(preparer); + MockServices.Verify(x => x.GetService(typeof(EndpointContentItemPreparer)), Times.Once); } - + [Fact] public void CreatesExtractRefreshTaskServerToCloudPreparer() { - var migrator = Pipeline.GetItemPreparer(); + var preparer = Pipeline.GetItemPreparer(); - Assert.IsType(migrator); + Assert.IsType(preparer); MockServices.Verify(x => x.GetService(typeof(ExtractRefreshTaskServerToCloudPreparer)), Times.Once); } } #endregion + #region - GetItemConverter - + + public class GetItemConverter : MigrationPipelineBaseTest + { + [Fact] + public void CreatesIdentityConverter() + { + var converter = Pipeline.GetItemConverter(); + + Assert.IsType>(converter); + MockServices.Verify(x => x.GetService(typeof(DirectContentItemConverter)), Times.Once); + } + } + + #endregion + #region - CreateSourceCache - public class CreateSourceCache : MigrationPipelineBaseTest @@ -208,5 +269,6 @@ public void GetsDestinationProjectCache() } #endregion + } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineContentTypeTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineContentTypeTests.cs index 196f29ba..6447318c 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineContentTypeTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineContentTypeTests.cs @@ -34,9 +34,10 @@ protected IImmutableList CreateTypes(int createCount, params Type[] explic protected MigrationPipelineContentType CreateContentType(Type? contentType = null) => new(contentType ?? CreateType()); - protected static void AssertTypes(MigrationPipelineContentType result, Type contentType, Type publishType, Type resultType) + protected static void AssertTypes(MigrationPipelineContentType result, Type contentType, Type prepareType, Type publishType, Type resultType) { Assert.Same(contentType, result.ContentType); + Assert.Same(prepareType, result.PrepareType); Assert.Same(publishType, result.PublishType); Assert.Same(resultType, result.ResultType); } @@ -51,7 +52,31 @@ public void Content_only() var t = new MigrationPipelineContentType(type); - AssertTypes(t, type, type, type); + AssertTypes(t, type, type, type, type); + } + } + + public class WithPrepareType : MigrationPipelineContentTypeTest + { + [Fact] + public void Different_types() + { + var contentType = CreateType(); + var prepareType = CreateType(); + + var t = CreateContentType(contentType).WithPrepareType(prepareType); + + AssertTypes(t, contentType, prepareType, contentType, contentType); + } + + [Fact] + public void Same_types() + { + var type = CreateType(); + + var t = CreateContentType(type).WithPrepareType(type); + + AssertTypes(t, type, type, type, type); } } @@ -65,7 +90,7 @@ public void Different_types() var t = CreateContentType(contentType).WithPublishType(publishType); - AssertTypes(t, contentType, publishType, contentType); + AssertTypes(t, contentType, contentType, publishType, contentType); } [Fact] @@ -75,7 +100,7 @@ public void Same_types() var t = CreateContentType(type).WithPublishType(type); - AssertTypes(t, type, type, type); + AssertTypes(t, type, type, type, type); } } @@ -89,7 +114,7 @@ public void Different_types() var t = CreateContentType(contentType).WithResultType(resultType); - AssertTypes(t, contentType, contentType, resultType); + AssertTypes(t, contentType, contentType, contentType, resultType); } [Fact] @@ -99,7 +124,7 @@ public void Same_types() var t = CreateContentType(type).WithResultType(type); - AssertTypes(t, type, type, type); + AssertTypes(t, type, type, type, type); } } @@ -190,7 +215,7 @@ private static void AssertConfigKey(MigrationPipelineContentType pipelineContent [Fact] public void ReturnsConfigKey() { - var pipelineContentTypes = ServerToCloudMigrationPipeline.ContentTypes; + var pipelineContentTypes = MigrationPipelineContentType.GetAllMigrationPipelineContentTypes(); foreach (var pipelineContentType in pipelineContentTypes) { @@ -204,7 +229,7 @@ public void ReturnsConfigKey() [Fact] public void StaticType() { - var pipelineContentTypes = ServerToCloudMigrationPipeline.ContentTypes; + var pipelineContentTypes = MigrationPipelineContentType.GetAllMigrationPipelineContentTypes(); foreach (var pipelineContentType in pipelineContentTypes) { @@ -215,5 +240,30 @@ public void StaticType() } } } + + public class GetMigrationPipelineContentTypes + { + public static TheoryData> GetPipelineProfiles() + { + return new TheoryData> + { + { PipelineProfile.ServerToCloud, ServerToCloudMigrationPipeline.ContentTypes }, + { PipelineProfile.ServerToServer, ServerToServerMigrationPipeline.ContentTypes }, + { PipelineProfile.CloudToCloud, CloudToCloudMigrationPipeline.ContentTypes } + }; + } + + [Theory] + [MemberData(nameof(GetPipelineProfiles))] + public void ReturnsContentTypes(PipelineProfile profile, ImmutableArray contentTypes) + { + var pipelineContentTypes = MigrationPipelineContentType.GetMigrationPipelineContentTypes(profile); + Assert.Equal(contentTypes.Length, pipelineContentTypes.Length); + for (var i = 0; i < contentTypes.Length; i++) + { + Assert.Same(contentTypes[i], pipelineContentTypes[i]); + } + } + } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineTestBase.cs index e8bab79a..cb7cd507 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/MigrationPipelineTestBase.cs @@ -32,7 +32,7 @@ public class MigrationPipelineTestBase : AutoFixtureTestBase protected readonly Mock MockDestinationEndpoint; protected readonly Mock MockSourceEndpoint; - protected readonly TPipeline Pipeline; + public readonly TPipeline Pipeline; protected virtual TPipeline CreatePipeline() => Create(); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/ServerToCloudMigrationPipelineTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/ServerToCloudMigrationPipelineTests.cs index 152640c6..523e9438 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/ServerToCloudMigrationPipelineTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/ServerToCloudMigrationPipelineTests.cs @@ -22,9 +22,12 @@ using Moq; using Tableau.Migration.Config; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules; +using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Engine.Actions; +using Tableau.Migration.Engine.Conversion; +using Tableau.Migration.Engine.Conversion.ExtractRefreshTasks; +using Tableau.Migration.Engine.Conversion.Subscriptions; using Tableau.Migration.Engine.Migrators.Batch; using Tableau.Migration.Engine.Pipelines; using Xunit; @@ -39,10 +42,15 @@ private static Type GetContentType(object o) private static Type GetPublishType(object o) { var t = o.GetType(); - if (t.GenericTypeArguments.Length == 1) - return t.GenericTypeArguments[0]; - - return t.GenericTypeArguments[1]; + switch(t.GenericTypeArguments.Length) + { + case 1: + return t.GenericTypeArguments[0]; + case 2: + return t.GenericTypeArguments[^1]; + default: + return t.GenericTypeArguments[^2]; + } } public class ContentTypes : MigrationPipelineTestBase @@ -92,7 +100,7 @@ public void BuildsPipeline() { var actions = Pipeline.BuildActions(); - Assert.Equal(8, actions.Length); + Assert.Equal(9, actions.Length); Assert.IsType(actions[0]); Assert.IsType>(actions[1]); Assert.IsType>(actions[2]); @@ -101,6 +109,7 @@ public void BuildsPipeline() Assert.IsType>(actions[5]); Assert.IsType>(actions[6]); Assert.IsType>(actions[7]); + Assert.IsType>(actions[8]); } [Fact] @@ -169,5 +178,32 @@ public void CreatesUserBatchMigrator() MockServices.Verify(x => x.GetService(typeof(BulkPublishContentBatchMigrator)), Times.Once); } } + + public class GetItemConverter : MigrationPipelineTestBase + { + [Fact] + public void CreatesExtractRefreshTaskConverter() + { + var converter = Pipeline.GetItemConverter(); + + Assert.IsAssignableFrom>(converter); + } + + [Fact] + public void CreatesSubscriptionConverter() + { + var converter = Pipeline.GetItemConverter(); + + Assert.IsAssignableFrom>(converter); + } + + [Fact] + public void CreatesDirectConverter() + { + var converter = Pipeline.GetItemConverter(); + + Assert.IsType>(converter); + } + } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/TestPipeline.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/TestPipeline.cs index 7dad9f3b..002b59a4 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/TestPipeline.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Pipelines/TestPipeline.cs @@ -18,6 +18,7 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using Tableau.Migration.Config; using Tableau.Migration.Engine.Actions; using Tableau.Migration.Engine.Pipelines; @@ -29,8 +30,8 @@ public class TestPipeline : MigrationPipelineBase public ImmutableArray TestPipelineActions { get; private set; } - public TestPipeline(IServiceProvider services) - : base(services) + public TestPipeline(IServiceProvider services, IConfigReader configReader) + : base(services, configReader) { TestPipelineActions = new[] { diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerBaseTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerBaseTests.cs index 5d25b3ea..bd7c6c3a 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerBaseTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerBaseTests.cs @@ -18,12 +18,16 @@ using System; using System.Threading; using System.Threading.Tasks; +using AutoFixture; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Conversion; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers; +using Tableau.Migration.Engine.Pipelines; using Tableau.Migration.Engine.Preparation; +using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Preparation @@ -32,61 +36,88 @@ public class ContentItemPreparerBaseTests { #region - Test Classes - - public class TestPreparer : ContentItemPreparerBase - where TPublish : class, new() + public class TestPreparer : ContentItemPreparerBase + where TPrepare : class + where TPublish : class { - public IResult PullResult { get; set; } = Result.Succeeded(new()); + public IResult PullResult { get; set; } public int PullCallCount { get; private set; } - public TestPreparer( + public TestPreparer(IFixture fixture, + IMigrationPipeline pipeline, IContentTransformerRunner transformerRunner, - IDestinationContentReferenceFinderFactory destinationFinderFactory) - : base(transformerRunner, destinationFinderFactory) - { } + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) + : base(pipeline, transformerRunner, destinationFinderFactory, localizer) + { + PullResult = Result.Succeeded(fixture.Create()); + } - protected override Task> PullAsync(ContentMigrationItem item, CancellationToken cancel) + protected override Task> PullAsync(ContentMigrationItem item, CancellationToken cancel) { PullCallCount++; return Task.FromResult(PullResult); } } - public class TestPreparer : TestPreparer - where TContent : class, new() + public class TestPreparer : TestPreparer + where TContent : class + where TPrepare : class { - public TestPreparer( + public TestPreparer(IFixture fixture, + IMigrationPipeline pipeline, IContentTransformerRunner transformerRunner, - IDestinationContentReferenceFinderFactory destinationFinderFactory) - : base(transformerRunner, destinationFinderFactory) + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) + : base(fixture, pipeline, transformerRunner, destinationFinderFactory, localizer) + { } + } + + public class TestPreparer : TestPreparer + where TContent : class + { + public TestPreparer(IFixture fixture, + IMigrationPipeline pipeline, + IContentTransformerRunner transformerRunner, + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) + : base(fixture, pipeline, transformerRunner, destinationFinderFactory, localizer) { } } public class TestPreparer : TestPreparer { - public TestPreparer( + public TestPreparer(IFixture fixture, + IMigrationPipeline pipeline, IContentTransformerRunner transformerRunner, - IDestinationContentReferenceFinderFactory destinationFinderFactory) - : base(transformerRunner, destinationFinderFactory) + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ISharedResourcesLocalizer localizer) + : base(fixture, pipeline, transformerRunner, destinationFinderFactory, localizer) { } } - public class TestMappableContentType : TestContentType, IMappableContent + public class TestMappableContainerContentType : MappableContainerContentBase { - public void SetLocation(ContentLocation newLocation) + protected override IContentReference? MappableContainer { get; set; } + + public IContentReference? PublicContainer { - Location = newLocation; + get => MappableContainer; + set => MappableContainer = value; } } - public class TestMappableContainerContentType : TestContentType, IMappableContainerContent + public class TestContainerContentType : ContainerContentBase { - public IContentReference? Container { get; set; } + public TestContainerContentType(IContentReference container) + : base(container) + { } - public void SetLocation(IContentReference? container, ContentLocation newLocation) + public IContentReference? PublicContainer { - Container = container; - Location = newLocation; + get => MappableContainer; + set => MappableContainer = value; } } @@ -94,12 +125,12 @@ public void SetLocation(IContentReference? container, ContentLocation newLocatio #region - PrepareAsync - - public class PrepareAsync : ContentItemPreparerTestBase + public class PrepareAsync : ContentItemPreparerTestBase { [Fact] public async Task PullsAndTransformsAsync() { - var preparer = Create(); + var preparer = Create>(); var result = await preparer.PrepareAsync(Item, Cancel); result.AssertSuccess(); @@ -114,7 +145,7 @@ public async Task PullsAndTransformsAsync() [Fact] public async Task PullsFailsAsync() { - var preparer = Create(); + var preparer = Create>(); var errors = new Exception[] { new(), new() }; preparer.PullResult = Result.Failed(errors); @@ -134,9 +165,9 @@ public async Task PullsFailsAsync() [Fact] public async Task AppliesMappingToContentAsync() { - var item = Create>(); + var item = Create>(); - var preparer = Create>(); + var preparer = Create>(); var result = await preparer.PrepareAsync(item, Cancel); result.AssertSuccess(); @@ -152,9 +183,9 @@ public async Task AppliesMappingToContainerContentAsync() var preparer = Create>(); var publishItem = preparer.PullResult.Value!; - publishItem.Container = Create(); + publishItem.PublicContainer = Create(); - var sourceParentLocation = publishItem.Container.Location; + var sourceParentLocation = publishItem.PublicContainer.Location; MappedLocation = sourceParentLocation.Append(Create()); @@ -172,7 +203,7 @@ public async Task AppliesMappingToContainerContentAsync() Assert.Equal(MockManifestEntry.Object.MappedLocation, publishItem.Location); Assert.Equal(destinationName, publishItem.Name); - Assert.Same(destinationProject, publishItem.Container); + Assert.Same(destinationProject, publishItem.PublicContainer); MockProjectFinder.Verify(x => x.FindBySourceLocationAsync(sourceParentLocation, Cancel), Times.Once); } @@ -184,7 +215,7 @@ public async Task AppliesNewParentToContainerContentAsync() var preparer = Create>(); var publishItem = preparer.PullResult.Value!; - publishItem.Container = Create(); + publishItem.PublicContainer = Create(); var destinationContainerLocation = MockManifestEntry.Object.MappedLocation.Parent(); var destinationProject = Create(); @@ -200,7 +231,7 @@ public async Task AppliesNewParentToContainerContentAsync() Assert.Equal(MockManifestEntry.Object.MappedLocation, publishItem.Location); Assert.Equal(destinationName, publishItem.Name); - Assert.Same(destinationProject, publishItem.Container); + Assert.Same(destinationProject, publishItem.PublicContainer); MockProjectFinder.Verify(x => x.FindByMappedLocationAsync(destinationContainerLocation, Cancel), Times.Once); } @@ -212,7 +243,7 @@ public async Task AppliesMappingToTopLevelContainerContentAsync() var preparer = Create>(); var publishItem = preparer.PullResult.Value!; - publishItem.Container = null; + publishItem.PublicContainer = null; MappedLocation = new(Create()); @@ -224,7 +255,7 @@ public async Task AppliesMappingToTopLevelContainerContentAsync() Assert.Equal(MockManifestEntry.Object.MappedLocation, publishItem.Location); Assert.Equal(destinationName, publishItem.Name); - Assert.Null(publishItem.Container); + Assert.Null(publishItem.PublicContainer); MockProjectFinder.Verify(x => x.FindBySourceLocationAsync(It.IsAny(), Cancel), Times.Never); } @@ -247,6 +278,53 @@ public async Task ReturnsTransformedObjectAsync() MockManifestEntry.Verify(x => x.SetFailed(preparer.PullResult.Errors), Times.Never); } + + [Fact] + public async Task PreMappedDestinationProjectNotFoundAsync() + { + var item = Create>(); + var preparer = Create>(); + + var publishItem = preparer.PullResult.Value!; + publishItem.PublicContainer = Create(); + + var destinationContainerLocation = MockManifestEntry.Object.MappedLocation.Parent(); + var destinationProject = Create(); + + MockProjectFinder.Setup(x => x.FindByMappedLocationAsync(destinationContainerLocation, Cancel)) + .ReturnsAsync((IContentReference?)null); + + await Assert.ThrowsAsync(() => preparer.PrepareAsync(item, Cancel)); + + MockProjectFinder.Verify(x => x.FindByMappedLocationAsync(destinationContainerLocation, Cancel), Times.Once); + } + + [Fact] + public async Task MappedProjectNotFoundAsync() + { + MockPipeline.Setup(x => x.GetItemConverter()) + .Returns(() => new DirectContentItemConverter()); + + var item = Create>(); + var preparer = Create>(); + + var publishItem = preparer.PullResult.Value!; + publishItem.PublicContainer = Create(); + + var sourceParentLocation = publishItem.PublicContainer.Location; + + MappedLocation = sourceParentLocation.Append(Create()); + + var destinationContainerLocation = MockManifestEntry.Object.MappedLocation.Parent(); + var destinationProject = Create(); + + MockProjectFinder.Setup(x => x.FindBySourceLocationAsync(sourceParentLocation, Cancel)) + .ReturnsAsync((IContentReference?)null); + + await Assert.ThrowsAsync(() => preparer.PrepareAsync(item, Cancel)); + + MockProjectFinder.Verify(x => x.FindBySourceLocationAsync(sourceParentLocation, Cancel), Times.Once); + } } public class PrepareAsyncFile : ContentItemPreparerTestBase @@ -333,6 +411,53 @@ public async Task DisposesOnCancellationExceptionAsync() } } + public class PrepareAsyncConvert : ContentItemPreparerTestBase + { + [Fact] + public async Task ConvertsToPublishTypeAsync() + { + var convertedItem = Create(); + var mockConverter = Create>>(); + mockConverter.Setup(x => x.ConvertAsync(It.IsAny(), Cancel)) + .ReturnsAsync(convertedItem); + + MockPipeline.Setup(x => x.GetItemConverter()) + .Returns(mockConverter.Object); + + var preparer = Create>(); + var result = await preparer.PrepareAsync(Item, Cancel); + + result.AssertSuccess(); + + MockPipeline.Verify(x => x.GetItemConverter(), Times.Once); + mockConverter.Verify(x => x.ConvertAsync(preparer.PullResult.Value!, Cancel), Times.Once); + + Assert.Same(convertedItem, result.Value); + } + + [Fact] + public async Task DisposesPullValueOnConverterExceptionAsync() + { + var ex = new Exception(); + var mockConverter = Create>>(); + mockConverter.Setup(x => x.ConvertAsync(It.IsAny(), Cancel)) + .ThrowsAsync(ex); + + MockPipeline.Setup(x => x.GetItemConverter()) + .Returns(mockConverter.Object); + + var preparer = Create>(); + + var thrown = await Assert.ThrowsAsync(() => preparer.PrepareAsync(Item, Cancel)); + + Assert.Same(ex, thrown); + Assert.True(preparer.PullResult.Value!.IsDisposed); + + MockPipeline.Verify(x => x.GetItemConverter(), Times.Once); + mockConverter.Verify(x => x.ConvertAsync(preparer.PullResult.Value!, Cancel), Times.Once); + } + } + #endregion } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerTestBase.cs index 0d3dfec7..5b0f383e 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/ContentItemPreparerTestBase.cs @@ -15,29 +15,43 @@ // limitations under the License. // +using System; using System.Threading; using Moq; using Tableau.Migration.Content; using Tableau.Migration.Content.Files; using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Conversion; using Tableau.Migration.Engine.Endpoints.Search; using Tableau.Migration.Engine.Hooks.Transformers; using Tableau.Migration.Engine.Manifest; +using Tableau.Migration.Engine.Pipelines; namespace Tableau.Migration.Tests.Unit.Engine.Preparation { - public class ContentItemPreparerTestBase : AutoFixtureTestBase + public class ContentItemPreparerTestBase : AutoFixtureTestBase + where TPrepare : class + where TPublish : class { + protected readonly Mock MockPipeline; protected readonly Mock MockTransformerRunner; protected readonly Mock MockManifestEntry; protected readonly Mock> MockProjectFinder; protected readonly Mock MockFileStore; - protected readonly ContentMigrationItem Item; + protected readonly ContentMigrationItem Item; protected ContentLocation MappedLocation { get; set; } public ContentItemPreparerTestBase() { + MockPipeline = Freeze>(); + MockPipeline.Setup(x => x.GetItemConverter()) + .Returns(new InvocationFunc(invocation => + { + var genericArgs = invocation.Method.GetGenericArguments(); + return Activator.CreateInstance(typeof(DirectContentItemConverter<,>).MakeGenericType(genericArgs)); + })); + MockTransformerRunner = Freeze>(); MockTransformerRunner.Setup(x => x.ExecuteAsync(It.IsAny(), Cancel)) .ReturnsAsync((TPublish item, CancellationToken cancel) => item); @@ -57,7 +71,11 @@ public ContentItemPreparerTestBase() MockFileStore = Freeze>(); - Item = Create>(); + Item = Create>(); } } + + public class ContentItemPreparerTestBase : ContentItemPreparerTestBase + where TPrepare : class + { } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/EndpointContentItemPreparerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/EndpointContentItemPreparerTests.cs index ac62bc4d..fc9022de 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/EndpointContentItemPreparerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Preparation/EndpointContentItemPreparerTests.cs @@ -25,15 +25,15 @@ namespace Tableau.Migration.Tests.Unit.Engine.Preparation { public class EndpointContentItemPreparerTests { - public class PullAsync : ContentItemPreparerTestBase + public class PullAsync : ContentItemPreparerTestBase { private readonly Mock _mockSourceEndpoint; - private readonly EndpointContentItemPreparer _preparer; + private readonly EndpointContentItemPreparer _preparer; public PullAsync() { _mockSourceEndpoint = Freeze>(); - _preparer = Create>(); + _preparer = Create>(); } [Fact] diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/ServerToCloudMigrationPlanBuilderTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/ServerToCloudMigrationPlanBuilderTests.cs index b6fffa60..a6e221aa 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/ServerToCloudMigrationPlanBuilderTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/ServerToCloudMigrationPlanBuilderTests.cs @@ -16,9 +16,9 @@ // using System; -using System.Linq; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Api.Rest.Models.Types; using Tableau.Migration.Content; @@ -39,10 +39,13 @@ public class ServerToCloudMigrationPlanBuilderTest : AutoFixtureTestBase protected readonly Mock MockInnerBuilder; protected readonly ServerToCloudMigrationPlanBuilder Builder; + private readonly Mock _mockLoggerFactory = new(); + + public ServerToCloudMigrationPlanBuilderTest() { MockInnerBuilder = Create>(); - Builder = new(new TestSharedResourcesLocalizer(), MockInnerBuilder.Object); + Builder = new(new TestSharedResourcesLocalizer(), _mockLoggerFactory.Object, MockInnerBuilder.Object); } protected void AssertRequiredAuthTypeExtensions(string authType, string userDomain, string groupDomain) @@ -151,17 +154,12 @@ public void ForServerToCloud() public void ForCustomPipelineFactory() { var contentTypes = CreateMany(); - var contentTypesArray = contentTypes.ToArray(); - var b = ((IMigrationPlanBuilder)Builder).ForCustomPipelineFactory(contentTypesArray); - b = ((IMigrationPlanBuilder)Builder).ForCustomPipelineFactory(contentTypes); + var b = ((IMigrationPlanBuilder)Builder).ForCustomPipelineFactory(contentTypes); - b = ((IMigrationPlanBuilder)Builder).ForCustomPipelineFactory(CreateFactory, contentTypesArray); b = ((IMigrationPlanBuilder)Builder).ForCustomPipelineFactory(CreateFactory, contentTypes); - MockInnerBuilder.Verify(x => x.ForCustomPipelineFactory(contentTypesArray), Times.Once); MockInnerBuilder.Verify(x => x.ForCustomPipelineFactory(contentTypes), Times.Once); - MockInnerBuilder.Verify(x => x.ForCustomPipelineFactory(CreateFactory, contentTypesArray), Times.Once); MockInnerBuilder.Verify(x => x.ForCustomPipelineFactory(CreateFactory, contentTypes), Times.Once); } @@ -169,12 +167,9 @@ public void ForCustomPipelineFactory() public void ForCustomPipeline() { var contentTypes = CreateMany(); - var contentTypesArray = contentTypes.ToArray(); - var b = ((IMigrationPlanBuilder)Builder).ForCustomPipeline(contentTypesArray); - b = ((IMigrationPlanBuilder)Builder).ForCustomPipeline(contentTypes); + var b = ((IMigrationPlanBuilder)Builder).ForCustomPipeline(contentTypes); - MockInnerBuilder.Verify(x => x.ForCustomPipeline(contentTypesArray), Times.Once); MockInnerBuilder.Verify(x => x.ForCustomPipeline(contentTypes), Times.Once); } @@ -218,6 +213,15 @@ public void RegistersSamlDomainMapping() AssertRequiredAuthTypeExtensions(AuthenticationTypes.Saml, "myDomain", Constants.LocalDomain); } + + [Fact] + public void RegistersSamlWithIdpConfigurationName() + { + var name = Create(); + Builder.WithSamlAuthenticationType("myDomain", name); + + AssertRequiredAuthTypeExtensions(name, "myDomain", Constants.LocalDomain); + } } public class WithTableauIdAuthenticationType : ServerToCloudMigrationPlanBuilderTest @@ -230,6 +234,15 @@ public void WithMfa() AssertRequiredAuthTypeExtensions(AuthenticationTypes.TableauIdWithMfa, Constants.TableauIdWithMfaDomain, Constants.LocalDomain); } + [Fact] + public void WithMfaIdpConfigurationName() + { + var name = Create(); + Builder.WithTableauIdAuthenticationType(idpConfigurationName: name); + + AssertRequiredAuthTypeExtensions(name, Constants.TableauIdWithMfaDomain, Constants.LocalDomain); + } + [Fact] public void WithoutMfa() { @@ -237,6 +250,15 @@ public void WithoutMfa() AssertRequiredAuthTypeExtensions(AuthenticationTypes.OpenId, Constants.ExternalDomain, Constants.LocalDomain); } + + [Fact] + public void WithoutMfaIdpConfigurationName() + { + var name = Create(); + Builder.WithTableauIdAuthenticationType(false, name); + + AssertRequiredAuthTypeExtensions(name, Constants.ExternalDomain, Constants.LocalDomain); + } } public class WithAuthenticationType : ServerToCloudMigrationPlanBuilderTest @@ -259,7 +281,6 @@ public void WithObject() MockInnerBuilder.Verify(x => x.Mappings.Add(myMapping), Times.Once); MockInnerBuilder.Verify(x => x.Mappings.Add(myMapping), Times.Once); - MockInnerBuilder.Verify(x => x.Options.Configure(It.Is(o => o.AuthenticationType == "myAuthType")), Times.Once); @@ -284,7 +305,6 @@ public void WithFactory() MockInnerBuilder.Verify(x => x.Mappings.Add(fact), Times.Once); MockInnerBuilder.Verify(x => x.Mappings.Add(fact), Times.Once); - MockInnerBuilder.Verify(x => x.Options.Configure(It.Is(o => o.AuthenticationType == "myAuthType")), Times.Once); @@ -301,7 +321,6 @@ public void WithCallback() MockInnerBuilder.Verify(x => x.Mappings.Add(It.IsAny()), Times.Once); MockInnerBuilder.Verify(x => x.Mappings.Add(It.IsAny()), Times.Once); - MockInnerBuilder.Verify(x => x.Options.Configure(It.Is(o => o.AuthenticationType == "myAuthType")), Times.Once); @@ -362,6 +381,8 @@ public void WithCallback() #endregion + #region - Validate - + public class Validate : ServerToCloudMigrationPlanBuilderTest { [Fact] @@ -399,5 +420,7 @@ public void AllRequiredInfo() result.AssertSuccess(); } } + + #endregion } } diff --git a/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializedExceptionJsonConverterTests.cs b/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializedExceptionJsonConverterTests.cs index 3c280e9a..a06407bb 100644 --- a/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializedExceptionJsonConverterTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializedExceptionJsonConverterTests.cs @@ -23,6 +23,7 @@ using System.Text.Json; using Tableau.Migration.Engine.Manifest; using Tableau.Migration.JsonConverters; +using Tableau.Migration.JsonConverters.Exceptions; using Tableau.Migration.JsonConverters.SerializableObjects; using Xunit; @@ -86,6 +87,11 @@ public void WriteAndReadBack_ExceptionObject_SerializesAndDeserializesToJson(Ser Assert.NotNull(result.Error); Assert.Equal(ex.Error!.Message, result.Error.Message); + if(ex.Error is not UnknownException) + { + Assert.IsNotType(result.Error); // Migration SDK Exception types should not rely on fall-back deserialization. + } + if (!exceptionNamespace.StartsWith("System")) // Built in Exception is not equatable { Assert.Equal(ex.Error, result.Error); diff --git a/tests/Tableau.Migration.Tests/Unit/MigrationCapabilitiesTests.cs b/tests/Tableau.Migration.Tests/Unit/MigrationCapabilitiesTests.cs new file mode 100644 index 00000000..8c73e9db --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/MigrationCapabilitiesTests.cs @@ -0,0 +1,64 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Reflection; +using AutoFixture.Kernel; +using Xunit; + + +namespace Tableau.Migration.Tests.Unit +{ + public class MigrationCapabilitiesTests : AutoFixtureTestBase + { + [Fact] + public void Clone_ShouldCloneAllPropertiesCorrectly() + { + // Arrange + var original = new MigrationCapabilities(); + SetNonDefaultValues(original); + + // Act + var clone = original.Clone(); + + // Assert + foreach (PropertyInfo property in typeof(MigrationCapabilities).GetProperties()) + { + var originalValue = property.GetValue(original); + var cloneValue = property.GetValue(clone); + Assert.Equal(originalValue, cloneValue); + } + } + + private void SetNonDefaultValues(MigrationCapabilities instance) + { + foreach (PropertyInfo property in typeof(MigrationCapabilities).GetProperties()) + { + if (property.PropertyType == typeof(bool)) + { + var currentValue = (bool)(property.GetValue(instance) ?? false); + property.SetValue(instance, !currentValue); + } + else + { + var value = AutoFixture.Create(property.PropertyType, new SpecimenContext(AutoFixture)); + property.SetValue(instance, value); + } + } + } + } +} + diff --git a/tests/Tableau.Migration.Tests/Unit/Net/Handlers/AuthenticationHandlerTests.cs b/tests/Tableau.Migration.Tests/Unit/Net/Handlers/AuthenticationHttpHandlerTests.cs similarity index 95% rename from tests/Tableau.Migration.Tests/Unit/Net/Handlers/AuthenticationHandlerTests.cs rename to tests/Tableau.Migration.Tests/Unit/Net/Handlers/AuthenticationHttpHandlerTests.cs index d9804d36..b6e2c4fb 100644 --- a/tests/Tableau.Migration.Tests/Unit/Net/Handlers/AuthenticationHandlerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Net/Handlers/AuthenticationHttpHandlerTests.cs @@ -28,15 +28,15 @@ namespace Tableau.Migration.Tests.Unit.Net.Handlers { - public class AuthenticationHandlerTests + public class AuthenticationHttpHandlerTests { - public abstract class AuthenticationHandlerTest : AutoFixtureTestBase + public abstract class AuthenticationHttpHandlerTest : AutoFixtureTestBase { protected readonly Mock MockTokenProvider = new(); - internal readonly AuthenticationHandler Handler; + internal readonly AuthenticationHttpHandler Handler; - public AuthenticationHandlerTest() + public AuthenticationHttpHandlerTest() { Handler = new(MockTokenProvider.Object); } @@ -70,7 +70,7 @@ protected static void AssertAuthenticationHeader(HttpRequestMessage request, str } } - public class SendAsync : AuthenticationHandlerTest + public class SendAsync : AuthenticationHttpHandlerTest { [Fact] public async Task Skips_if_not_Rest_request() diff --git a/tests/Tableau.Migration.Tests/Unit/Net/Handlers/LoggingHandlerTests.cs b/tests/Tableau.Migration.Tests/Unit/Net/Handlers/LoggingHttpHandlerTests.cs similarity index 86% rename from tests/Tableau.Migration.Tests/Unit/Net/Handlers/LoggingHandlerTests.cs rename to tests/Tableau.Migration.Tests/Unit/Net/Handlers/LoggingHttpHandlerTests.cs index f23a1f22..9ae99b5a 100644 --- a/tests/Tableau.Migration.Tests/Unit/Net/Handlers/LoggingHandlerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Net/Handlers/LoggingHttpHandlerTests.cs @@ -27,11 +27,11 @@ namespace Tableau.Migration.Tests.Unit.Net.Handlers { - public class LoggingHandlerTests + public class LoggingHttpHandlerTests { private readonly Mock _mockedTraceLogger; - public LoggingHandlerTests() + public LoggingHttpHandlerTests() { _mockedTraceLogger = new Mock(); } @@ -40,9 +40,9 @@ public LoggingHandlerTests() public void SuccessfullySendAsync_CallTraceLogger() { // Arrange - var handler = new LoggingHandler(_mockedTraceLogger.Object); + var handler = new LoggingHttpHandler(_mockedTraceLogger.Object); handler.InnerHandler = Mock.Of(); - var methodInfo = typeof(LoggingHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + var methodInfo = typeof(LoggingHttpHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; var message = new HttpRequestMessage(); // Act @@ -66,11 +66,11 @@ public void SuccessfullySendAsync_CallTraceLogger() public void SendAsyncWithException_CallTraceLogger() { // Arrange - var handler = new LoggingHandler(_mockedTraceLogger.Object); + var handler = new LoggingHttpHandler(_mockedTraceLogger.Object); var exception = new Exception("Failed to Send"); var innerHandler = new MockDelegatingHandler(_ => throw exception); handler.InnerHandler = innerHandler; - var methodInfo = typeof(LoggingHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + var methodInfo = typeof(LoggingHttpHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; var message = new HttpRequestMessage(); // Act diff --git a/tests/Tableau.Migration.Tests/Unit/Net/Handlers/UserAgentHttpMessageHandlerTests.cs b/tests/Tableau.Migration.Tests/Unit/Net/Handlers/UserAgentHeaderHttpHandlerTests.cs similarity index 80% rename from tests/Tableau.Migration.Tests/Unit/Net/Handlers/UserAgentHttpMessageHandlerTests.cs rename to tests/Tableau.Migration.Tests/Unit/Net/Handlers/UserAgentHeaderHttpHandlerTests.cs index 077140ff..67e3bc23 100644 --- a/tests/Tableau.Migration.Tests/Unit/Net/Handlers/UserAgentHttpMessageHandlerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Net/Handlers/UserAgentHeaderHttpHandlerTests.cs @@ -26,11 +26,11 @@ namespace Tableau.Migration.Tests.Unit.Net.Handlers { - public class UserAgentHttpMessageHandlerTests + public class UserAgentHeaderHttpHandlerTests { private readonly Mock _mockedUserAgentProvider; - public UserAgentHttpMessageHandlerTests() + public UserAgentHeaderHttpHandlerTests() { _mockedUserAgentProvider = new Mock(); } @@ -42,9 +42,11 @@ public void CallSendAsync_AttachUserAgentHeader() var currentVersion = new Version("1.2.3.4"); _mockedUserAgentProvider.Setup(x => x.UserAgent) .Returns($"{Constants.USER_AGENT_PREFIX}/{currentVersion}"); - var handler = new UserAgentHttpMessageHandler(_mockedUserAgentProvider.Object); - handler.InnerHandler = Mock.Of(); - var methodInfo = typeof(UserAgentHttpMessageHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + var handler = new UserAgentHeaderHttpHandler(_mockedUserAgentProvider.Object) + { + InnerHandler = Mock.Of() + }; + var methodInfo = typeof(UserAgentHeaderHttpHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; var message = new HttpRequestMessage(); Assert.Empty(message.Headers.UserAgent); @@ -69,9 +71,11 @@ public void CallSendAsync_ReplaceUserAgentHeader() var currentVersion = new Version("10.12.23.44"); _mockedUserAgentProvider.Setup(x => x.UserAgent) .Returns($"{Constants.USER_AGENT_PREFIX}/{currentVersion}"); - var handler = new UserAgentHttpMessageHandler(_mockedUserAgentProvider.Object); - handler.InnerHandler = Mock.Of(); - var methodInfo = typeof(UserAgentHttpMessageHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + var handler = new UserAgentHeaderHttpHandler(_mockedUserAgentProvider.Object) + { + InnerHandler = Mock.Of() + }; + var methodInfo = typeof(UserAgentHeaderHttpHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; var message = new HttpRequestMessage(); message.Headers.UserAgent.TryParseAdd("PreSetAgent"); Assert.Single(message.Headers.UserAgent); @@ -98,9 +102,11 @@ public void CallSendAsync_ReplaceUserAgent_DontTouchOtherHeaders() var language = "pt-BR"; _mockedUserAgentProvider.Setup(x => x.UserAgent) .Returns($"{Constants.USER_AGENT_PREFIX}/{currentVersion}"); - var handler = new UserAgentHttpMessageHandler(_mockedUserAgentProvider.Object); - handler.InnerHandler = Mock.Of(); - var methodInfo = typeof(UserAgentHttpMessageHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; + var handler = new UserAgentHeaderHttpHandler(_mockedUserAgentProvider.Object) + { + InnerHandler = Mock.Of() + }; + var methodInfo = typeof(UserAgentHeaderHttpHandler).GetMethod("SendAsync", BindingFlags.NonPublic | BindingFlags.Instance)!; var message = new HttpRequestMessage(); message.Headers.UserAgent.TryParseAdd("PreSetAgent"); message.Headers.AcceptLanguage.TryParseAdd(language); diff --git a/tests/Tableau.Migration.Tests/Unit/Net/IServiceCollectionExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Net/IServiceCollectionExtensionsTests.cs index a967612c..9b331e52 100644 --- a/tests/Tableau.Migration.Tests/Unit/Net/IServiceCollectionExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Net/IServiceCollectionExtensionsTests.cs @@ -51,9 +51,10 @@ public async Task Registers_expected_services() await AssertServiceAsync(ServiceLifetime.Singleton); await AssertServiceAsync(ServiceLifetime.Singleton); await AssertServiceAsync(ServiceLifetime.Transient); - await AssertServiceAsync(ServiceLifetime.Transient); - await AssertServiceAsync(ServiceLifetime.Transient); - await AssertServiceAsync(ServiceLifetime.Transient); + await AssertServiceAsync(ServiceLifetime.Transient); + await AssertServiceAsync(ServiceLifetime.Transient); + await AssertServiceAsync(ServiceLifetime.Transient); + await AssertServiceAsync(ServiceLifetime.Transient); await AssertServiceAsync(ServiceLifetime.Transient); var defaultHttpClientFactoryType = Migration.Net.IServiceCollectionExtensions.GetDefaultHttpClientFactoryType(); diff --git a/tests/Tableau.Migration.Tests/Unit/Net/NetworkTraceLoggerTests.cs b/tests/Tableau.Migration.Tests/Unit/Net/NetworkTraceLoggerTests.cs index a0762a7c..29b9cbd4 100644 --- a/tests/Tableau.Migration.Tests/Unit/Net/NetworkTraceLoggerTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Net/NetworkTraceLoggerTests.cs @@ -33,28 +33,52 @@ public class NetworkTraceLoggerTests { public abstract class NetworkTraceLoggerTestsBase { - internal readonly Mock> _mockedLogger = new(); - internal readonly Mock _mockedConfigReader = new(); - internal readonly Mock _mockedLocalizer = new(); - internal readonly Mock _mockedTraceRedactor = new(); + private const LogLevel DefaultLogLevel = LogLevel.Information; + private const LogLevel ExceptionLogLevel = LogLevel.Error; + + private readonly Mock> _mockLogger = new(); + internal readonly Mock _mockConfigReader = new(); + internal readonly Mock _mockLocalizer = new(); + internal readonly Mock _mockTraceRedactor = new(); internal readonly MigrationSdkOptions _sdkOptions = new(); internal readonly NetworkTraceLogger _traceLogger; public NetworkTraceLoggerTestsBase() { - _mockedConfigReader - .Setup(x => x.Get()) - .Returns(_sdkOptions); + _mockConfigReader.Setup(x => x.Get()).Returns(_sdkOptions); + _traceLogger = new NetworkTraceLogger( - _mockedLogger.Object, - _mockedConfigReader.Object, - _mockedLocalizer.Object, - _mockedTraceRedactor.Object); + _mockLogger.Object, + _mockConfigReader.Object, + _mockLocalizer.Object, + _mockTraceRedactor.Object); + } + + protected void VerifyDefaultLogging() + { + _mockLogger.VerifyLogging(DefaultLogLevel, Times.Once); + _mockLogger.VerifyLogging(ExceptionLogLevel, Times.Never); + _mockLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceLogMessage], Times.Once); + } + + protected void VerifyExceptionLogging() + { + _mockLogger.VerifyLogging(DefaultLogLevel, Times.Never); + _mockLogger.VerifyLogging(ExceptionLogLevel, Times.Once); + _mockLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); } + + protected void VerifyNetworkTraceRedactor() + { + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); + _mockTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + } + + protected void VerifyLocalizerInvocationCount(int count) + => _mockLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(count)); } - public class WriteNetworkLogsAsync - : NetworkTraceLoggerTestsBase + public class WriteNetworkLogsAsync : NetworkTraceLoggerTestsBase { [Fact] public async Task WriteDefaultLogs() @@ -64,23 +88,16 @@ public async Task WriteDefaultLogs() var response = new HttpResponseMessage(); // Act - await _traceLogger.WriteNetworkLogsAsync( - request, - response, - CancellationToken.None); + await _traceLogger.WriteNetworkLogsAsync(request, response, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Once); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Never); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyDefaultLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); } } - public class WriteHeadersForLogs - : NetworkTraceLoggerTestsBase + public class WriteHeadersForLogs : NetworkTraceLoggerTestsBase { [Fact] public async Task EnableHeadersDetailsWithoutHeaders() @@ -91,18 +108,13 @@ public async Task EnableHeadersDetailsWithoutHeaders() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkLogsAsync( - request, - response, - CancellationToken.None); + await _traceLogger.WriteNetworkLogsAsync(request, response, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Once); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Never); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyDefaultLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); + } [Fact] @@ -116,18 +128,13 @@ public async Task EnableHeadersDetailsWithOnlyDisallowedHeaders() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkLogsAsync( - request, - response, - CancellationToken.None); + await _traceLogger.WriteNetworkLogsAsync(request, response, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Once); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Never); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyDefaultLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); + } [Fact] @@ -144,25 +151,19 @@ public async Task EnableHeadersDetailsWithAcceptHeader() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkLogsAsync( - request, - response, - CancellationToken.None); + await _traceLogger.WriteNetworkLogsAsync(request, response, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Once); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Never); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(3)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionResponseHeaders], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyDefaultLogging(); + VerifyLocalizerInvocationCount(3); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionResponseHeaders], Times.Once); + VerifyNetworkTraceRedactor(); + } } - public class WriteExceptionLogs - : NetworkTraceLoggerTestsBase + public class WriteExceptionLogs : NetworkTraceLoggerTestsBase { [Fact] public async Task WriteDefaultLogs() @@ -172,18 +173,13 @@ public async Task WriteDefaultLogs() var exception = new Exception(); // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); + } [Fact] @@ -195,19 +191,14 @@ public async Task EnableExceptionDetails() _sdkOptions.Network.ExceptionsLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionException], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionException], Times.Once); + VerifyNetworkTraceRedactor(); + } [Fact] @@ -219,18 +210,12 @@ public async Task EnableHeadersDetailsWithoutHeaders() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); } [Fact] @@ -243,18 +228,12 @@ public async Task EnableHeadersDetailsWithOnlyDisallowedHeaders() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); } [Fact] @@ -267,24 +246,19 @@ public async Task EnableHeadersDetailsWithAcceptHeader() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); + VerifyNetworkTraceRedactor(); + } } - public class WriteHeadersForExceptionLogs - : NetworkTraceLoggerTestsBase + public class WriteHeadersForExceptionLogs : NetworkTraceLoggerTestsBase { [Fact] public async Task EnableHeadersDetailsWithoutHeaders() @@ -295,18 +269,13 @@ public async Task EnableHeadersDetailsWithoutHeaders() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); + } [Fact] @@ -319,18 +288,13 @@ public async Task EnableHeadersDetailsWithOnlyDisallowedHeaders() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); + } [Fact] @@ -343,106 +307,87 @@ public async Task EnableHeadersDetailsWithAcceptHeader() _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); + VerifyNetworkTraceRedactor(); } } - public class WriteContentHeadersForExceptionLogs - : NetworkTraceLoggerTestsBase + public class WriteContentHeadersForExceptionLogs : NetworkTraceLoggerTestsBase { [Fact] public async Task EnableHeadersDetailsWithoutHeaders() { // Arrange - var request = new HttpRequestMessage(); - request.Content = new StringContent(string.Empty); + var request = new HttpRequestMessage + { + Content = new StringContent(string.Empty) + }; request.Content.Headers.Clear(); var exception = new Exception(); _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); } [Fact] public async Task EnableHeadersDetailsWithOnlyDisallowedHeaders() { // Arrange - var request = new HttpRequestMessage(); - request.Content = new StringContent(string.Empty); + var request = new HttpRequestMessage + { + Content = new StringContent(string.Empty) + }; request.Content.Headers.Clear(); request.Content.Headers.Add("bearer", "test"); var exception = new Exception(); _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); } [Fact] public async Task EnableHeadersDetailsWithContentTypeHeader() { // Arrange - var request = new HttpRequestMessage(); - request.Content = new StringContent(string.Empty); + var request = new HttpRequestMessage + { + Content = new StringContent(string.Empty) + }; request.Content.Headers.Clear(); request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); var exception = new Exception(); _sdkOptions.Network.HeadersLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestHeaders], Times.Once); + VerifyNetworkTraceRedactor(); + } } - public class WriteRequestContentForExceptionLogs - : NetworkTraceLoggerTestsBase + public class WriteRequestContentForExceptionLogs : NetworkTraceLoggerTestsBase { [Fact] public async Task EnableContentDetailsWithoutContent() @@ -453,97 +398,84 @@ public async Task EnableContentDetailsWithoutContent() _sdkOptions.Network.ContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(1); + VerifyNetworkTraceRedactor(); + } [Fact] public async Task EnableContentDetailsWithEmptyPdfContent() { // Arrange - var request = new HttpRequestMessage(); - request.Content = new StringContent(string.Empty); + var request = new HttpRequestMessage + { + Content = new StringContent(string.Empty) + }; request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); var exception = new Exception(); _sdkOptions.Network.ContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(3)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(3); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Once); + VerifyNetworkTraceRedactor(); + } [Fact] public async Task EnableBinaryContentDetailsWithEmptyPdfContent() { // Arrange - var request = new HttpRequestMessage(); - request.Content = new StringContent(string.Empty); + var request = new HttpRequestMessage + { + Content = new StringContent(string.Empty) + }; request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); var exception = new Exception(); _sdkOptions.Network.ContentLoggingEnabled = true; _sdkOptions.Network.BinaryContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); + _mockTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); } [Fact] public async Task EnableContentDetailsWithEmptyTextContent() { // Arrange - var request = new HttpRequestMessage(); - request.Content = new StringContent(string.Empty); + var request = new HttpRequestMessage + { + Content = new StringContent(string.Empty) + }; var exception = new Exception(); _sdkOptions.Network.ContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); + _mockTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); } } @@ -554,25 +486,21 @@ public class WriteMultipartRequestContentForExceptionLogs public async Task EnableContentDetailsWithoutContent() { // Arrange - var request = new HttpRequestMessage(); - request.Content = new MultipartFormDataContent(); + var request = new HttpRequestMessage + { + Content = new MultipartFormDataContent() + }; var exception = new Exception(); _sdkOptions.Network.ContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + VerifyNetworkTraceRedactor(); } [Fact] @@ -589,20 +517,16 @@ public async Task EnableContentDetailsWithEmptyPdfContent() _sdkOptions.Network.ContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(3)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Never); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(3); + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Once); + VerifyNetworkTraceRedactor(); + } [Fact] @@ -621,21 +545,18 @@ public async Task EnableContentDetailsTooLarge() _sdkOptions.Network.BinaryContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(3)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Never); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceTooLargeDetails], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Never); + VerifyExceptionLogging(); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(3); + + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Never); + _mockLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceTooLargeDetails], Times.Once); + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); + } [Fact] @@ -653,19 +574,16 @@ public async Task EnableBinaryContentDetailsWithEmptyPdfContent() _sdkOptions.Network.BinaryContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); + VerifyExceptionLogging(); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); + _mockTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); } [Fact] @@ -681,19 +599,16 @@ public async Task EnableContentDetailsWithEmptyTextContent() _sdkOptions.Network.ContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); + VerifyExceptionLogging(); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); + _mockTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); } [Fact] @@ -711,20 +626,17 @@ public async Task EnableMultipleContentDetails() _sdkOptions.Network.ContentLoggingEnabled = true; // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(3)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); + VerifyExceptionLogging(); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(3); + + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceNotDisplayedDetails], Times.Once); + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Once); + _mockTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); } [Fact] @@ -735,32 +647,30 @@ public async Task EnableMultipleContentDetailsWithSensitiveData() var multipart = new MultipartFormDataContent(); var content = new StringContent(string.Empty); content.Headers.ContentType = new MediaTypeHeaderValue("application/pdf"); - content.Headers.ContentDisposition = new ContentDispositionHeaderValue("test"); - content.Headers.ContentDisposition.Name = "password"; + content.Headers.ContentDisposition = new ContentDispositionHeaderValue("test") + { + Name = "password" + }; multipart.Add(content); multipart.Add(new StringContent(string.Empty)); request.Content = multipart; var exception = new Exception(); _sdkOptions.Network.ContentLoggingEnabled = true; _sdkOptions.Network.BinaryContentLoggingEnabled = true; - _mockedTraceRedactor + _mockTraceRedactor .Setup(x => x.IsSensitiveMultipartContent("password")) .Returns(true); // Act - await _traceLogger.WriteNetworkExceptionLogsAsync( - request, - exception, - CancellationToken.None); + await _traceLogger.WriteNetworkExceptionLogsAsync(request, exception, CancellationToken.None); // Assert - _mockedLogger.VerifyLogging(LogLevel.Information, Times.Never); - _mockedLogger.VerifyLogging(LogLevel.Error, Times.Once); - _mockedLocalizer.Verify(x => x[It.IsAny()], Times.Exactly(2)); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.NetworkTraceExceptionLogMessage], Times.Once); - _mockedLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); - _mockedTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Exactly(2)); - _mockedTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); + VerifyExceptionLogging(); + VerifyLocalizerInvocationCount(2); + + _mockLocalizer.Verify(x => x[SharedResourceKeys.SectionRequestContent], Times.Once); + _mockTraceRedactor.Verify(x => x.IsSensitiveMultipartContent(It.IsAny()), Times.Exactly(2)); + _mockTraceRedactor.Verify(x => x.ReplaceSensitiveData(It.IsAny()), Times.Once); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/OptionsHookTestBase.cs b/tests/Tableau.Migration.Tests/Unit/OptionsHookTestBase.cs index 5846aaba..38148bd3 100644 --- a/tests/Tableau.Migration.Tests/Unit/OptionsHookTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/OptionsHookTestBase.cs @@ -15,7 +15,6 @@ // limitations under the License. // -using Microsoft.Extensions.Logging; using Moq; using Tableau.Migration.Engine.Options; @@ -32,7 +31,7 @@ public OptionsHookTestBase() { Options = new(); - MockOptionsProvider = new(); + MockOptionsProvider = Freeze>>(); MockOptionsProvider.Setup(x => x.Get()).Returns(() => Options); } } diff --git a/tests/Tableau.Migration.Tests/Unit/Paging/MemoryPagerTests.cs b/tests/Tableau.Migration.Tests/Unit/Paging/MemoryPagerTests.cs new file mode 100644 index 00000000..ce3e0018 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Paging/MemoryPagerTests.cs @@ -0,0 +1,73 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Tableau.Migration.Content; +using Tableau.Migration.Paging; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Paging +{ + public sealed class MemoryPagerTests + { + public sealed class NextPageAsync : AutoFixtureTestBase + { + [Fact] + public async Task PagesThroughCollectionAsync() + { + int count = 105; + int pageSize = 10; + + var collection = CreateMany(count).ToImmutableArray(); + + var pager = new MemoryPager(collection, pageSize); + + for(int i = 0; i < count / pageSize + 1; i++) + { + var pageResult = await pager.NextPageAsync(Cancel); + + pageResult.AssertSuccess(); + Assert.Equal(pageSize, pageResult.PageSize); + Assert.Equal(i + 1, pageResult.PageNumber); + Assert.Equal(count, pageResult.TotalCount); + Assert.Equal(collection.Skip(i * pageSize).Take(pageSize), pageResult.Value); + if (i == count / pageSize) + Assert.True(pageResult.FetchedAllPages); + else + Assert.False(pageResult.FetchedAllPages); + } + } + + [Fact] + public async Task GetFailsAsync() + { + var failureResult = Result>.Failed(CreateMany()); + + var pager = new MemoryPager((c) => Task.FromResult>>(failureResult), 10); + + var pageResult = await pager.NextPageAsync(Cancel); + + pageResult.AssertFailure(); + Assert.Equal(failureResult.Errors, pageResult.Errors); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/StreamExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/StreamExtensionsTests.cs index 099f4ef1..41a0e2ae 100644 --- a/tests/Tableau.Migration.Tests/Unit/StreamExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/StreamExtensionsTests.cs @@ -134,6 +134,7 @@ public void True() var stream = CreateStream(firstBytes: StreamExtensions.ZIP_LEAD_BYTES); Assert.True(stream.IsZip()); + Assert.Equal(0, stream.Position); } [Fact] @@ -144,6 +145,7 @@ public void False_when_stream_length_is_too_short() var stream = CreateStream(0, bytes); Assert.False(stream.IsZip()); + Assert.Equal(0, stream.Position); } [Fact] @@ -152,6 +154,7 @@ public void False_when_not_zip_header() var stream = CreateStream(); Assert.False(stream.IsZip()); + Assert.Equal(0, stream.Position); } [Fact] @@ -160,6 +163,7 @@ public void False_when_zip_bytes_are_not_first() var stream = CreateStream(firstBytes: new[] { Create() }.Concat(StreamExtensions.ZIP_LEAD_BYTES)); Assert.False(stream.IsZip()); + Assert.Equal(0, stream.Position); } [Theory] @@ -185,6 +189,7 @@ public void Reads_from_beginning() stream.Seek(position, SeekOrigin.Begin); Assert.True(stream.IsZip()); + Assert.Equal(position, stream.Position); } [Fact] diff --git a/tools/Tableau.Migration.CleanServer/GlobalSuppressions.cs b/tools/Tableau.Migration.CleanServer/GlobalSuppressions.cs new file mode 100644 index 00000000..8b65129d --- /dev/null +++ b/tools/Tableau.Migration.CleanServer/GlobalSuppressions.cs @@ -0,0 +1,20 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "")] diff --git a/tools/Tableau.Migration.CleanServer/Program.cs b/tools/Tableau.Migration.CleanServer/Program.cs new file mode 100644 index 00000000..63acee8d --- /dev/null +++ b/tools/Tableau.Migration.CleanServer/Program.cs @@ -0,0 +1,121 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; +using Tableau.Migration; +using Tableau.Migration.Api; +using Tableau.Migration.Content; + +var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + { "Files:RootPath", @"C:\Temp\filestore"}, + { "Network:HeadersLoggingEnabled", "true" }, + { "Network:ContentLoggingEnabled", "true" }, + { "Network:BinaryContentLoggingEnabled", "true" } + }) + // Copy the clean-site-settings.json to clean-site-settings.dev.json and fill in. + .AddJsonFile(Path.Combine(Directory.GetCurrentDirectory(), "clean-site-settings.dev.json")) + .Build(); + +var serviceCollection = new ServiceCollection() + //.AddLogging(b => b.AddConsole()) + .AddTableauMigrationSdk(config); + +var services = serviceCollection.BuildServiceProvider(); + +var connectionConfig = config.GetSection("ConnectionConfig").Get(); + +var apiClient = services.GetRequiredService() + .Initialize(connectionConfig); + +var signIn = await apiClient.SignInAsync(default); + +if (!signIn.Success) +{ + foreach (var e in signIn.Errors) + Console.WriteLine(e); + + // If the above error isn't enough, uncomment the .AddLogging during when newing the ServiceCollection +} + +var siteClient = signIn.Value!; + +// Asks for confirmation. Site name is in purple. +Console.WriteLine($"Are you sure you want to delete all projects, groups, and users on the site \u001b[35m{connectionConfig.SiteContentUrl}\u001b[0m? (y/N)"); +ConsoleKeyInfo response = Console.ReadKey(); + +if (!response.KeyChar.ToString().Equals("y", StringComparison.CurrentCultureIgnoreCase)) +{ + Console.WriteLine("\nOperation canceled.\n"); + return; +} + +Console.WriteLine("\nPreparing to delete projects..."); + +await DeleteProjectsAsync(); +await DeleteGroupsAsync(); +await DeleteUsersAsync(); + +#region - Functions - + +async Task DeleteProjectsAsync() +{ + var projects = await siteClient.Projects.GetAllAsync(100, default).ConfigureAwait(false); + + foreach (var proj in projects.Value!) + { + Console.WriteLine($"About to delete project: {proj.Name}"); + await siteClient.Projects.DeleteProjectAsync(proj.Id, default).ConfigureAwait(false); + } +} + +async Task DeleteGroupsAsync() +{ + var groups = await siteClient.Groups.GetAllAsync(100, default).ConfigureAwait(false); + + foreach (var group in groups.Value!) + { + Console.WriteLine($"About to delete group: {group.Name}"); + await siteClient.Groups.DeleteGroupAsync(group.Id, default).ConfigureAwait(false); + } +} + +async Task DeleteUsersAsync() +{ + var users = await siteClient.Users.GetAllAsync(1000, default); + + var parallelUsers = new ConcurrentBag(users.Value!); + + ParallelOptions parallelOptions = new() + { + MaxDegreeOfParallelism = 10 + }; + + await Parallel.ForEachAsync(parallelUsers, parallelOptions, async (user, cancel) => + { + if (user.AdministratorLevel.Contains("None")) + { + Console.WriteLine($"About to delete user: {user.Name}"); + await siteClient.Users.DeleteUserAsync(user.Id, cancel); + } + }); +} + +#endregion \ No newline at end of file diff --git a/tools/Tableau.Migration.CleanServer/Tableau.Migration.CleanSite.csproj b/tools/Tableau.Migration.CleanServer/Tableau.Migration.CleanSite.csproj new file mode 100644 index 00000000..504a4e17 --- /dev/null +++ b/tools/Tableau.Migration.CleanServer/Tableau.Migration.CleanSite.csproj @@ -0,0 +1,22 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + + + + Always + + + + diff --git a/scripts/clean-server-settings.json b/tools/Tableau.Migration.CleanServer/clean-site-settings.json similarity index 100% rename from scripts/clean-server-settings.json rename to tools/Tableau.Migration.CleanServer/clean-site-settings.json diff --git a/tools/Tableau.Migration.ManifestAnalyzer/ExceptionComparer.cs b/tools/Tableau.Migration.ManifestAnalyzer/ExceptionComparer.cs new file mode 100644 index 00000000..a424adcd --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/ExceptionComparer.cs @@ -0,0 +1,84 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace Tableau.Migration.ManifestAnalyzer +{ + public class ExceptionComparer : IEqualityComparer + { + private static readonly List RegexPatterns = new List + { + // Matches GUIDs in the format 8-4-4-4-12 (e.g., 123e4567-e89b-12d3-a456-426614174000) + new Regex(@"\b[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}\b", RegexOptions.Compiled), + + // Matches upload session IDs in the format uploadSessionId= followed by alphanumeric characters, %, :, or - + new Regex(@"uploadSessionId=[0-9a-fA-F%:-]+", RegexOptions.Compiled), + + // Matches identifiers in the format digits:hexadecimal-0:0 (e.g., 4629:4448bd27de134820bf46a12fd09d4df5-0:0) + new Regex(@"\b\d+:[0-9a-fA-F]{32}-0:0\b", RegexOptions.Compiled), + + // Matches datasource names in the format 'DatasourceName' with start anchor 'Datasource' and end anchor ' not found' + // Message example: There was a problem publishing the file '15575:b3b652dc9b544784b0896b26deecf3f9-0:0'.. (0x9F4C1551 : com.tableausoftware.domain.exception.PublishingException: Datasource 'AssetsSnapshotUSL' not found for workbook 'Updatable License : Phase 1 Mer Edits'.) + new Regex(@"(?i)(?<=Datasource\s').*?(?='\snot\sfound)", RegexOptions.Compiled), + + // Matches workbook names in the format 'WorkbookName' with start anchor 'workbook' and end anchor '.)' + // Message example: There was a problem publishing the file '15575:b3b652dc9b544784b0896b26deecf3f9-0:0'.. (0x9F4C1551 : com.tableausoftware.domain.exception.PublishingException: Datasource 'AssetsSnapshotUSL' not found for workbook 'Updatable License : Phase 1 Mer Edits'.) + new Regex(@"(?i)(?<=workbook\s').*?(?='\.\))", RegexOptions.Compiled), + + // Matches the repeated message about .tde files being deprecated + new Regex(@"Live and extract connection with \.tde files have been deprecated\. To upgrade \.tde files to \.hyper, please follow instructions at https:\/\/community\.tableau\.com\/s\/feed\/0D58b0000CQKAUbCQP\.\s*", RegexOptions.Compiled), + + // Matches file paths in PublishingException messages + // Message example: There was a problem publishing the file '29315:4c2461aa36eb4f5bb491bf88e8913741-0:0'.. (0xD30BACC6 : com.tableausoftware.domain.exception.PublishingException: File Data/Feb2020_ImpactAnalysis/TABLEAU MODELS CRM LEAD SCORES_2022-02-03.csv is too large. Files larger than 1,024 MB decompressed size are not permitted. Please create an extract to proceed with publishing.) + new Regex(@"(?<=com\.tableausoftware\.domain\.exception\.PublishingException:).*?(?=\sis\stoo\slarge\.)", RegexOptions.Compiled), + + // Matches view names in the format 'ViewName' with start anchor 'View with name ' and end anchor ', ownerId' + // Message example: Detail: Customized View with name 'My Account Engagement', ownerId 'f35f7db7-e18e-4a47-b137-e3a0e0276b27', and viewId '8a2098ed-ff6c-4e04-8f00-f6e56e3dc4b7' already exists. + new Regex(@"(?<=View\swith\sname\s').*?(?=',\sownerId)", RegexOptions.Compiled) + }; + + public bool Equals(Exception? x, Exception? y) + { + if (x is null || y is null) + return x == y; + + return x.GetType() == y.GetType() && + RemoveIdentifiers(x.Message) == RemoveIdentifiers(y.Message); + } + + public int GetHashCode(Exception obj) + { + if (obj is null) + return 0; + + return obj.GetType().GetHashCode() ^ RemoveIdentifiers(obj.Message).GetHashCode(); + } + + private string RemoveIdentifiers(string message) + { + foreach (var regex in RegexPatterns) + { + message = regex.Replace(message, string.Empty); + } + return message; + } + } +} + diff --git a/tools/Tableau.Migration.ManifestAnalyzer/HtmlTemplate.cs b/tools/Tableau.Migration.ManifestAnalyzer/HtmlTemplate.cs new file mode 100644 index 00000000..ad22b32b --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/HtmlTemplate.cs @@ -0,0 +1,190 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.ManifestAnalyzer +{ + internal static class HtmlTemplate + { + public const string Template = @" + + + Manifest Analysis Report + + + +
+

Manifest Analysis Report

+
{{datetime}}
+
+
+

Migration Summary

+ {{summary}} +
+
+ {{content}} +
+ + + "; + } +} diff --git a/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzer.cs b/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzer.cs new file mode 100644 index 00000000..03a91ef3 --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzer.cs @@ -0,0 +1,235 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Tableau.Migration.Engine.Manifest; +using Tableau.Migration.Engine.Pipelines; + +namespace Tableau.Migration.ManifestAnalyzer +{ + internal class ManifestAnalyzer : IHostedService + { + private const string MANIFEST_TOP_LEVEL_KEY = "Manifest Top Level"; + private readonly IHostApplicationLifetime _appLifetime; + private readonly MigrationManifestSerializer _manifestSerializer; + private readonly ManifestAnalyzerOptions _options; + + public ManifestAnalyzer( + IHostApplicationLifetime appLifetime, + MigrationManifestSerializer manifestSerializer, + IOptions options) + { + _appLifetime = appLifetime; + _manifestSerializer = manifestSerializer; + _options = options.Value; + } + + public async Task StartAsync(CancellationToken cancel) + { + var manifestPath = _options.ManifestPath; + var errorFilePath = _options.ErrorFilePath; + + MigrationManifest? manifest; + try + { + manifest = await _manifestSerializer.LoadAsync(manifestPath, cancel).ConfigureAwait(false); + } + catch (Exception e) + { + Console.WriteLine("Manifest could not be loaded."); + Console.Error.WriteLine(e); + Console.WriteLine("Press any key to exit."); + Console.ReadKey(); + _appLifetime.StopApplication(); + return; + } + + if (manifest is null) + { + Console.WriteLine("Manifest could not be loaded."); + Console.WriteLine("Press any key to exit."); + Console.ReadKey(); + _appLifetime.StopApplication(); + return; + } + + + var errors = new Dictionary>(); + var totalErrors = new Dictionary(); + + AnalyzeManifestTopLevel(manifest, errors, totalErrors); + + AnalyzeContentTypeEntries(manifest, errors, totalErrors); + + var summary = GenerateSummary(manifest); + + await WriteErrorsToFileAsync(errors, totalErrors, errorFilePath, manifestPath, summary).ConfigureAwait(false); + + _appLifetime.StopApplication(); + } + + private static void AnalyzeContentTypeEntries(MigrationManifest manifest, Dictionary> errors, Dictionary totalErrors) + { + // Go through each content type and count the unique errors and total errors + foreach (var pipelineContentType in MigrationPipelineContentType.GetMigrationPipelineContentTypes(manifest.PipelineProfile)) + { + var errorCounts = new Dictionary(new ExceptionComparer()); + var totalErrorCount = 0; + + foreach (var entry in manifest.Entries.ForContentType(pipelineContentType.ContentType)) + { + totalErrorCount = CalculateErrorCounts(entry.Errors, errorCounts, totalErrorCount); + } + var friendlyName = pipelineContentType.GetConfigKey(); + AddErrors(friendlyName, errors, totalErrors, errorCounts, totalErrorCount); + WriteErrorCountSummary(friendlyName, errorCounts.Count, totalErrorCount); + } + } + + private static void AnalyzeManifestTopLevel(MigrationManifest manifest, Dictionary> errors, Dictionary totalErrors) + { + var manifestErrorCounts = new Dictionary(new ExceptionComparer()); + var totalManifestErrorCount = CalculateErrorCounts(manifest.Errors, manifestErrorCounts); + AddErrors(MANIFEST_TOP_LEVEL_KEY, errors, totalErrors, manifestErrorCounts, totalManifestErrorCount); + WriteErrorCountSummary(MANIFEST_TOP_LEVEL_KEY, manifestErrorCounts.Count, totalManifestErrorCount); + } + + private static int CalculateErrorCounts(IReadOnlyList errorList, Dictionary errorCounts, int totalErrorCount = 0) + { + foreach (var error in errorList) + { + totalErrorCount++; + errorCounts[error] = errorCounts.TryGetValue(error, out int value) ? ++value : 1; + } + + return totalErrorCount; + } + + private static string GenerateSummary(MigrationManifest manifest) + { + var summaryBuilder = new StringBuilder(); + summaryBuilder.AppendLine(""); + summaryBuilder.AppendLine(""); + + foreach (var contentType in MigrationPipelineContentType.GetMigrationPipelineContentTypes(manifest.PipelineProfile)) + { + var entries = manifest.Entries.ForContentType(contentType.ContentType); + var total = entries.Count(); + var migrated = entries.Count(e => e.Status == MigrationManifestEntryStatus.Migrated); + var skipped = entries.Count(e => e.Status == MigrationManifestEntryStatus.Skipped); + var errored = entries.Count(e => e.Status == MigrationManifestEntryStatus.Error); + var pending = entries.Count(e => e.Status == MigrationManifestEntryStatus.Pending); + var canceled = entries.Count(e => e.Status == MigrationManifestEntryStatus.Canceled); + var success = migrated + skipped; + var successRate = total > 0 ? (success / (double)total) * 100 : 0; + + summaryBuilder.AppendLine($""); + } + + summaryBuilder.AppendLine("
Content TypeSuccess RateSuccess iSuccess includes both migrated and skipped entries.ErroredPendingCanceledTotal
{contentType.GetConfigKey()}{successRate:F2}%{success}{errored}{pending}{canceled}{total}
"); + return summaryBuilder.ToString(); + } + + private static void AddErrors(string friendlyName, Dictionary> errors, Dictionary totalErrors, Dictionary errorCounts, int totalErrorCount) + { + errors.Add(friendlyName, errorCounts); + totalErrors.Add(friendlyName, totalErrorCount); + } + + private static void WriteErrorCountSummary(string friendlyName, int errorCounts, int totalErrorCount) + => Console.WriteLine($"{friendlyName} has {errorCounts} unique errors and {totalErrorCount} total errors."); + + private static async Task WriteErrorsToFileAsync(Dictionary> errors, Dictionary totalErrors, string filePath, string manifestPath, string summary) + { + var contentBuilder = new StringBuilder(); + + foreach (var topLevel in errors.Keys.Where(k => k == MANIFEST_TOP_LEVEL_KEY)) + { + AppendSection(contentBuilder, topLevel, errors, totalErrors); + } + + foreach (var contentType in errors.Keys.Where(k => k != MANIFEST_TOP_LEVEL_KEY)) + { + AppendSection(contentBuilder, contentType, errors, totalErrors); + } + + var fileName = Path.GetFileName(manifestPath); + var dateTime = GetDateTimeFromFileName(fileName); + var finalHtml = HtmlTemplate.Template + .Replace("{{datetime}}", dateTime) + .Replace("{{summary}}", summary) + .Replace("{{content}}", contentBuilder.ToString()); + + await File.WriteAllTextAsync(filePath, finalHtml).ConfigureAwait(false); + + // Open the generated HTML file in the default application + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = filePath, + UseShellExecute = true + } + }; + process.Start(); + + static void AppendSection(StringBuilder contentBuilder, string key, Dictionary> errors, Dictionary totalErrors) + { + var errorList = errors[key]; + + contentBuilder.AppendLine($""); + + if (totalErrors[key] == 0) + return; + + contentBuilder.AppendLine("
"); + + foreach (var error in errorList.OrderByDescending(e => e.Value)) + { + contentBuilder.AppendLine($"

Count: {error.Value}

"); + contentBuilder.AppendLine($"
{System.Net.WebUtility.HtmlEncode(error.Key.Message)}
"); + } + + contentBuilder.AppendLine("
"); + } + } + + private static string GetDateTimeFromFileName(string fileName) + { + var match = Regex.Match(fileName, @"^Manifest-(\d{4}-\d{2}-\d{2})-(\d{2}-\d{2}-\d{2})\.json$"); + if (match.Success) + { + var date = DateTime.ParseExact(match.Groups[1].Value, "yyyy-MM-dd", null); + var time = match.Groups[2].Value.Replace("-", ":"); + return $"{date:dddd, MMMM dd, yyyy} {time}"; + } + return string.Empty; + } + + public Task StopAsync(CancellationToken cancel) => Task.CompletedTask; + } +} diff --git a/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzerIcon.ico b/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzerIcon.ico new file mode 100644 index 00000000..e08ab485 Binary files /dev/null and b/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzerIcon.ico differ diff --git a/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzerOptions.cs b/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzerOptions.cs new file mode 100644 index 00000000..3fbc17bb --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/ManifestAnalyzerOptions.cs @@ -0,0 +1,25 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.ManifestAnalyzer +{ + public class ManifestAnalyzerOptions + { + public required string ManifestPath { get; set; } + public required string ErrorFilePath { get; set; } + } +} diff --git a/tools/Tableau.Migration.ManifestAnalyzer/OpenWithManifestAnalyzer.ps1 b/tools/Tableau.Migration.ManifestAnalyzer/OpenWithManifestAnalyzer.ps1 new file mode 100644 index 00000000..4e3a3964 --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/OpenWithManifestAnalyzer.ps1 @@ -0,0 +1,38 @@ +# Check if the script is running with administrator privileges +if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + # Re-launch the script with elevated privileges + $newProcess = Start-Process powershell -ArgumentList "-File `"$PSCommandPath`"" -Verb RunAs -PassThru + $newProcess.WaitForExit() + exit +} + +try { + # Get the directory of the current script + $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + + # Define the path to your program executable + $programPath = Join-Path $scriptDir "Tableau.Migration.ManifestAnalyzer.exe" + + # Define the registry key path for .json files + $regPath = "HKLM:\Software\Classes\SystemFileAssociations\.json\shell\OpenWithManifestAnalyzer" + + # Create the registry key for the context menu item and set the default value + New-Item -Path $regPath -Force -Value "Open with Manifest Analyzer" -ErrorAction Stop | Out-Null + + # Create the command subkey and set the command to run your program with the selected file as an argument + New-Item -Path "$regPath\command" -Force -Value "`"$programPath`" `"%1`"" -ErrorAction Stop | Out-Null + + # Define the path to the icon file + $iconPath = Join-Path $scriptDir "ManifestAnalyzerIcon.ico" + + # Set the icon for the context menu item + Set-ItemProperty -Path $regPath -Name "Icon" -Value $iconPath -ErrorAction Stop + + Write-Output "Context menu item added successfully." +} +catch { + # Write the error details + Write-Error "Context menu failed to be added. Error: $_" + Write-Error "Detailed Error Message: $($_.Exception.Message)" + Write-Error "Stack Trace: $($_.Exception.StackTrace)" +} diff --git a/tools/Tableau.Migration.ManifestAnalyzer/Program.cs b/tools/Tableau.Migration.ManifestAnalyzer/Program.cs new file mode 100644 index 00000000..17a99b86 --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/Program.cs @@ -0,0 +1,77 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Tableau.Migration.ManifestAnalyzer +{ + internal class Program + { + static async Task Main(string[] args) + { + if (args.Length == 0) + { + PrintHelp(); + return; + } + + var manifestPath = args[0]; + var remainingArgs = args.Skip(1).ToArray(); + + var host = Host.CreateDefaultBuilder(remainingArgs) + .ConfigureAppConfiguration(config => + { + config.AddCommandLine(remainingArgs); + }) + .ConfigureServices((context, services) => + { + var configuration = context.Configuration; + + services.Configure(options => + { + options.ManifestPath = manifestPath; + + var errorFilePath = configuration["ErrorFilePath"]; + if (string.IsNullOrEmpty(errorFilePath)) + { + var manifestDirectory = Path.GetDirectoryName(manifestPath) ?? throw new Exception("Can't get directory from manifest path."); + var manifestFileName = Path.GetFileNameWithoutExtension(manifestPath); + errorFilePath = Path.Combine(manifestDirectory, $"{manifestFileName}_Analysis.html"); + } + options.ErrorFilePath = errorFilePath; + }); + + services.AddTableauMigrationSdk(configuration.GetSection("tableau:migrationSdk")); + services.AddHostedService(); + }) + .Build(); + + await host.RunAsync().ConfigureAwait(false); + } + + private static void PrintHelp() + { + Console.WriteLine("Usage: ManifestAnalyzer [--ErrorFilePath ]"); + } + } +} diff --git a/tools/Tableau.Migration.ManifestAnalyzer/README.md b/tools/Tableau.Migration.ManifestAnalyzer/README.md new file mode 100644 index 00000000..dc2d76ad --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/README.md @@ -0,0 +1,27 @@ +# Manifest Analyzer + +# Manifest Analyzer + +The Manifest Analyzer is a tool designed to analyze migration manifests and report on unique errors encountered during the migration process. It is part of the Tableau Migration suite and helps users identify and understand issues that may arise when migrating content from a server to the cloud. + +## Key Features + +- **Manifest Loading**: The tool loads a migration manifest file specified in the configuration. +- **Error Analysis**: It analyzes the manifest entries for different content types and counts unique errors. +- **Error Reporting**: The tool generates a report of the unique errors and their occurrences, which is saved to a specified error file. + +## Installation +From the project folder directly, run `dotnet publish -o ` to the folder you want ManifestAnalyzer to be in. You'll do this everytime it updates. + +Once published, run the `OpenWithManifestAnalyzer.ps1` file to add this tool to the right click menu. You only need to do this once. + +## Usage + +1. **Configuration**: Ensure that the configuration file includes the paths for the manifest file and the error file. +2. **Execution**: Run the Manifest Analyzer. It will load the manifest, analyze the errors, and generate a report. Easiest is to right click on a manifest.json file and click `Open with Manifest Analyzer`. +3. **Review Report**: Check the error file for a detailed report on the unique errors encountered during the migration. + +## Configuration Options + +- **ManifestPath** (optional): The path to the migration manifest file. By default this will be passed in. +- **ErrorFilePath** (optional): The path where the error report will be saved. By default this will be next to the manifest file. \ No newline at end of file diff --git a/tools/Tableau.Migration.ManifestAnalyzer/Tableau.Migration.ManifestAnalyzer.csproj b/tools/Tableau.Migration.ManifestAnalyzer/Tableau.Migration.ManifestAnalyzer.csproj new file mode 100644 index 00000000..5a36f0f5 --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/Tableau.Migration.ManifestAnalyzer.csproj @@ -0,0 +1,30 @@ + + + + Exe + net9.0 + + + + + + + + + + + + + Always + + + Always + Always + + + Always + Always + + + + diff --git a/tools/Tableau.Migration.ManifestAnalyzer/appsettings.json b/tools/Tableau.Migration.ManifestAnalyzer/appsettings.json new file mode 100644 index 00000000..2dd01f8a --- /dev/null +++ b/tools/Tableau.Migration.ManifestAnalyzer/appsettings.json @@ -0,0 +1,4 @@ +{ + "ManifestPath": "", + "ErrorFilePath": "" +} \ No newline at end of file diff --git a/tools/Tableau.Migration.ManifestExplorer.Browser/Program.cs b/tools/Tableau.Migration.ManifestExplorer.Browser/Program.cs new file mode 100644 index 00000000..fe694494 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Browser/Program.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Browser; +using Avalonia.ReactiveUI; +using Tableau.Migration.ManifestExplorer; + +internal sealed partial class Program +{ + private static Task Main(string[] _) => BuildAvaloniaApp() + .WithInterFont() + .UseReactiveUI() + .StartBrowserAppAsync("out"); + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure(); +} diff --git a/tools/Tableau.Migration.ManifestExplorer.Browser/Properties/AssemblyInfo.cs b/tools/Tableau.Migration.ManifestExplorer.Browser/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..d62ecb1c --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Browser/Properties/AssemblyInfo.cs @@ -0,0 +1,18 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +[assembly:System.Runtime.Versioning.SupportedOSPlatform("browser")] diff --git a/tools/Tableau.Migration.ManifestExplorer.Browser/Tableau.Migration.ManifestExplorer.Browser.csproj b/tools/Tableau.Migration.ManifestExplorer.Browser/Tableau.Migration.ManifestExplorer.Browser.csproj new file mode 100644 index 00000000..1ab0c5c5 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Browser/Tableau.Migration.ManifestExplorer.Browser.csproj @@ -0,0 +1,13 @@ + + + net8.0-browser + Exe + true + + + + + + + + diff --git a/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/app.css b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/app.css new file mode 100644 index 00000000..1d6f754a --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/app.css @@ -0,0 +1,58 @@ +/* HTML styles for the splash screen */ +.avalonia-splash { + position: absolute; + height: 100%; + width: 100%; + background: white; + font-family: 'Outfit', sans-serif; + justify-content: center; + align-items: center; + display: flex; + pointer-events: none; +} + +/* Light theme styles */ +@media (prefers-color-scheme: light) { + .avalonia-splash { + background: white; + } + + .avalonia-splash h2 { + color: #1b2a4e; + } + + .avalonia-splash a { + color: #0D6EFD; + } +} + +@media (prefers-color-scheme: dark) { + .avalonia-splash { + background: #1b2a4e; + } + + .avalonia-splash h2 { + color: white; + } + + .avalonia-splash a { + color: white; + } +} + +.avalonia-splash h2 { + font-weight: 400; + font-size: 1.5rem; +} + +.avalonia-splash a { + text-decoration: none; + font-size: 2.5rem; + display: block; +} + +.avalonia-splash.splash-close { + transition: opacity 200ms, display 200ms; + display: none; + opacity: 0; +} diff --git a/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/favicon.ico b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/favicon.ico new file mode 100644 index 00000000..da8d49ff Binary files /dev/null and b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/favicon.ico differ diff --git a/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/index.html b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/index.html new file mode 100644 index 00000000..66d648df --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/index.html @@ -0,0 +1,36 @@ + + + + + Tableau.Migration.ManifestExplorer + + + + + + +
+
+

+ Powered by + + + + + + + + + + + + + + +

+
+
+ + + + diff --git a/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/main.js b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/main.js new file mode 100644 index 00000000..bf1555e4 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Browser/wwwroot/main.js @@ -0,0 +1,13 @@ +import { dotnet } from './_framework/dotnet.js' + +const is_browser = typeof window != "undefined"; +if (!is_browser) throw new Error(`Expected to be running in a browser`); + +const dotnetRuntime = await dotnet + .withDiagnosticTracing(false) + .withApplicationArgumentsFromQuery() + .create(); + +const config = dotnetRuntime.getConfig(); + +await dotnetRuntime.runMain(config.mainAssemblyName, [globalThis.location.href]); diff --git a/tools/Tableau.Migration.ManifestExplorer.Desktop/ManifestExplorerIcon.ico b/tools/Tableau.Migration.ManifestExplorer.Desktop/ManifestExplorerIcon.ico new file mode 100644 index 00000000..cc1f1724 Binary files /dev/null and b/tools/Tableau.Migration.ManifestExplorer.Desktop/ManifestExplorerIcon.ico differ diff --git a/tools/Tableau.Migration.ManifestExplorer.Desktop/OpenWithManifestExplorer.ps1 b/tools/Tableau.Migration.ManifestExplorer.Desktop/OpenWithManifestExplorer.ps1 new file mode 100644 index 00000000..12eba0bc --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Desktop/OpenWithManifestExplorer.ps1 @@ -0,0 +1,38 @@ +# Check if the script is running with administrator privileges +if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + # Re-launch the script with elevated privileges + $newProcess = Start-Process powershell -ArgumentList "-File `"$PSCommandPath`"" -Verb RunAs -PassThru + $newProcess.WaitForExit() + exit +} + +try { + # Get the directory of the current script + $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition + + # Define the path to your program executable + $programPath = Join-Path $scriptDir "Tableau.Migration.ManifestExplorer.Desktop.exe" + + # Define the registry key path for .json files + $regPath = "HKLM:\Software\Classes\SystemFileAssociations\.json\shell\OpenWithManifestExplorer" + + # Create the registry key for the context menu item and set the default value + New-Item -Path $regPath -Force -Value "Open with Manifest Explorer" -ErrorAction Stop | Out-Null + + # Create the command subkey and set the command to run your program with the selected file as an argument + New-Item -Path "$regPath\command" -Force -Value "`"$programPath`" `"%1`"" -ErrorAction Stop | Out-Null + + # Define the path to the icon file + $iconPath = Join-Path $scriptDir "ManifestExplorerIcon.ico" + + # Set the icon for the context menu item + Set-ItemProperty -Path $regPath -Name "Icon" -Value $iconPath -ErrorAction Stop + + Write-Output "Context menu item added successfully." +} +catch { + # Write the error details + Write-Error "Context menu failed to be added. Error: $_" + Write-Error "Detailed Error Message: $($_.Exception.Message)" + Write-Error "Stack Trace: $($_.Exception.StackTrace)" +} diff --git a/tools/Tableau.Migration.ManifestExplorer.Desktop/Program.cs b/tools/Tableau.Migration.ManifestExplorer.Desktop/Program.cs new file mode 100644 index 00000000..815f036d --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Desktop/Program.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Avalonia; +using Avalonia.ReactiveUI; + +namespace Tableau.Migration.ManifestExplorer.Desktop +{ + class Program + { + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .AfterSetup(builder => + { + if (builder.Instance is App app) + { + app.Initialize(args); // Pass args directly to Initialize method + } + }) + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace() + .UseReactiveUI(); + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer.Desktop/README.md b/tools/Tableau.Migration.ManifestExplorer.Desktop/README.md new file mode 100644 index 00000000..e3a5491a --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Desktop/README.md @@ -0,0 +1,12 @@ +# Manifest Explorer + +The Manifest Analyzer is a tool designed to let users view the manifest. It has the ability to filter the entries and to only view entries with errors. + +## Key Features + +- **Manifest Loading**: Loading of the manifest is either done by pressing the `Load Manifest` button or by starting the application with the first command line parameter being the path to the manifest. + +## Installation +From the project folder directly, run `dotnet publish -o -f net9.0` to the folder you want ManifestExplorer to be in. You'll do this everytime it updates. + +Once published, run the `OpenWithManifestExplorer.ps1` file to add this tool to the right click menu. You only need to do this once. diff --git a/tools/Tableau.Migration.ManifestExplorer.Desktop/Tableau.Migration.ManifestExplorer.Desktop.csproj b/tools/Tableau.Migration.ManifestExplorer.Desktop/Tableau.Migration.ManifestExplorer.Desktop.csproj new file mode 100644 index 00000000..53f5c29e --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer.Desktop/Tableau.Migration.ManifestExplorer.Desktop.csproj @@ -0,0 +1,26 @@ + + + WinExe + + net8.0;net9.0 + true + app.manifest + + + + + + + + + + + PreserveNewest + + + Always + Always + + + diff --git a/tools/Tableau.Migration.ManifestExplorer/App.axaml b/tools/Tableau.Migration.ManifestExplorer/App.axaml new file mode 100644 index 00000000..99f7f34e --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/App.axaml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/tools/Tableau.Migration.ManifestExplorer/App.axaml.cs b/tools/Tableau.Migration.ManifestExplorer/App.axaml.cs new file mode 100644 index 00000000..a8e56497 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/App.axaml.cs @@ -0,0 +1,90 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Microsoft.Extensions.DependencyInjection; +using Tableau.Migration.ManifestExplorer.ViewModels; +using Tableau.Migration.ManifestExplorer.Views; + +namespace Tableau.Migration.ManifestExplorer +{ + public partial class App : Application + { + private static readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + public static CancellationToken AppCancellationToken => _cancellationTokenSource.Token; + + public string[] CommandLineArgs { get; private set; } = Array.Empty(); + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public void Initialize(string[] args) + { + CommandLineArgs = args; + Initialize(); + } + + private IServiceProvider BuildServices() + { + var serviceCollection = new ServiceCollection(); + + serviceCollection.AddTableauMigrationSdk(); + + serviceCollection.AddTransient(); + + return serviceCollection.BuildServiceProvider(); + } + + public override void OnFrameworkInitializationCompleted() + { + var services = BuildServices(); + + var mainData = services.GetRequiredService(); + mainData.Initialize(CommandLineArgs); // Pass the command line arguments to the MainViewModel + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow + { + DataContext = mainData + }; + desktop.Exit += OnExit; + } + else if (ApplicationLifetime is ISingleViewApplicationLifetime singleViewPlatform) + { + singleViewPlatform.MainView = new MainView + { + DataContext = mainData + }; + singleViewPlatform.MainView.DetachedFromVisualTree += OnExit; + } + + base.OnFrameworkInitializationCompleted(); + } + + private void OnExit(object? sender, EventArgs e) + { + _cancellationTokenSource.Cancel(); + } + } +} \ No newline at end of file diff --git a/tools/Tableau.Migration.ManifestExplorer/Assets/ManifestExplorer.ico b/tools/Tableau.Migration.ManifestExplorer/Assets/ManifestExplorer.ico new file mode 100644 index 00000000..cc1f1724 Binary files /dev/null and b/tools/Tableau.Migration.ManifestExplorer/Assets/ManifestExplorer.ico differ diff --git a/tools/Tableau.Migration.ManifestExplorer/Assets/StartImage.png b/tools/Tableau.Migration.ManifestExplorer/Assets/StartImage.png new file mode 100644 index 00000000..a547ee76 Binary files /dev/null and b/tools/Tableau.Migration.ManifestExplorer/Assets/StartImage.png differ diff --git a/tools/Tableau.Migration.ManifestExplorer/Assets/avalonia-logo.ico b/tools/Tableau.Migration.ManifestExplorer/Assets/avalonia-logo.ico new file mode 100644 index 00000000..da8d49ff Binary files /dev/null and b/tools/Tableau.Migration.ManifestExplorer/Assets/avalonia-logo.ico differ diff --git a/tools/Tableau.Migration.ManifestExplorer/Converters/InverseBooleanConverter.cs b/tools/Tableau.Migration.ManifestExplorer/Converters/InverseBooleanConverter.cs new file mode 100644 index 00000000..b2a6f9ee --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/Converters/InverseBooleanConverter.cs @@ -0,0 +1,44 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Globalization; +using Avalonia.Data.Converters; + +namespace Tableau.Migration.ManifestExplorer.Converters +{ + public class InverseBooleanConverter : IValueConverter + { + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool boolean) + { + return !boolean; + } + return false; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is bool boolean) + { + return !boolean; + } + return false; + } + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/Tableau.Migration.ManifestExplorer.csproj b/tools/Tableau.Migration.ManifestExplorer/Tableau.Migration.ManifestExplorer.csproj new file mode 100644 index 00000000..9e23cf41 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/Tableau.Migration.ManifestExplorer.csproj @@ -0,0 +1,28 @@ + + + net8.0;net9.0 + true + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignExceptionListDialogViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignExceptionListDialogViewModel.cs new file mode 100644 index 00000000..bdaeebfb --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignExceptionListDialogViewModel.cs @@ -0,0 +1,38 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + internal sealed class DesignExceptionListDialogViewModel : ExceptionListDialogViewModel + { + public DesignExceptionListDialogViewModel() + : base(BuildDesignExceptions()) + { } + + private static IReadOnlyList BuildDesignExceptions() + { + return [ + new Exception("Test"), + new Exception("Test 2"), + new Exception("Test 3"), + ]; + } + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignMainViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignMainViewModel.cs new file mode 100644 index 00000000..c85a8a0d --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignMainViewModel.cs @@ -0,0 +1,28 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.IO.Abstractions; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + internal sealed class DesignMainViewModel : MainViewModel + { + public DesignMainViewModel() + : base(new(new FileSystem())) + { } + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignManifestViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignManifestViewModel.cs new file mode 100644 index 00000000..f03be677 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/DesignManifestViewModel.cs @@ -0,0 +1,72 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using Tableau.Migration.Engine.Manifest; +using Tableau.Migration.Engine.Pipelines; +using Tableau.Migration.JsonConverters.SerializableObjects; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + internal sealed class DesignManifestViewModel : ManifestViewModel + { + public DesignManifestViewModel() + : base(BuildDesignManifest(), new DesignMainViewModel()) + { } + + private static IMigrationManifest BuildDesignManifest() + { + var manifest = new SerializableMigrationManifest + { + Entries = new(), + Errors = new(), + PipelineProfile = PipelineProfile.ServerToCloud, + ManifestVersion = MigrationManifest.LatestManifestVersion, + MigrationId = Guid.NewGuid(), + PlanId = Guid.NewGuid() + }; + + SerializableManifestEntry CreateEntry(MigrationPipelineContentType type, MigrationManifestEntryStatus status, params string[] path) + { + var sourceLocation = ContentLocation.ForContentType(type.ContentType, path); + + return new SerializableManifestEntry + { + Source = new() { Id = Guid.NewGuid().ToString(), ContentUrl = string.Empty, Location = new(sourceLocation), Name = path[^1] }, + MappedLocation = new(sourceLocation), + Status = status.ToString() + }; + } + + foreach (var contentType in MigrationPipelineContentType.GetMigrationPipelineContentTypes(manifest.PipelineProfile.Value)) + { + var entries = new List + { + CreateEntry(contentType, MigrationManifestEntryStatus.Migrated, "Migrated"), + CreateEntry(contentType, MigrationManifestEntryStatus.Migrated, "Parent", "Migrated"), + CreateEntry(contentType, MigrationManifestEntryStatus.Skipped, "Skipped"), + CreateEntry(contentType, MigrationManifestEntryStatus.Skipped, "Parent", "Skipped"), + }; + + manifest.Entries.Add(contentType.ContentType.FullName!, entries); + } + + return manifest.ToMigrationManifest(); + } + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/ExceptionListDialogViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ExceptionListDialogViewModel.cs new file mode 100644 index 00000000..eb5870b2 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ExceptionListDialogViewModel.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public class ExceptionListDialogViewModel + { + public IReadOnlyList Exceptions { get; } + + public ExceptionListDialogViewModel(IReadOnlyList exceptions) + { + Exceptions = exceptions; + } + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/IShowExceptionListDialogViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/IShowExceptionListDialogViewModel.cs new file mode 100644 index 00000000..716fa8fd --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/IShowExceptionListDialogViewModel.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Reactive; +using ReactiveUI; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public interface IShowExceptionListDialogViewModel + { + Interaction, Unit> ShowExceptionListDialog { get; } + + ReactiveCommand, Unit> ShowExceptionListDialogCommand { get; } + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/MainViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/MainViewModel.cs new file mode 100644 index 00000000..18126f7a --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/MainViewModel.cs @@ -0,0 +1,141 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reactive; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Platform.Storage; +using ReactiveUI; +using Tableau.Migration.Engine.Manifest; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public class MainViewModel : ViewModelBase, IShowExceptionListDialogViewModel + { + private readonly MigrationManifestSerializer _serializer; + + public ManifestViewModel? ManifestViewModel + { + get => _manifestViewModel; + set => this.RaiseAndSetIfChanged(ref _manifestViewModel, value); + } + private ManifestViewModel? _manifestViewModel; + + public Interaction OpenFileDialog { get; } + + public Interaction, Unit> ShowExceptionListDialog { get; } + + public ReactiveCommand LoadManifestCommand { get; } + + public ReactiveCommand, Unit> ShowExceptionListDialogCommand { get; } + + private bool _isManifestViewVisible; + public bool IsManifestViewVisible + { + get => _isManifestViewVisible; + set + { + this.RaiseAndSetIfChanged(ref _isManifestViewVisible, value); + this.RaisePropertyChanged(nameof(IsOverlayVisible)); + } + } + + // Reverse of IsManifestViewVisible for overlay visibility + public bool IsOverlayVisible => !IsManifestViewVisible; + + private bool _isLoading; + public bool IsLoading + { + get => _isLoading; + set => this.RaiseAndSetIfChanged(ref _isLoading, value); + } + + public MainViewModel(MigrationManifestSerializer serializer) + { + _serializer = serializer; + + OpenFileDialog = new(); + ShowExceptionListDialog = new(); + + LoadManifestCommand = ReactiveCommand.CreateFromTask(() => LoadManifestAsync()); + ShowExceptionListDialogCommand = ReactiveCommand.CreateFromTask>(ShowExceptionListDialogAsync); + + // Initialize the visibility property + IsManifestViewVisible = false; + IsLoading = false; + } + + public async void Initialize(string[] args) + { + if (args.Length > 0 && !string.IsNullOrEmpty(args[0])) + { + await LoadManifestAsync(args[0]).ConfigureAwait(false); + } + } + + private async Task LoadManifestAsync(string? filePath = null) + { + try + { + IsLoading = true; + + if (string.IsNullOrEmpty(filePath)) + { + var file = await OpenFileDialog.Handle(this); + if (file is null) + { + IsLoading = false; + return; + } + + filePath = file.Path.LocalPath; + } + + using var fileStream = File.OpenRead(filePath); + var newManifest = await _serializer.LoadAsync(fileStream, CancellationToken.None) + .ConfigureAwait(false); + + if (newManifest is null) + { + IsLoading = false; + return; + } + + ManifestViewModel = new(newManifest, this); + IsManifestViewVisible = true; // Set visibility to true when a new manifest is loaded + } + catch (Exception ex) + { + // Handle exceptions (e.g., file not found, invalid format) + await ShowExceptionListDialog.Handle(new List { ex }); + } + finally + { + IsLoading = false; + } + } + + private async Task ShowExceptionListDialogAsync(IReadOnlyList exceptionList) + { + await ShowExceptionListDialog.Handle(exceptionList); + } + } +} \ No newline at end of file diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestEntryPageViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestEntryPageViewModel.cs new file mode 100644 index 00000000..e342788d --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestEntryPageViewModel.cs @@ -0,0 +1,117 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using ReactiveUI; +using Tableau.Migration.Engine.Manifest; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public class ManifestEntryPageViewModel : ViewModelBase, INotifyPropertyChanged + { + #region - Properties - + public IShowExceptionListDialogViewModel ShowExceptionList { get; } + + private readonly IReadOnlyCollection _entries; + private ObservableCollection _shownEntries; + + private CancellationTokenSource _searchCancellationTokenSource = new(); + public ObservableCollection ShownEntries + { + get => _shownEntries; + set + { + _shownEntries = value; + OnPropertyChanged(nameof(ShownEntries)); + } + } + + //public int TotalCount { get; } + #endregion + + public ManifestEntryPageViewModel(IReadOnlyCollection entries, IShowExceptionListDialogViewModel showExceptionList) + { + _entries = entries; + ShowExceptionList = showExceptionList; + + _shownEntries = new ObservableCollection(_entries.Select(e => new ManifestEntryViewModel(e))); + } + + public async Task SearchAsync(string searchText, bool errorsOnly, CancellationToken cancel) + { + // Cancel the previous search if it is still running + _searchCancellationTokenSource.Cancel(); + _searchCancellationTokenSource = new CancellationTokenSource(); + var searchToken = _searchCancellationTokenSource.Token; + + // Create a linked token that combines the provided token and the search-specific token + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancel, searchToken); + var linkedToken = linkedCts.Token; + + if (string.IsNullOrWhiteSpace(searchText) && !errorsOnly) + { + ShownEntries = new ObservableCollection(_entries.Select(e => new ManifestEntryViewModel(e))); + return; + } + + var filteredEntries = await Task.Run(() => + { + return _entries.Where(e => + (!errorsOnly || e.Errors.Any()) && + + (string.IsNullOrWhiteSpace(searchText) || + e.Source.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + e.Source.Id.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) || + e.Source.Location.Path.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + + (e.Destination != null && + (e.Destination.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + e.Destination.Id.ToString().Contains(searchText, StringComparison.OrdinalIgnoreCase) || + e.Destination.Location.Path.Contains(searchText, StringComparison.OrdinalIgnoreCase))) || + + e.MappedLocation.Name.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + e.MappedLocation.Path.Contains(searchText, StringComparison.OrdinalIgnoreCase) || + + e.Errors.Any(err => err.Message.Contains(searchText, StringComparison.OrdinalIgnoreCase)) + ) + ).Select(e => new ManifestEntryViewModel(e)).ToList(); + }, linkedToken).ConfigureAwait(false); + + if (!linkedToken.IsCancellationRequested) + { + ShownEntries = new ObservableCollection(filteredEntries); + } + } + + #region - Event Handlers - + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + this.RaisePropertyChanged(propertyName); + } + + #endregion + } + +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestEntryViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestEntryViewModel.cs new file mode 100644 index 00000000..abb9d644 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestEntryViewModel.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Avalonia.Controls; +using ReactiveUI; +using Tableau.Migration.Engine.Manifest; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public class ManifestEntryViewModel : ViewModelBase + { + public IMigrationManifestEntry Entry { get; } + + private DataGridRowDetailsVisibilityMode _detailsVisibility; + public DataGridRowDetailsVisibilityMode DetailsVisibility + { + get => _detailsVisibility; + set => this.RaiseAndSetIfChanged(ref _detailsVisibility, value); + } + + public ManifestEntryViewModel(IMigrationManifestEntry entry) + { + Entry = entry; + _detailsVisibility = DataGridRowDetailsVisibilityMode.Collapsed; + } + } +} \ No newline at end of file diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestPartitionViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestPartitionViewModel.cs new file mode 100644 index 00000000..cf45772d --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestPartitionViewModel.cs @@ -0,0 +1,42 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Engine.Manifest; +using Tableau.Migration.Engine.Pipelines; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public class ManifestPartitionViewModel : ViewModelBase + { + public string Header { get; } + + public ManifestEntryPageViewModel Entries { get; } + + public ManifestPartitionViewModel(IMigrationManifestContentTypePartition partition, IShowExceptionListDialogViewModel showExceptionList) + { + Header = MigrationPipelineContentType.GetDisplayNameForType(partition.ContentType, plural: true); + Entries = new(partition, showExceptionList); + } + + public async Task UpdateEntriesAsync(string searchText, bool errorsOnly, CancellationToken cancel) + { + await Entries.SearchAsync(searchText, errorsOnly, cancel).ConfigureAwait(false); + } + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestViewModel.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestViewModel.cs new file mode 100644 index 00000000..a1771c44 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ManifestViewModel.cs @@ -0,0 +1,95 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Immutable; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using ReactiveUI; +using Tableau.Migration.Engine.Manifest; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public class ManifestViewModel : ViewModelBase, INotifyPropertyChanged + { + #region - Properties - + + private string _searchText = string.Empty; + public string SearchText + { + get => _searchText; + set + { + if (_searchText != value) + { + _searchText = value; + OnPropertyChanged(); + _ = UpdateDisplayedContentAsync(App.AppCancellationToken); + } + } + } + + private bool _errorsOnly = false; + public bool ErrorsOnly + { + get => _errorsOnly; + set + { + if (_errorsOnly != value) + { + _errorsOnly = value; + OnPropertyChanged(); + _ = UpdateDisplayedContentAsync(App.AppCancellationToken); + } + } + } + + + + public ImmutableArray Partitions { get; } + + #endregion + + public ManifestViewModel(IShowExceptionListDialogViewModel showExceptionList) + : this(new MigrationManifest(PipelineProfile.ServerToCloud), showExceptionList) // Defaulting to Server To Cloud + { } + + public ManifestViewModel(IMigrationManifest manifest, IShowExceptionListDialogViewModel showExceptionList) + { + Partitions = manifest.Entries.GetPartitionTypes() + .Select(t => new ManifestPartitionViewModel(manifest.Entries.ForContentType(t), showExceptionList)) + .ToImmutableArray(); + } + + private async Task UpdateDisplayedContentAsync(CancellationToken cancel) + { + var tasks = Partitions.Select(item => item.UpdateEntriesAsync(SearchText, ErrorsOnly, cancel)); + await Task.WhenAll(tasks).ConfigureAwait(false); + } + + #region - Event Handlers - + + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + this.RaisePropertyChanged(propertyName); + } + + #endregion + } +} diff --git a/tools/Tableau.Migration.ManifestExplorer/ViewModels/ViewModelBase.cs b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..e50f07a1 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/ViewModels/ViewModelBase.cs @@ -0,0 +1,24 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using ReactiveUI; + +namespace Tableau.Migration.ManifestExplorer.ViewModels +{ + public class ViewModelBase : ReactiveObject + { } +} \ No newline at end of file diff --git a/tools/Tableau.Migration.ManifestExplorer/Views/ExceptionListDialog.axaml b/tools/Tableau.Migration.ManifestExplorer/Views/ExceptionListDialog.axaml new file mode 100644 index 00000000..2926b25f --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/Views/ExceptionListDialog.axaml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + diff --git a/tools/Tableau.Migration.ManifestExplorer/Views/ExceptionListDialog.axaml.cs b/tools/Tableau.Migration.ManifestExplorer/Views/ExceptionListDialog.axaml.cs new file mode 100644 index 00000000..ba828acd --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/Views/ExceptionListDialog.axaml.cs @@ -0,0 +1,29 @@ +// +// Copyright (c) 2025, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Avalonia.Controls; + +namespace Tableau.Migration.ManifestExplorer +{ + public partial class ExceptionListDialog : Window + { + public ExceptionListDialog() + { + InitializeComponent(); + } + } +} \ No newline at end of file diff --git a/tools/Tableau.Migration.ManifestExplorer/Views/MainView.axaml b/tools/Tableau.Migration.ManifestExplorer/Views/MainView.axaml new file mode 100644 index 00000000..d7c6ec52 --- /dev/null +++ b/tools/Tableau.Migration.ManifestExplorer/Views/MainView.axaml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + +