From 9460621b4849602ab28bdd96acc71bc54865dd5b Mon Sep 17 00:00:00 2001 From: Pranav Vishnumolakala Date: Wed, 30 Oct 2024 13:29:55 -0700 Subject: [PATCH] Release 5.0.0 --- Directory.Build.props | 2 +- .../Csharp.ExampleApplication.csproj | 4 +- .../InitializeMigration/CustomContext.cs | 13 + .../SetMigrationContextHook.cs | 23 + .../MyMigrationApplication.cs | 6 + examples/Csharp.ExampleApplication/Program.cs | 9 + ...endencyInjection.ExampleApplication.csproj | 4 +- .../set_custom_context_hook.py | 10 + .../Python.ExampleApplication.pyproj | 2 + .../Python.ExampleApplication/print_result.py | 17 +- global.json | 2 +- .../articles/hooks/custom_hooks.md | 24 +- src/Documentation/articles/hooks/index.md | 8 +- .../samples/initialize-migration/index.md | 7 + .../set_custom_context.md | 57 +++ .../samples/initialize-migration/toc.yml | 2 + src/Python/Python.pyproj | 3 + src/Python/pyproject.toml | 4 +- src/Python/scripts/build-package.ps1 | 2 +- src/Python/scripts/build_binaries.py | 2 +- src/Python/src/tableau_migration/__init__.py | 5 + .../migration_engine_hooks.py | 8 +- .../migration_engine_hooks_interop.py | 48 +- .../migration_engine_hooks_results.py | 58 +++ .../migration_engine_manifest.py | 13 + .../migration_engine_pipelines.py | 147 ++++++ src/Python/tests/test_classes.py | 58 ++- .../tests/test_migration_engine_hooks.py | 79 +++- .../tests/test_migration_engine_pipelines.py | 105 +++++ .../Generators/PythonMemberGenerator.cs | 4 + .../Generators/PythonPropertyGenerator.cs | 44 +- .../Keywords/Dotnet/Namespaces.cs | 9 +- .../PythonGenerationList.cs | 16 +- .../PythonGeneratorService.cs | 5 +- .../PythonProperty.cs | 4 +- .../Tableau.Migration.PythonGenerator.csproj | 6 +- .../Writers/PythonConstructorTestWriter.cs | 20 +- .../Writers/PythonMemberWriter.cs | 17 + .../Writers/PythonPropertyTestWriter.cs | 73 +-- .../Writers/PythonPropertyWriter.cs | 64 ++- .../appsettings.json | 19 + .../Api/IHttpResponseMessageExtensions.cs | 17 + .../Api/IProjectsApiClient.cs | 13 +- .../Api/IServiceCollectionExtensions.cs | 11 +- .../DefaultPermissionsApiClient.cs | 29 +- .../IDefaultPermissionsApiClient.cs | 22 +- .../Api/Permissions/IPermissionsApiClient.cs | 26 +- .../Api/Permissions/PermissionsApiClient.cs | 116 +---- .../Api/ProjectsApiClient.cs | 2 +- .../Api/Rest/RestException.cs | 14 +- .../Api/PermissionsRestApiSimulatorBase.cs | 9 + .../Rest/Api/ProjectsRestApiSimulator.cs | 9 + .../Api/Simulation/TableauData.cs | 12 +- src/Tableau.Migration/Api/TasksApiClient.cs | 17 +- src/Tableau.Migration/Constants.cs | 2 + ...efaultPermissionsContentTypeUrlSegments.cs | 5 - .../Content/Schedules/Cloud/CloudSchedule.cs | 4 + .../Schedules/Cloud/CloudScheduleValidator.cs | 354 ++++++++++++++ .../ExtractRefreshTaskConverterBase.cs | 52 +++ .../Content/Schedules/FrequencyDetails.cs | 15 +- .../Schedules/IExtractRefreshTaskConverter.cs | 42 ++ .../Content/Schedules/ILoggerExtensions.cs | 49 -- .../Content/Schedules/IScheduleValidator.cs | 25 + .../Content/Schedules/IntervalValues.cs | 51 +- .../Schedules/InvalidScheduleException.cs | 45 ++ .../Content/Schedules/ScheduleBase.cs | 17 +- .../Content/Schedules/ScheduleComparers.cs | 36 +- .../Server/ServerExtractRefreshTask.cs | 2 +- .../Server/ServerScheduleValidator.cs | 255 ++++++++++ ...erverToCloudExtractRefreshTaskConverter.cs | 271 +++++++++++ .../Engine/Actions/PreflightAction.cs | 31 +- .../Engine/Endpoints/IDestinationEndpoint.cs | 4 +- .../TableauApiDestinationEndpoint.cs | 4 +- .../Engine/Hooks/Filters/ContentFilterBase.cs | 2 +- .../Engine/Hooks/IInitializeMigrationHook.cs | 25 + .../Hooks/IInitializeMigrationHookResult.cs | 39 ++ .../Hooks/InitializeMigrationHookResult.cs | 38 ++ .../CloudIncrementalRefreshTransformer.cs | 52 --- .../CloudScheduleCompatibilityTransformer.cs | 74 --- .../Engine/IServiceCollectionExtensions.cs | 4 +- .../IMigrationManifestContentTypePartition.cs | 13 + .../IMigrationManifestEntryBuilder.cs | 10 +- .../Manifest/IMigrationManifestEntryEditor.cs | 6 + .../Engine/Manifest/MigrationManifest.cs | 2 +- .../MigrationManifestContentTypePartition.cs | 40 +- .../Engine/Manifest/MigrationManifestEntry.cs | 49 +- .../MigrationManifestEntryCollection.cs | 13 +- .../Engine/MigrationPlanBuilder.cs | 3 - .../Engine/Migrators/ContentMigrator.cs | 2 +- .../Pipelines/MigrationPipelineRunner.cs | 9 + src/Tableau.Migration/EquatableException.cs | 78 ++++ src/Tableau.Migration/HttpHeaderExtensions.cs | 35 ++ .../IDictionaryExtensions.cs | 59 +++ .../Hooks/ISyncInitializeMigrationHook.cs | 38 ++ .../Interop/InteropHelper.cs | 23 + .../RestExceptionJsonConverter.cs | 12 +- .../SerializableManifestEntry.cs | 6 +- .../Net/DefaultHttpResponseMessage.cs | 13 +- .../Handlers/RequestCorrelationIdHandler.cs | 58 +++ .../Net/IServiceCollectionExtensions.cs | 4 +- .../Net/NetworkTraceLogger.cs | 16 +- .../Net/Rest/HttpRequestMessageExtensions.cs | 20 + .../Resources/SharedResourceKeys.cs | 72 +++ .../Resources/SharedResources.resx | 124 ++++- .../Tableau.Migration.csproj | 14 +- src/Tableau.Migration/TypeExtensions.cs | 20 + .../pyproject.toml | 2 +- tests/Python.TestApplication/build.py | 2 +- tests/Python.TestApplication/pyproject.toml | 2 +- .../ActivityEnricher.cs | 42 ++ .../Config/TestApplicationOptions.cs | 2 + .../Hooks/LogMigrationBatchSummaryHook.cs | 59 +++ .../Hooks/TimeLoggerAfterActionHook.cs | 16 +- .../Hooks/ViewerOwnerTransformer.cs | 81 ++++ .../LogFileHelper.cs | 2 +- .../MigrationSummaryBuilder.cs | 140 ++++++ .../Program.cs | 44 +- .../Tableau.Migration.TestApplication.csproj | 14 +- .../TestApplication.cs | 104 ++--- .../appsettings.json | 14 +- .../AutoFixtureTestBaseTests.cs | 4 +- .../Tableau.Migration.Tests/FixtureFactory.cs | 6 +- .../ServerToCloudSimulationTestBase.cs | 9 +- .../Tests/CustomViewsMigrationTests.cs | 5 +- .../Tests/DataSourceMigrationTests.cs | 6 +- .../Tests/ExtractRefreshTaskMigrationTests.cs | 24 +- .../Simulation/Tests/GroupMigrationTests.cs | 6 +- .../Simulation/Tests/ProjectMigrationTests.cs | 5 +- .../Simulation/Tests/UserMigrationTests.cs | 7 +- .../Tests/WorkbookMigrationTests.cs | 11 +- .../Tableau.Migration.Tests.csproj | 14 +- .../Unit/Api/ApiClientTestBase.cs | 13 +- .../Unit/Api/ApiClientTestDependencies.cs | 1 - .../DefaultPermissionsApiClientTests.cs | 24 - .../Permissions/PermissionsApiClientTests.cs | 386 ++++------------ .../CommitWorkbookPublishRequestTests.cs | 3 +- .../Unit/Api/Rest/RestExceptionTests.cs | 4 + .../Unit/Api/SiteApiTestBase.cs | 37 ++ .../Permissions/GranteeCapabilityTests.cs | 16 +- .../Cloud/CloudScheduleValidatorTests.cs | 435 ++++++++++++++++++ .../Schedules/ILoggerExtensionsTests.cs | 95 ---- .../Schedules/IntervalComparerTests.cs | 71 +++ .../Server/ServerScheduleValidatorTests.cs | 302 ++++++++++++ ...ToCloudExtractRefreshTaskConverterTests.cs | 391 ++++++++++++++++ .../Engine/Actions/PreflightActionTests.cs | 49 +- ...tDestinationContentReferenceFinderTests.cs | 14 +- ...nifestSourceContentReferenceFinderTests.cs | 4 +- .../InitializeMigrationHookResultTests.cs | 75 +++ ...CloudIncrementalRefreshTransformerTests.cs | 74 --- ...udScheduleCompatibilityTransformerTests.cs | 101 ---- ...rationManifestContentTypePartitionTests.cs | 193 +++++++- .../MigrationManifestEntryCollectionTests.cs | 15 +- .../Manifest/MigrationManifestEntryTests.cs | 69 ++- .../Unit/Engine/MigrationPlanBuilderTests.cs | 2 - .../Engine/Migrators/ContentMigratorTests.cs | 45 +- .../TestSerializableManifestEntry.cs | 13 +- ...LoggingServiceCollectionExtensionsTests.cs | 2 +- .../Resources/AllUsersTranslationsTests.cs | 2 +- 158 files changed, 5150 insertions(+), 1444 deletions(-) create mode 100644 examples/Csharp.ExampleApplication/Hooks/InitializeMigration/CustomContext.cs create mode 100644 examples/Csharp.ExampleApplication/Hooks/InitializeMigration/SetMigrationContextHook.cs create mode 100644 examples/Python.ExampleApplication/Hooks/initialize_migration/set_custom_context_hook.py create mode 100644 src/Documentation/samples/initialize-migration/index.md create mode 100644 src/Documentation/samples/initialize-migration/set_custom_context.md create mode 100644 src/Documentation/samples/initialize-migration/toc.yml create mode 100644 src/Python/src/tableau_migration/migration_engine_hooks_results.py create mode 100644 src/Python/src/tableau_migration/migration_engine_pipelines.py create mode 100644 src/Python/tests/test_migration_engine_pipelines.py create mode 100644 src/Tableau.Migration/Content/Schedules/Cloud/CloudScheduleValidator.cs create mode 100644 src/Tableau.Migration/Content/Schedules/ExtractRefreshTaskConverterBase.cs create mode 100644 src/Tableau.Migration/Content/Schedules/IExtractRefreshTaskConverter.cs delete mode 100644 src/Tableau.Migration/Content/Schedules/ILoggerExtensions.cs create mode 100644 src/Tableau.Migration/Content/Schedules/IScheduleValidator.cs create mode 100644 src/Tableau.Migration/Content/Schedules/InvalidScheduleException.cs create mode 100644 src/Tableau.Migration/Content/Schedules/Server/ServerScheduleValidator.cs create mode 100644 src/Tableau.Migration/Content/Schedules/ServerToCloudExtractRefreshTaskConverter.cs create mode 100644 src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHook.cs create mode 100644 src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs create mode 100644 src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs delete mode 100644 src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformer.cs delete mode 100644 src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformer.cs create mode 100644 src/Tableau.Migration/EquatableException.cs create mode 100644 src/Tableau.Migration/HttpHeaderExtensions.cs create mode 100644 src/Tableau.Migration/IDictionaryExtensions.cs create mode 100644 src/Tableau.Migration/Interop/Hooks/ISyncInitializeMigrationHook.cs create mode 100644 src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHandler.cs create mode 100644 tests/Tableau.Migration.TestApplication/ActivityEnricher.cs create mode 100644 tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs create mode 100644 tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs create mode 100644 tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs create mode 100644 tests/Tableau.Migration.Tests/Unit/Api/SiteApiTestBase.cs create mode 100644 tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs delete mode 100644 tests/Tableau.Migration.Tests/Unit/Content/Schedules/ILoggerExtensionsTests.cs create mode 100644 tests/Tableau.Migration.Tests/Unit/Content/Schedules/IntervalComparerTests.cs create mode 100644 tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs create mode 100644 tests/Tableau.Migration.Tests/Unit/Content/Schedules/ServerToCloudExtractRefreshTaskConverterTests.cs create mode 100644 tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigrationHookResultTests.cs delete mode 100644 tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformerTests.cs delete mode 100644 tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformerTests.cs diff --git a/Directory.Build.props b/Directory.Build.props index d641ca5..c6d5209 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable true true - 4.3.1 + 5.0.0 Salesforce, Inc. Salesforce, Inc. Copyright (c) 2024, Salesforce, Inc. and its licensors diff --git a/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj b/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj index ac6bdc1..ef8c3a9 100644 --- a/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj +++ b/examples/Csharp.ExampleApplication/Csharp.ExampleApplication.csproj @@ -1,7 +1,7 @@  Exe - net6.0;net8.0 + net8.0 CA2007,IDE0073 8368baab-103b-45f6-bfb1-f89a537f4f3c @@ -10,7 +10,7 @@ - + diff --git a/examples/Csharp.ExampleApplication/Hooks/InitializeMigration/CustomContext.cs b/examples/Csharp.ExampleApplication/Hooks/InitializeMigration/CustomContext.cs new file mode 100644 index 0000000..a294e33 --- /dev/null +++ b/examples/Csharp.ExampleApplication/Hooks/InitializeMigration/CustomContext.cs @@ -0,0 +1,13 @@ +using System; + +namespace Csharp.ExampleApplication.Hooks.InitializeMigration +{ + #region class + + public class CustomContext + { + public Guid CustomerId { get; set; } + } + + #endregion +} diff --git a/examples/Csharp.ExampleApplication/Hooks/InitializeMigration/SetMigrationContextHook.cs b/examples/Csharp.ExampleApplication/Hooks/InitializeMigration/SetMigrationContextHook.cs new file mode 100644 index 0000000..e3b5a68 --- /dev/null +++ b/examples/Csharp.ExampleApplication/Hooks/InitializeMigration/SetMigrationContextHook.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Tableau.Migration.Engine.Hooks; + +namespace Csharp.ExampleApplication.Hooks.InitializeMigration +{ + #region class + + internal class SetMigrationContextHook : IInitializeMigrationHook + { + public Task ExecuteAsync(IInitializeMigrationHookResult ctx, CancellationToken cancel) + { + var customContext = ctx.ScopedServices.GetRequiredService(); + customContext.CustomerId = Guid.NewGuid(); + + return Task.FromResult(ctx); + } + } + + #endregion +} diff --git a/examples/Csharp.ExampleApplication/MyMigrationApplication.cs b/examples/Csharp.ExampleApplication/MyMigrationApplication.cs index bd1b73b..0c8346c 100644 --- a/examples/Csharp.ExampleApplication/MyMigrationApplication.cs +++ b/examples/Csharp.ExampleApplication/MyMigrationApplication.cs @@ -8,6 +8,7 @@ using Csharp.ExampleApplication.Config; using Csharp.ExampleApplication.Hooks.BatchMigrationCompleted; using Csharp.ExampleApplication.Hooks.Filters; +using Csharp.ExampleApplication.Hooks.InitializeMigration; using Csharp.ExampleApplication.Hooks.Mappings; using Csharp.ExampleApplication.Hooks.MigrationActionCompleted; using Csharp.ExampleApplication.Hooks.PostPublish; @@ -147,6 +148,11 @@ public async Task StartAsync(CancellationToken cancel) _planBuilder.Transformers.Add(); #endregion + // Add initialize migration hooks + #region SetCustomContext-Registration + _planBuilder.Hooks.Add(); + #endregion + // Add migration action completed hooks #region LogMigrationActionsHook-Registration _planBuilder.Hooks.Add(); diff --git a/examples/Csharp.ExampleApplication/Program.cs b/examples/Csharp.ExampleApplication/Program.cs index 48c1d6c..f322db8 100644 --- a/examples/Csharp.ExampleApplication/Program.cs +++ b/examples/Csharp.ExampleApplication/Program.cs @@ -2,6 +2,7 @@ using Csharp.ExampleApplication.Config; using Csharp.ExampleApplication.Hooks.BatchMigrationCompleted; using Csharp.ExampleApplication.Hooks.Filters; +using Csharp.ExampleApplication.Hooks.InitializeMigration; using Csharp.ExampleApplication.Hooks.Mappings; using Csharp.ExampleApplication.Hooks.MigrationActionCompleted; using Csharp.ExampleApplication.Hooks.PostPublish; @@ -44,6 +45,14 @@ public static async Task Main(string[] args) /// The same service collection as the parameter. public static IServiceCollection AddCustomizations(this IServiceCollection services) { + #region SetCustomContext-Service-DI + services.AddScoped(); + #endregion + + #region SetCustomContext-Hook-DI + services.AddScoped(); + #endregion + #region EmailDomainMapping-DI services.AddScoped(); #endregion diff --git a/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj b/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj index 0ce3747..38aa6c0 100644 --- a/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj +++ b/examples/DependencyInjection.ExampleApplication/DependencyInjection.ExampleApplication.csproj @@ -2,13 +2,13 @@ Exe - net6.0;net8.0 + net8.0 CA2007,IDE0073 - + diff --git a/examples/Python.ExampleApplication/Hooks/initialize_migration/set_custom_context_hook.py b/examples/Python.ExampleApplication/Hooks/initialize_migration/set_custom_context_hook.py new file mode 100644 index 0000000..6ce5b51 --- /dev/null +++ b/examples/Python.ExampleApplication/Hooks/initialize_migration/set_custom_context_hook.py @@ -0,0 +1,10 @@ +from tableau_migration import( + InitializeMigrationHookBase, + IInitailizeMigrationHookResult +) + +from Csharp.ExampleApplication.Hooks.InitializeMigration import CustomContext + +class SetMigrationContextHook(InitializeMigrationHookBase): + def execute(self, ctx: IInitailizeMigrationHookResult) -> IInitailizeMigrationHookResult: + ctx.scoped_services._get_service(CustomContext) \ No newline at end of file diff --git a/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj b/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj index 459ae6a..6f49b4e 100644 --- a/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj +++ b/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj @@ -25,6 +25,7 @@ + @@ -60,6 +61,7 @@ + diff --git a/examples/Python.ExampleApplication/print_result.py b/examples/Python.ExampleApplication/print_result.py index 5e6dd00..b260882 100644 --- a/examples/Python.ExampleApplication/print_result.py +++ b/examples/Python.ExampleApplication/print_result.py @@ -1,15 +1,18 @@ -from tableau_migration.migration import PyMigrationResult -from tableau_migration import IMigrationManifestEntry, MigrationManifestEntryStatus -from Tableau.Migration.Engine.Pipelines import ServerToCloudMigrationPipeline +from tableau_migration import ( + IMigrationManifestEntry, + MigrationManifestEntryStatus, + MigrationResult, + ServerToCloudMigrationPipeline +) -def print_result(result: PyMigrationResult): +def print_result(result: MigrationResult): """Prints the result of a migration.""" print(f'Result: {result.status}') - for pipeline_content_type in ServerToCloudMigrationPipeline.ContentTypes: - content_type = pipeline_content_type.ContentType + for pipeline_content_type in ServerToCloudMigrationPipeline.content_types(): + content_type = pipeline_content_type.content_type - type_entries = [IMigrationManifestEntry(x) for x in result.manifest.entries.ForContentType(content_type)] + type_entries = [IMigrationManifestEntry(x) for x in result.manifest.entries.for_content_type(content_type)] count_total = len(type_entries) diff --git a/global.json b/global.json index 34b2b9f..7227be0 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.302", + "version": "8.0.403", "rollForward": "latestMajor" } } \ No newline at end of file diff --git a/src/Documentation/articles/hooks/custom_hooks.md b/src/Documentation/articles/hooks/custom_hooks.md index b400fa2..81e4492 100644 --- a/src/Documentation/articles/hooks/custom_hooks.md +++ b/src/Documentation/articles/hooks/custom_hooks.md @@ -11,15 +11,16 @@ Here are some important things to know when writing custom hooks: ## [Python](#tab/Python) The base classes can be used as they are linked in the API reference. However, for ease of use, all base classes have been imported into the `tableau_migration` namespace without the `Py` prefix. -For example: [`PyContentFilterBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_filters_interop.PyContentFilterBase.md) has been imported as `tableau_migration.PyContentFilterBase`. +For example: [`PyContentFilterBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_filters_interop.PyContentFilterBase.md) has been imported as `tableau_migration.ContentFilterBase`. #### Pre-Migration -| Type | Base Class | Code Samples | -|---------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------| -| [Filters](~/api-python/reference/tableau_migration.migration_engine_hooks_filters_interop.md) | [`ContentFilterBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_filters_interop.PyContentFilterBase.md) | [Code Samples/Filters](~/samples/filters/index.md) | -| [Mappings](~/api-python/reference/tableau_migration.migration_engine_hooks_mappings_interop.md) | [`ContentMappingBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_mappings_interop.PyContentMappingBase.md) | [Code Samples/Mappings](~/samples/mappings/index.md) | -| [Transformers](~/api-python/reference/tableau_migration.migration_engine_hooks_transformers_interop.md) | [`ContentTransformerBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_transformers_interop.PyContentTransformerBase.md) | [Code Samples/Transformers](~/samples/transformers/index.md) | +| Type | Base Class | Code Samples | +|---------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| +| [Initialize Migration](~/api-python/reference/tableau_migration.migration_engine_hooks_interop.md) | [`InitializeMigrationHookBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_interop.PyInitializeMigrationHookBase.md) | [Code Samples/Initialize Migration](~/samples/initialize-migration/index.md) | +| [Filters](~/api-python/reference/tableau_migration.migration_engine_hooks_filters_interop.md) | [`ContentFilterBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_filters_interop.PyContentFilterBase.md) | [Code Samples/Filters](~/samples/filters/index.md) | +| [Mappings](~/api-python/reference/tableau_migration.migration_engine_hooks_mappings_interop.md) | [`ContentMappingBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_mappings_interop.PyContentMappingBase.md) | [Code Samples/Mappings](~/samples/mappings/index.md) | +| [Transformers](~/api-python/reference/tableau_migration.migration_engine_hooks_transformers_interop.md) | [`ContentTransformerBase`](~/api-python/reference/tableau_migration.migration_engine_hooks_transformers_interop.PyContentTransformerBase.md) | [Code Samples/Transformers](~/samples/transformers/index.md) | #### Post-Migration @@ -38,11 +39,12 @@ To register Python hooks, register the object with the appropriate hook type lis #### Pre-Migration -| Type | Base Class | Interface | Code Samples | -|---------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|--------------------------------------------------------------| -| [Filters](xref:Tableau.Migration.Engine.Hooks.Filters) | [`ContentFilterBase`](xref:Tableau.Migration.Engine.Hooks.Filters.ContentFilterBase`1) | [`IContentFilter`](xref:Tableau.Migration.Engine.Hooks.Filters.IContentFilter`1) | [Code Samples/Filters](~/samples/filters/index.md) | -| [Mappings](xref:Tableau.Migration.Engine.Hooks.Mappings) | [`ContentMappingBase`](xref:Tableau.Migration.Engine.Hooks.Mappings.ContentMappingBase`1) | [`IContentMapping`](xref:Tableau.Migration.Engine.Hooks.Mappings.IContentMapping`1) | [Code Samples/Mappings](~/samples/mappings/index.md) | -| [Transformers](xref:Tableau.Migration.Engine.Hooks.Transformers) | [`ContentTransformerBase`](xref:Tableau.Migration.Engine.Hooks.Transformers.ContentTransformerBase`1) | [`IContentTransformer`](xref:Tableau.Migration.Engine.Hooks.Transformers.IContentTransformer`1) | [Code Samples/Transformers](~/samples/transformers/index.md) | +| Type | Base Class | Interface | Code Samples | +|---------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------| +| [Initialize Migration](xref:Tableau.Migration.Engine.Hooks) | None | [`IInitializeMigrationHook`](xref:Tableau.Migration.Engine.Hooks.IInitializeMigrationHook) | [Code Samples/Initialize Migration](~/samples/initialize-migration/index.md) | +| [Filters](xref:Tableau.Migration.Engine.Hooks.Filters) | [`ContentFilterBase`](xref:Tableau.Migration.Engine.Hooks.Filters.ContentFilterBase`1) | [`IContentFilter`](xref:Tableau.Migration.Engine.Hooks.Filters.IContentFilter`1) | [Code Samples/Filters](~/samples/filters/index.md) | +| [Mappings](xref:Tableau.Migration.Engine.Hooks.Mappings) | [`ContentMappingBase`](xref:Tableau.Migration.Engine.Hooks.Mappings.ContentMappingBase`1) | [`IContentMapping`](xref:Tableau.Migration.Engine.Hooks.Mappings.IContentMapping`1) | [Code Samples/Mappings](~/samples/mappings/index.md) | +| [Transformers](xref:Tableau.Migration.Engine.Hooks.Transformers) | [`ContentTransformerBase`](xref:Tableau.Migration.Engine.Hooks.Transformers.ContentTransformerBase`1) | [`IContentTransformer`](xref:Tableau.Migration.Engine.Hooks.Transformers.IContentTransformer`1) | [Code Samples/Transformers](~/samples/transformers/index.md) | #### Post-Migration diff --git a/src/Documentation/articles/hooks/index.md b/src/Documentation/articles/hooks/index.md index ac99924..d3d6eee 100644 --- a/src/Documentation/articles/hooks/index.md +++ b/src/Documentation/articles/hooks/index.md @@ -35,13 +35,15 @@ These types of hooks run on content items. These types of hooks run before or after certain migration events. +- Migration Initialized: Executed after preflight validation is completed successfully, but before any migration actions are started. + - Post-Publish: Run on the destination content after the items for the content type have been published. -- Bulk Post-Publish: Execute after publishing a batch of content, when bulk publishing is supported. You can make changes to the published set of items with this type of hook. You can write this type of hook for content types such as Users. +- Bulk Post-Publish: Executed after publishing a batch of content, when bulk publishing is supported. You can make changes to the published set of items with this type of hook. You can write this type of hook for content types such as Users. -- Migration Action Completed: Execute after the migration of each content type. +- Migration Action Completed: Executed after the migration of each content type. -- Batch Migration Completed: Execute after the completion of the migration of a batch of Tableau’s content. +- Batch Migration Completed: Executed after the completion of the migration of a batch of Tableau’s content. ## Hook execution flow diff --git a/src/Documentation/samples/initialize-migration/index.md b/src/Documentation/samples/initialize-migration/index.md new file mode 100644 index 0000000..170649e --- /dev/null +++ b/src/Documentation/samples/initialize-migration/index.md @@ -0,0 +1,7 @@ +# Initialize Migration + +Initialize Migration Hooks allow custom logic to run after a migration startup has been validated but before any migration work is performed. + +The following samples cover some common scenarios: + +- [Sample: Set Custom Migration Scoped Context](~/samples/initialize-migration/set_custom_context.md) diff --git a/src/Documentation/samples/initialize-migration/set_custom_context.md b/src/Documentation/samples/initialize-migration/set_custom_context.md new file mode 100644 index 0000000..69cb66f --- /dev/null +++ b/src/Documentation/samples/initialize-migration/set_custom_context.md @@ -0,0 +1,57 @@ +# Sample: Set Custom Migration Scoped Context + +This example demonstrates how to set custom context in the migration scoped dependency injection container using an initialize migration hook. +This is useful when other hooks like filters are registered with dependency injection and rely on migration scoped services. + +# [Python](#tab/Python) + +#### Custom Context Service Class + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Hooks/InitializeMigration/CustomContext.cs#class)] + +#### Custom Context Service Class Dependency Injection + +[Learn more.](~/articles/dependency_injection.md) + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Program.cs#SetCustomContext-Service-DI)] + +#### Initialize Migration Hook Class + +[!code-python[](../../../../examples/Python.ExampleApplication/hooks/initialize_migration/set_custom_context_hook.py)] + +#### Registration + +[Learn more.](~/samples/index.md?tabs=Python#hook-registration) + +[//]: <> (Adding this as code as regions are not supported in Python snippets) +```Python +plan_builder.hooks.add(SetMigrationContextHook) +``` + +# [C#](#tab/CSharp) + +#### Custom Context Service Class + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Hooks/InitializeMigration/CustomContext.cs#class)] + +#### Custom Context Service Class Dependency Injection + +[Learn more.](~/articles/dependency_injection.md) + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Program.cs#SetCustomContext-Service-DI)] + +#### Initialize Migration Hook Class + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Hooks/InitializeMigration/SetMigrationContextHook.cs#class)] + +#### Registration + +[Learn more.](~/samples/index.md?tabs=CSharp#hook-registration) + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/MyMigrationApplication.cs#SetCustomContext-Registration)] + +#### Dependency Injection + +[Learn more.](~/articles/dependency_injection.md) + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Program.cs#SetCustomContext-Hook-DI)] \ No newline at end of file diff --git a/src/Documentation/samples/initialize-migration/toc.yml b/src/Documentation/samples/initialize-migration/toc.yml new file mode 100644 index 0000000..3078263 --- /dev/null +++ b/src/Documentation/samples/initialize-migration/toc.yml @@ -0,0 +1,2 @@ +- name: Set Custom Migration Scoped Context + href: set_custom_context.md \ No newline at end of file diff --git a/src/Python/Python.pyproj b/src/Python/Python.pyproj index a073df7..2f2347e 100644 --- a/src/Python/Python.pyproj +++ b/src/Python/Python.pyproj @@ -44,12 +44,14 @@ + + @@ -67,6 +69,7 @@ + diff --git a/src/Python/pyproject.toml b/src/Python/pyproject.toml index 059191e..91873a3 100644 --- a/src/Python/pyproject.toml +++ b/src/Python/pyproject.toml @@ -12,7 +12,7 @@ authors = [ description = "Tableau Migration SDK" readme = "README.md" # https://devguide.python.org/versions/ -requires-python = ">=3.8" +requires-python = ">=3.9" classifiers = [ "Programming Language :: Python :: 3", "Operating System :: OS Independent", @@ -64,7 +64,7 @@ test = "pytest -vv" testcov = "test --cov-config=pyproject.toml --cov=tableau_migration" [[tool.hatch.envs.test.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12"] +python = ["3.9", "3.10", "3.11", "3.12"] [project.urls] "Homepage" = "http://www.tableau.com" diff --git a/src/Python/scripts/build-package.ps1 b/src/Python/scripts/build-package.ps1 index 60b0bc0..3853d37 100644 --- a/src/Python/scripts/build-package.ps1 +++ b/src/Python/scripts/build-package.ps1 @@ -38,7 +38,7 @@ try { Remove-Item -Recurse -ErrorAction SilentlyContinue dist/* - dotnet publish /p:DebugType=None /p:DebugSymbols=false $projectToBuild -c $Configuration -o .\src\tableau_migration\bin -f net6.0 + dotnet publish /p:DebugType=None /p:DebugSymbols=false $projectToBuild -c $Configuration -o .\src\tableau_migration\bin -f net8.0 } finally { diff --git a/src/Python/scripts/build_binaries.py b/src/Python/scripts/build_binaries.py index 9dd0003..d3e8745 100644 --- a/src/Python/scripts/build_binaries.py +++ b/src/Python/scripts/build_binaries.py @@ -14,4 +14,4 @@ sys.path.append(bin_path) shutil.rmtree(bin_path, True) -subprocess.run(["dotnet", "publish", migration_project, "-o", bin_path, "-f", "net6.0"]) \ No newline at end of file +subprocess.run(["dotnet", "publish", migration_project, "-o", bin_path, "-f", "net8.0"]) \ 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 a7eba83..583ce75 100644 --- a/src/Python/src/tableau_migration/__init__.py +++ b/src/Python/src/tableau_migration/__init__.py @@ -50,10 +50,12 @@ cancellation_token = cancellation_token_source.Token # Friendly renames for common top-level imports +from tableau_migration.migration import PyMigrationResult as MigrationResult # noqa: E402, F401 from tableau_migration.migration_engine import PyMigrationPlanBuilder as MigrationPlanBuilder # noqa: E402, F401 from tableau_migration.migration_engine_hooks_filters_interop import PyContentFilterBase as ContentFilterBase # noqa: E402, F401 from tableau_migration.migration_engine_hooks_interop import ( # noqa: E402, F401 PyContentBatchMigrationCompletedHookBase as ContentBatchMigrationCompletedHookBase, + PyInitializeMigrationHookBase as InitializeMigrationHookBase, PyMigrationActionCompletedHookBase as MigrationActionCompletedHookBase ) from tableau_migration.migration_engine_hooks_mappings_interop import ( # noqa: E402, F401 @@ -64,6 +66,7 @@ PyBulkPostPublishHookBase as BulkPostPublishHookBase, PyContentItemPostPublishHookBase as ContentItemPostPublishHookBase ) +from tableau_migration.migration_engine_hooks_results import PyInitializeMigrationHookResult as IInitializeMigrationHookResult # noqa: E402, F401 from tableau_migration.migration_engine_hooks_transformers_interop import ( # noqa: E402, F401 PyContentTransformerBase as ContentTransformerBase, PyXmlContentTransformerBase as XmlContentTransformerBase @@ -145,6 +148,8 @@ from tableau_migration.migration_engine_manifest import PyMigrationManifestEntryStatus as MigrationManifestEntryStatus # noqa: E402, F401 from tableau_migration.migration_engine_migrators import PyContentItemMigrationResult as IContentItemMigrationResult # noqa: E402, F401 from tableau_migration.migration_engine_migrators_batch import PyContentBatchMigrationResult as IContentBatchMigrationResult # noqa: E402, F401 +from tableau_migration.migration_engine_pipelines import PyMigrationPipelineContentType as MigrationPipelineContentType # noqa: E402, F401 +from tableau_migration.migration_engine_pipelines import PyServerToCloudMigrationPipeline as ServerToCloudMigrationPipeline # noqa: E402, F401 # endregion diff --git a/src/Python/src/tableau_migration/migration_engine_hooks.py b/src/Python/src/tableau_migration/migration_engine_hooks.py index 0cc8128..190d1f2 100644 --- a/src/Python/src/tableau_migration/migration_engine_hooks.py +++ b/src/Python/src/tableau_migration/migration_engine_hooks.py @@ -51,15 +51,21 @@ def get_hooks(self, type_to_get: Type[T]): def _get_wrapper_from_callback_context(t: type) -> type: from migration_engine_actions import PyMigrationActionResult - from migration_engine_hooks_interop import _PyMigrationActionCompletedHookWrapper, _PyContentBatchMigrationCompletedHookWrapper + from migration_engine_hooks_interop import ( + _PyContentBatchMigrationCompletedHookWrapper, + _PyInitializeMigrationHookWrapper, + _PyMigrationActionCompletedHookWrapper + ) from migration_engine_hooks_postpublish import PyBulkPostPublishContext, PyContentItemPostPublishContext from migration_engine_hooks_postpublish_interop import _PyBulkPostPublishHookWrapper, _PyContentItemPostPublishHookWrapper + from migration_engine_hooks_results import PyInitializeMigrationHookResult from migration_engine_migrators_batch import PyContentBatchMigrationResult types = { PyBulkPostPublishContext.__name__: _PyBulkPostPublishHookWrapper, PyContentBatchMigrationResult.__name__: _PyContentBatchMigrationCompletedHookWrapper, PyContentItemPostPublishContext.__name__: _PyContentItemPostPublishHookWrapper, + PyInitializeMigrationHookResult.__name__: _PyInitializeMigrationHookWrapper, PyMigrationActionResult.__name__: _PyMigrationActionCompletedHookWrapper } diff --git a/src/Python/src/tableau_migration/migration_engine_hooks_interop.py b/src/Python/src/tableau_migration/migration_engine_hooks_interop.py index 6f1c835..72d3dee 100644 --- a/src/Python/src/tableau_migration/migration_engine_hooks_interop.py +++ b/src/Python/src/tableau_migration/migration_engine_hooks_interop.py @@ -21,13 +21,15 @@ from uuid import uuid4 from migration_engine_actions import PyMigrationActionResult +from migration_engine_hooks_results import PyInitializeMigrationHookResult from migration_engine_migrators_batch import PyContentBatchMigrationResult from System import IServiceProvider from System.Threading.Tasks import Task from Tableau.Migration.Engine.Actions import IMigrationActionResult from Tableau.Migration.Engine.Migrators.Batch import IContentBatchMigrationResult -from Tableau.Migration.Interop.Hooks import ISyncContentBatchMigrationCompletedHook, ISyncMigrationActionCompletedHook +from Tableau.Migration.Engine.Hooks import IInitializeMigrationHookResult +from Tableau.Migration.Interop.Hooks import ISyncContentBatchMigrationCompletedHook, ISyncInitializeMigrationHook, ISyncMigrationActionCompletedHook TContent = TypeVar("TContent") @@ -248,6 +250,50 @@ class PyContentBatchMigrationCompletedHookBase(Generic[TContent]): def execute(self, ctx: PyContentBatchMigrationResult[TContent]) -> PyContentBatchMigrationResult[TContent]: """Executes a hook callback. + Args: + ctx: The input context from the migration engine or previous hook. + + Returns: + The context, potentially modified to pass on to the next hook or migration engine, or None to continue passing the input context. + """ + return ctx + +class _PyInitializeMigrationHookWrapper(_PyHookWrapperBase): + + @property + def _wrapper_method_name(self) -> str: + return "Execute" + + @property + def _wrapper_async(self) -> bool: + return False + + def _wrapper_base_type(self) -> type: + return ISyncInitializeMigrationHook + + def _wrapper_context_type(self) -> type: + return IInitializeMigrationHookResult + + def _wrap_execute_method(self) -> Callable: + def _wrap_execute(w): + return w._hook.execute + + return _wrap_execute + + def _wrap_context_callback(self) -> Callable: + def _wrap_context(ctx): + return PyInitializeMigrationHookResult(ctx) + + return _wrap_context + +class PyInitializeMigrationHookBase: + """Base class for initialize migration hooks.""" + + _wrapper = _PyInitializeMigrationHookWrapper + + def execute(self, ctx: PyInitializeMigrationHookResult) -> PyInitializeMigrationHookResult: + """Executes a hook callback. + Args: ctx: The input context from the migration engine or previous hook. diff --git a/src/Python/src/tableau_migration/migration_engine_hooks_results.py b/src/Python/src/tableau_migration/migration_engine_hooks_results.py new file mode 100644 index 0000000..32ddf69 --- /dev/null +++ b/src/Python/src/tableau_migration/migration_engine_hooks_results.py @@ -0,0 +1,58 @@ +# 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. + +"""Wrapper for result classes in Tableau.Migration.Engine.Hooks namespace.""" + +# Not in migration_engine_hooks.py to avoid circular references. + +from typing import Sequence +from typing_extensions import Self + +from tableau_migration.migration import PyResult +from migration_services import ScopedMigrationServices + +from System import Exception as DotNetException +from Tableau.Migration.Engine.Hooks import IInitializeMigrationHookResult + +class PyInitializeMigrationHookResult(PyResult): + """IInitializeMigrationHook.""" + + _dotnet_base = IInitializeMigrationHookResult + + def __init__(self, initialize_migration_hook_result: IInitializeMigrationHookResult) -> None: + """Creates a new PyInitializeMigrationHookResult object. + + Args: + initialize_migration_hook_result: A IInitializeMigrationHookResult object. + + Returns: None. + """ + self._dotnet = initialize_migration_hook_result + + @property + def scoped_services(self) -> ScopedMigrationServices: + """Gets the migration-scoped service provider.""" + return ScopedMigrationServices(self._dotnet.ScopedServices) + + def to_failure(self, errors: Sequence[DotNetException]=None) -> Self: + """Creates a new IInitializeMigrationHookResult object with the given errors. + + Args: + errors: The errors that caused the failure. + + Returns: The new IInitializeMigrationHookResult object. + """ + result = self._dotnet.ToFailure(errors) + return None if result is None else PyInitializeMigrationHookResult(result) \ No newline at end of file diff --git a/src/Python/src/tableau_migration/migration_engine_manifest.py b/src/Python/src/tableau_migration/migration_engine_manifest.py index ffc0292..e1045e0 100644 --- a/src/Python/src/tableau_migration/migration_engine_manifest.py +++ b/src/Python/src/tableau_migration/migration_engine_manifest.py @@ -66,6 +66,11 @@ def load(self, path: str) -> PyMigrationManifest: result = self._dotnet.LoadAsync(path, cancellation_token).GetAwaiter().GetResult() return None if result is None else PyMigrationManifest(result) + @classmethod + def get_supported_manifest_version(cls) -> int: + """This is the current MigrationManifest.ManifestVersion that this serializer supports.""" + return MigrationManifestSerializer.SupportedManifestVersion + # region _generated from enum import IntEnum # noqa: E402, F401 @@ -161,6 +166,14 @@ def __init__(self, migration_manifest_entry_editor: IMigrationManifestEntryEdito """ self._dotnet = migration_manifest_entry_editor + def reset_status(self) -> Self: + """Resets the status to Pending. + + Returns: The current entry editor, for fluent API usage. + """ + result = self._dotnet.ResetStatus() + return None if result is None else PyMigrationManifestEntryEditor(result) + def map_to_destination(self, destination_location: PyContentLocation) -> Self: """Sets the intended mapped destination location to the manifest entry. Clears the Destination information if the mapped location is different. diff --git a/src/Python/src/tableau_migration/migration_engine_pipelines.py b/src/Python/src/tableau_migration/migration_engine_pipelines.py new file mode 100644 index 0000000..af49b8b --- /dev/null +++ b/src/Python/src/tableau_migration/migration_engine_pipelines.py @@ -0,0 +1,147 @@ +# 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. + +"""Wrapper for classes in Tableau.Migration.Engine.Pipelines namespace.""" + +# region _generated + +from typing import Sequence # noqa: E402, F401 +from typing_extensions import Self # noqa: E402, F401 + +import System # noqa: E402 + +from Tableau.Migration.Engine.Pipelines import ( # noqa: E402, F401 + MigrationPipelineContentType, + ServerToCloudMigrationPipeline +) + +class PyMigrationPipelineContentType(): + """Object that represents a definition of a content type that a pipeline migrates.""" + + _dotnet_base = MigrationPipelineContentType + + def __init__(self, migration_pipeline_content_type: MigrationPipelineContentType) -> None: + """Creates a new PyMigrationPipelineContentType object. + + Args: + migration_pipeline_content_type: A MigrationPipelineContentType object. + + Returns: None. + """ + self._dotnet = migration_pipeline_content_type + + @classmethod + def get_users(cls) -> Self: + """Gets the user MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.Users is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Users) + + @classmethod + def get_groups(cls) -> Self: + """Gets the groups MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.Groups is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Groups) + + @classmethod + def get_projects(cls) -> Self: + """Gets the projects MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.Projects is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Projects) + + @classmethod + def get_data_sources(cls) -> Self: + """Gets the data sources MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.DataSources is None else PyMigrationPipelineContentType(MigrationPipelineContentType.DataSources) + + @classmethod + def get_workbooks(cls) -> Self: + """Gets the workbooks MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.Workbooks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Workbooks) + + @classmethod + 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_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_custom_views(cls) -> Self: + """Gets the custom views MigrationPipelineContentType.""" + return None if MigrationPipelineContentType.CustomViews is None else PyMigrationPipelineContentType(MigrationPipelineContentType.CustomViews) + + @property + def content_type(self) -> System.Type: + """The content type.""" + return self._dotnet.ContentType + + @property + def publish_type(self) -> System.Type: + """Gets the publish type.""" + return self._dotnet.PublishType + + @property + def result_type(self) -> System.Type: + """Gets the result type.""" + return self._dotnet.ResultType + + @property + def types(self) -> Sequence[System.Type]: + """Gets the types for this instance.""" + return None if self._dotnet.Types is None else list(self._dotnet.Types) + + def get_config_key(self) -> str: + """Gets the config key for this content type. + + Returns: The config key string. + """ + result = self._dotnet.GetConfigKey() + return result + + @classmethod + def get_config_key_for_type(cls, content_type: System.Type) -> str: + """Gets the config key for a content type. + + Args: + content_type: The content type. + + Returns: The config key string. + """ + result = MigrationPipelineContentType.GetConfigKeyForType(content_type) + return result + +class PyServerToCloudMigrationPipeline(): + """IMigrationPipeline implementation to perform migrations from Tableau Server to Tableau Cloud.""" + + _dotnet_base = ServerToCloudMigrationPipeline + + def __init__(self, server_to_cloud_migration_pipeline: ServerToCloudMigrationPipeline) -> None: + """Creates a new PyServerToCloudMigrationPipeline object. + + Args: + server_to_cloud_migration_pipeline: A ServerToCloudMigrationPipeline object. + + Returns: None. + """ + self._dotnet = server_to_cloud_migration_pipeline + + @classmethod + def get_content_types(cls) -> Sequence[PyMigrationPipelineContentType]: + """Content types that are supported for migrations.""" + return None if ServerToCloudMigrationPipeline.ContentTypes is None else list((None if x is None else PyMigrationPipelineContentType(x)) for x in ServerToCloudMigrationPipeline.ContentTypes) + + +# endregion + diff --git a/src/Python/tests/test_classes.py b/src/Python/tests/test_classes.py index 90151a8..659918c 100644 --- a/src/Python/tests/test_classes.py +++ b/src/Python/tests/test_classes.py @@ -79,7 +79,7 @@ _module_path = abspath(Path(__file__).parent.resolve().__str__() + "/../src/tableau_migration") sys.path.append(_module_path) -def get_class_methods(cls): +def get_class_methods(cls) -> List[str]: """Gets all the methods in a class. https://stackoverflow.com/a/4241225. @@ -90,10 +90,10 @@ def get_class_methods(cls): methods.extend([item[0] for item in inspect.getmembers(cls, predicate=inspect.isfunction) if item[0] not in _base_object]) #Remove python internal methods - return (m for m in methods if not m.startswith("__")) + return [m for m in methods if not m.startswith("__")] -def get_class_properties(cls): +def get_class_properties(cls) -> List[str]: """Gets all the properties in a class. https://stackoverflow.com/a/34643176. @@ -109,14 +109,7 @@ def get_enum(enumType: Enum): def normalize_name(name: str) -> str: return name.replace("_", "").lower() -def remove_suffix(input: str, suffix_to_remove: str) -> str: - """str.removesuffix() was introduced in python 3.9""" - if(input.endswith(suffix_to_remove)): - return input[:-len(suffix_to_remove)] - else: - return input - -def compare_names(dotnet_names: List[str], python_names: List[str]) -> str: +def compare_names(dotnet_names: List[str], python_names: List[str], ) -> str: """Compares dotnet names with python names dotnet names look like 'DoWalk' @@ -135,7 +128,7 @@ def compare_names(dotnet_names: List[str], python_names: List[str]) -> str: for item in dotnet_names: # Python does not support await/async so all method need to be syncronous # The wrapper method is expected to handle this, so the "Async" suffix should be dropped - normalized_name = normalize_name(remove_suffix(item, "Async")) + normalized_name = normalize_name(item.removesuffix("Async")) dotnet_normalized.append(normalized_name) dotnet_lookup[normalized_name] = item @@ -151,12 +144,12 @@ def compare_names(dotnet_names: List[str], python_names: List[str]) -> str: message = "" # Check what names are missing from python but are available in dotnet - python_lacks = [x for x in dotnet_set if x not in python_set] + python_lacks = [x for x in dotnet_set if x not in python_set and "get" + x not in python_set and "set" not in python_set] lacks_message_items = [f"({lacks_item}: (py:???->net:{dotnet_lookup[lacks_item]}))" for lacks_item in python_lacks] message += f"Python lacks elements {lacks_message_items}\n" if lacks_message_items else '' # Check what names are extra in python and missing from dotnet - python_extra = [x for x in python_set if x not in dotnet_set] + python_extra = [x for x in python_set if x not in dotnet_set and x.removeprefix("get") not in dotnet_set and x.removeprefix("set") not in dotnet_set] extra_message_items = [f"({extra_item}: (py:{python_lookup[extra_item]}->net:???))" for extra_item in python_extra] message += f"Python has extra elements {extra_message_items}\n" if extra_message_items else '' @@ -195,8 +188,8 @@ def verify_enum(python_enum, dotnet_enum): class TestNameComparison(): def test_valid(self): - dotnet_names = ["DoWalk", "RunAsync", "RunAsync"] - python_names = ["do_walk", "run", "run"] + dotnet_names = ["DoWalk", "RunAsync", "RunAsync", "Static"] + python_names = ["do_walk", "run", "run", "get_static", "set_static"] message = compare_names(dotnet_names, python_names) assert not message @@ -324,6 +317,11 @@ def test_overloaded_missing(self): from tableau_migration.migration_engine_migrators_batch import PyContentBatchMigrationResult # noqa: E402, F401 +from tableau_migration.migration_engine_pipelines import ( # noqa: E402, F401 + PyMigrationPipelineContentType, + PyServerToCloudMigrationPipeline +) + from Tableau.Migration import MigrationCompletionStatus from Tableau.Migration.Api.Rest.Models import AdministratorLevels @@ -391,7 +389,9 @@ def test_overloaded_missing(self): (PyMigrationManifestEntry, None), (PyMigrationManifestEntryEditor, [ "SetFailed" ]), (PyContentItemMigrationResult, [ "CastFailure" ]), - (PyContentBatchMigrationResult, [ "CastFailure" ]) + (PyContentBatchMigrationResult, [ "CastFailure" ]), + (PyMigrationPipelineContentType, [ "GetContentTypeForInterface", "GetPostPublishTypesForInterface", "GetPublishTypeForInterface", "WithPublishType", "WithResultType" ]), + (PyServerToCloudMigrationPipeline, [ "BuildActions", "BuildPipeline", "CreateDestinationCache", "CreateSourceCache", "GetBatchMigrator", "GetDestinationLockedProjectCache", "GetItemPreparer", "GetMigrator" ]) ] _generated_enum_data = [ @@ -441,34 +441,32 @@ def test_classes(python_class, ignored_members): """Verify that all the python wrapper classes actually wrap all the dotnet methods and properties.""" from Tableau.Migration.Interop import InteropHelper - # Verify that this class has a _dotnet_base + # Verify that this class has a _dotnet_base. assert python_class._dotnet_base dotnet_class = python_class._dotnet_base - # Get all the python methods and properties + # Get all the python methods and properties. _all_py_methods = get_class_methods(python_class) _all_py_props = get_class_properties(python_class) - # Get all the dotnet methods and properties - _all_dotnet_methods = InteropHelper.GetMethods(dotnet_class) - _all_dotnet_props = InteropHelper.GetProperties(dotnet_class) + # Get all the dotnet methods and field/properties. + _all_dotnet_methods = [m for m in InteropHelper.GetMethods(dotnet_class)] + _all_dotnet_props = [p for p in InteropHelper.GetProperties(dotnet_class)] + [f for f in InteropHelper.GetFields(dotnet_class)] - # Clean methods that should be ignored + # Clean methods that should be ignored. if ignored_members is None: - _clean_dotnet_method = _all_dotnet_methods + _clean_dotnet_methods = _all_dotnet_methods _clean_dotnet_props = _all_dotnet_props else: - _clean_dotnet_method = [method for method in _all_dotnet_methods if method not in ignored_members] + _clean_dotnet_methods = [method for method in _all_dotnet_methods if method not in ignored_members] _clean_dotnet_props = [prop for prop in _all_dotnet_props if prop not in ignored_members] - # Compare the lists - method_message = compare_names(_clean_dotnet_method, _all_py_methods) - prop_message = compare_names(_clean_dotnet_props, _all_py_props) + # Compare the lists. + member_message = compare_names(_clean_dotnet_methods + _clean_dotnet_props, _all_py_methods + _all_py_props) # Assert - assert not method_message # Remember that the names are normalize - assert not prop_message # Remember that the names are normalize + assert not member_message # Remember that the names are normalized. _test_enum_data = [] _test_enum_data.extend(_generated_enum_data) diff --git a/src/Python/tests/test_migration_engine_hooks.py b/src/Python/tests/test_migration_engine_hooks.py index 317b27a..66787ea 100644 --- a/src/Python/tests/test_migration_engine_hooks.py +++ b/src/Python/tests/test_migration_engine_hooks.py @@ -21,7 +21,12 @@ from tableau_migration.migration_content import PyUser from tableau_migration.migration_engine_actions import PyMigrationActionResult from tableau_migration.migration_engine_hooks import PyMigrationHookBuilder -from tableau_migration.migration_engine_hooks_interop import PyContentBatchMigrationCompletedHookBase, PyMigrationActionCompletedHookBase +from tableau_migration.migration_engine_hooks_interop import ( + PyContentBatchMigrationCompletedHookBase, + PyInitializeMigrationHookBase, + PyMigrationActionCompletedHookBase +) +from tableau_migration.migration_engine_hooks_results import PyInitializeMigrationHookResult from tableau_migration.migration_engine_migrators_batch import PyContentBatchMigrationResult from tableau_migration.migration_services import ScopedMigrationServices @@ -32,8 +37,9 @@ from Tableau.Migration.Content import IUser from Tableau.Migration.Engine.Actions import IMigrationActionResult from Tableau.Migration.Engine.Hooks import ( - IContentBatchMigrationCompletedHook, IMigrationActionCompletedHook, - IMigrationHook, MigrationHookBuilder + IContentBatchMigrationCompletedHook, + IInitializeMigrationHookResult, IInitializeMigrationHook, + IMigrationActionCompletedHook, IMigrationHook, MigrationHookBuilder ) from Tableau.Migration.Engine.Migrators.Batch import IContentBatchMigrationResult @@ -168,4 +174,69 @@ def test_interop_callback_services(self): hook = hook_factories[0].Create[IMigrationHook[IContentBatchMigrationResult[IUser]]](services) hook_result = hook.ExecuteAsync(ctx, CancellationToken(False)).GetAwaiter().GetResult() - assert hook_result.PerformNextBatch == False \ No newline at end of file + assert hook_result.PerformNextBatch == False + +class PyInitializeMigrationHook(PyInitializeMigrationHookBase): + + def execute(self, ctx: PyInitializeMigrationHookResult) -> PyInitializeMigrationHookResult: + return ctx.to_failure() + +def init_migration(ctx: PyInitializeMigrationHookResult) -> PyInitializeMigrationHookResult: + return ctx.to_failure() + +def init_migration_services(ctx: PyInitializeMigrationHookResult, services: ScopedMigrationServices) -> PyInitializeMigrationHookResult: + return ctx.to_failure() + +class TestInitializeMigrationHookInterop(AutoFixtureTestBase): + def test_interop_class(self): + hook_builder = PyMigrationHookBuilder(MigrationHookBuilder()) + + result = hook_builder.add(PyInitializeMigrationHook) + assert result is hook_builder + + hook_factories = hook_builder.build().get_hooks(IInitializeMigrationHook) + assert len(hook_factories) == 1 + + services = self.create(IServiceProvider) + ctx = self.create(IInitializeMigrationHookResult) + + hook = hook_factories[0].Create[IMigrationHook[IInitializeMigrationHookResult]](services) + hook_result = hook.ExecuteAsync(ctx, CancellationToken(False)).GetAwaiter().GetResult() + + assert hook_result.Success == False + + def test_interop_callback(self): + hook_builder = PyMigrationHookBuilder(MigrationHookBuilder()) + + ctx = self.create(IInitializeMigrationHookResult) + + result = hook_builder.add(PyInitializeMigrationHookResult, init_migration) + assert result is hook_builder + + hook_factories = hook_builder.build().get_hooks(IInitializeMigrationHook) + assert len(hook_factories) == 1 + + services = self.create(IServiceProvider) + + hook = hook_factories[0].Create[IMigrationHook[IInitializeMigrationHookResult]](services) + hook_result = hook.ExecuteAsync(ctx, CancellationToken(False)).GetAwaiter().GetResult() + + assert hook_result.Success == False + + def test_interop_callback_services(self): + hook_builder = PyMigrationHookBuilder(MigrationHookBuilder()) + + ctx = self.create(IInitializeMigrationHookResult) + + result = hook_builder.add(PyInitializeMigrationHookResult, init_migration_services) + assert result is hook_builder + + hook_factories = hook_builder.build().get_hooks(IInitializeMigrationHook) + assert len(hook_factories) == 1 + + services = self.create(IServiceProvider) + + hook = hook_factories[0].Create[IMigrationHook[IInitializeMigrationHookResult]](services) + hook_result = hook.ExecuteAsync(ctx, CancellationToken(False)).GetAwaiter().GetResult() + + assert hook_result.Success == False \ No newline at end of file diff --git a/src/Python/tests/test_migration_engine_pipelines.py b/src/Python/tests/test_migration_engine_pipelines.py new file mode 100644 index 0000000..265e7a7 --- /dev/null +++ b/src/Python/tests/test_migration_engine_pipelines.py @@ -0,0 +1,105 @@ +# region _generated + +from typing import Sequence # noqa: E402, F401 +from typing_extensions import Self # noqa: E402, F401 + +import System # noqa: E402 + +from Tableau.Migration.Engine.Pipelines import ( # noqa: E402, F401 + MigrationPipelineContentType, + ServerToCloudMigrationPipeline +) + +from tableau_migration.migration_engine_pipelines import ( # noqa: E402, F401 + PyMigrationPipelineContentType, + PyServerToCloudMigrationPipeline +) + + +# Extra imports for tests. +from tests.helpers.autofixture import AutoFixtureTestBase # noqa: E402, F401 + +class TestPyMigrationPipelineContentTypeGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py._dotnet == dotnet + + def test_users_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_users() == None if MigrationPipelineContentType.Users is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Users) + + def test_groups_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_groups() == None if MigrationPipelineContentType.Groups is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Groups) + + def test_projects_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_projects() == None if MigrationPipelineContentType.Projects is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Projects) + + def test_data_sources_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_data_sources() == None if MigrationPipelineContentType.DataSources is None else PyMigrationPipelineContentType(MigrationPipelineContentType.DataSources) + + def test_workbooks_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_workbooks() == None if MigrationPipelineContentType.Workbooks is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Workbooks) + + def test_views_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.get_views() == None if MigrationPipelineContentType.Views is None else PyMigrationPipelineContentType(MigrationPipelineContentType.Views) + + 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_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_content_type_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.content_type == dotnet.ContentType + + def test_publish_type_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.publish_type == dotnet.PublishType + + def test_result_type_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert py.result_type == dotnet.ResultType + + def test_types_getter(self): + dotnet = self.create(MigrationPipelineContentType) + py = PyMigrationPipelineContentType(dotnet) + assert len(dotnet.Types) != 0 + assert len(py.types) == len(dotnet.Types) + +class TestPyServerToCloudMigrationPipelineGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(ServerToCloudMigrationPipeline) + py = PyServerToCloudMigrationPipeline(dotnet) + assert py._dotnet == dotnet + + def test_content_types_getter(self): + dotnet = self.create(ServerToCloudMigrationPipeline) + py = PyServerToCloudMigrationPipeline(dotnet) + assert len(ServerToCloudMigrationPipeline.ContentTypes) != 0 + assert len(py.get_content_types()) == len(ServerToCloudMigrationPipeline.ContentTypes) + + +# endregion + diff --git a/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs b/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs index b7c04df..706bae3 100644 --- a/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs +++ b/src/Tableau.Migration.PythonGenerator/Generators/PythonMemberGenerator.cs @@ -70,6 +70,8 @@ internal abstract class PythonMemberGenerator DotNetParseFunction: "TimeOnly.Parse", ExtraImports: ImmutableArray.Create( 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; @@ -182,6 +184,8 @@ protected PythonTypeReference ToPythonType(ITypeSymbol t) return STRING; case nameof(TimeOnly): return TIME_ONLY; + case nameof(Type): + return TYPE; default: if (t is IArrayTypeSymbol symbol) { diff --git a/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs b/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs index 4ba1133..49fda75 100644 --- a/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs +++ b/src/Tableau.Migration.PythonGenerator/Generators/PythonPropertyGenerator.cs @@ -15,7 +15,6 @@ // limitations under the License. // -using System; using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Options; @@ -41,25 +40,48 @@ public PythonPropertyGenerator(IPythonDocstringGenerator docGenerator, public ImmutableArray GenerateProperties(INamedTypeSymbol dotNetType) { + // Enums don't generate any properties, but "enum values" through IPythonEnumValueGenerator. + if(dotNetType.IsAnyEnum()) + { + return ImmutableArray.Empty; + } + var results = ImmutableArray.CreateBuilder(); + // Generate both C# fields and properties as Python properties. foreach (var dotNetMember in dotNetType.GetMembers()) { - if(dotNetMember.IsStatic || - !(dotNetMember is IPropertySymbol dotNetProperty) || - IgnoreMember(dotNetType, dotNetProperty) || - IGNORED_PROPERTIES.Contains(dotNetProperty.Name)) + if(IgnoreMember(dotNetType, dotNetMember) || IGNORED_PROPERTIES.Contains(dotNetMember.Name)) + { + continue; + } + + bool hasGetter, hasSetter, isStatic; + ITypeSymbol dotNetMemberType; + if (dotNetMember is IPropertySymbol dotNetProperty) + { + dotNetMemberType = dotNetProperty.Type; + hasGetter = !dotNetProperty.IsWriteOnly; + hasSetter = !(dotNetProperty.IsReadOnly || (dotNetProperty.SetMethod is not null && dotNetProperty.SetMethod.IsInitOnly)); + isStatic = dotNetProperty.IsStatic; + } + else if(dotNetMember is IFieldSymbol dotNetField) + { + dotNetMemberType = dotNetField.Type; + hasGetter = true; + hasSetter = !dotNetField.IsReadOnly; + isStatic = dotNetField.IsStatic; + } + else { continue; } - var type = ToPythonType(dotNetProperty.Type); - var docs = _docGenerator.Generate(dotNetProperty); + var type = ToPythonType(dotNetMemberType); + var docs = _docGenerator.Generate(dotNetMember); - var pyProperty = new PythonProperty(dotNetProperty.Name.ToSnakeCase(), type, - !dotNetProperty.IsWriteOnly, - !(dotNetProperty.IsReadOnly || (dotNetProperty.SetMethod is not null && dotNetProperty.SetMethod.IsInitOnly)), - docs, dotNetProperty); + var pyProperty = new PythonProperty(dotNetMember.Name.ToSnakeCase(), type, + hasGetter, hasSetter, isStatic, docs, dotNetMember, dotNetMemberType); results.Add(pyProperty); } diff --git a/src/Tableau.Migration.PythonGenerator/Keywords/Dotnet/Namespaces.cs b/src/Tableau.Migration.PythonGenerator/Keywords/Dotnet/Namespaces.cs index 1527331..c14fe1b 100644 --- a/src/Tableau.Migration.PythonGenerator/Keywords/Dotnet/Namespaces.cs +++ b/src/Tableau.Migration.PythonGenerator/Keywords/Dotnet/Namespaces.cs @@ -17,11 +17,16 @@ namespace Tableau.Migration.PythonGenerator.Keywords.Dotnet { - internal class Namespaces + internal static class Namespaces { public const string SYSTEM = "System"; - public const string TABLEAU_MIGRATION = "Tableau.Migration"; + public const string SYSTEM_COLLECTIONS_GENERIC = "System.Collections.Generic"; + public const string SYSTEM_EXCEPTION = "System.Exception"; + + public const string SYSTEM_TYPE = "System.Type"; + + public const string TABLEAU_MIGRATION = "Tableau.Migration"; } } diff --git a/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs b/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs index 46043b5..c115d3e 100644 --- a/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs +++ b/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs @@ -33,6 +33,7 @@ using Tableau.Migration.Engine.Manifest; using Tableau.Migration.Engine.Migrators; using Tableau.Migration.Engine.Migrators.Batch; +using Tableau.Migration.Engine.Pipelines; namespace Tableau.Migration.PythonGenerator { @@ -95,12 +96,6 @@ internal static class PythonGenerationList #endregion - #region - Tableau.Migration.Engine.Hooks.PostPublish - - - typeof(ContentItemPostPublishContext<,>), - - #endregion - #region - Tableau.Migration.Engine.Hooks.Mappings - typeof(ContentMappingContext<>), @@ -108,7 +103,9 @@ internal static class PythonGenerationList #endregion #region - Tableau.Migration.Engine.Hooks.PostPublish - + typeof(BulkPostPublishContext<>), + typeof(ContentItemPostPublishContext<,>), #endregion @@ -129,6 +126,13 @@ internal static class PythonGenerationList #endregion + #region - Tableau.Migrations.Engine.Pipelines - + + typeof(MigrationPipelineContentType), + typeof(ServerToCloudMigrationPipeline), + + #endregion + #region - Tableau.Migration.Api.Rest.Models - typeof(AdministratorLevels), diff --git a/src/Tableau.Migration.PythonGenerator/PythonGeneratorService.cs b/src/Tableau.Migration.PythonGenerator/PythonGeneratorService.cs index 5615ea9..443a1ae 100644 --- a/src/Tableau.Migration.PythonGenerator/PythonGeneratorService.cs +++ b/src/Tableau.Migration.PythonGenerator/PythonGeneratorService.cs @@ -59,7 +59,10 @@ public async Task StartAsync(CancellationToken cancel) documentation: XmlDocumentationProvider.CreateFromFile(Path.Combine(_options.ImportPath, "Tableau.Migration.xml")))); var module = compilation.SourceModule.ReferencedAssemblySymbols.Single().Modules.Single(); - var rootNamespace = module.GlobalNamespace.GetNamespaceMembers().Single().GetNamespaceMembers().Single(); + var rootNamespace = module.GlobalNamespace + .GetNamespaceMembers() + .Where(ns => ns.Name.StartsWith("Tableau", StringComparison.Ordinal)) + .Single().GetNamespaceMembers().Single(); var dotNetTypes = PythonGenerationList.FindTypesToGenerate(rootNamespace); diff --git a/src/Tableau.Migration.PythonGenerator/PythonProperty.cs b/src/Tableau.Migration.PythonGenerator/PythonProperty.cs index 0d076fb..9213076 100644 --- a/src/Tableau.Migration.PythonGenerator/PythonProperty.cs +++ b/src/Tableau.Migration.PythonGenerator/PythonProperty.cs @@ -19,7 +19,7 @@ namespace Tableau.Migration.PythonGenerator { - internal sealed record PythonProperty(string Name, PythonTypeReference Type, bool Getter, bool Setter, - PythonDocstring? Documentation, IPropertySymbol DotNetProperty) + internal sealed record PythonProperty(string Name, PythonTypeReference Type, bool Getter, bool Setter, + bool IsStatic, PythonDocstring? Documentation, ISymbol DotNetProperty, ITypeSymbol DotNetPropertyType) { } } diff --git a/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj b/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj index d7668df..08d04a3 100644 --- a/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj +++ b/src/Tableau.Migration.PythonGenerator/Tableau.Migration.PythonGenerator.csproj @@ -10,9 +10,9 @@ - - - + + + diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs index 3de1fa2..1d1385b 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonConstructorTestWriter.cs @@ -15,9 +15,6 @@ // limitations under the License. // -using Microsoft.CodeAnalysis; -using System.Linq; - namespace Tableau.Migration.PythonGenerator.Writers { internal sealed class PythonConstructorTestWriter : PythonMemberWriter, IPythonConstructorTestWriter @@ -37,21 +34,10 @@ public void Write(IndentingStringBuilder builder, PythonType type) private static void BuildCtorTestBody(PythonType type, IndentingStringBuilder ctorBuilder) { - var dotnetObj = "dotnet"; - var pyObj = "py"; - - if (!type.DotNetType.IsGenericType) - { - ctorBuilder.AppendLine($"dotnet = self.create({type.DotNetType.Name})"); - ctorBuilder.AppendLine($"{pyObj} = {type.Name}({dotnetObj})"); - } - else - { - ctorBuilder.AppendLine($"dotnet = self.create({type.DotNetType.OriginalDefinition.Name}[{BuildDotnetGenericTypeConstraintsString(type.DotNetType)}])"); - ctorBuilder.AppendLine($"{pyObj} = {type.Name}[{BuildPythongGenericTypeConstraintsString(type.DotNetType)}]({dotnetObj})"); - } + ctorBuilder.AppendLine($"dotnet = self.create({DotNetTypeName(type.DotNetType)})"); + ctorBuilder.AppendLine($"py = {PythonTypeName(type)}(dotnet)"); - ctorBuilder.AppendLine($"assert {pyObj}._dotnet == {dotnetObj}"); + 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 386f39b..315d291 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonMemberWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonMemberWriter.cs @@ -135,6 +135,23 @@ protected static string ToDotNetType(PythonTypeReference typeRef, string express } } + protected static string DotNetTypeName(PythonType type) => DotNetTypeName(type.DotNetType); + + protected static string DotNetTypeName(ITypeSymbol dotNetType) + { + return dotNetType switch + { + INamedTypeSymbol namedSymbol => DotNetTypeName(namedSymbol), + _ => throw new ArgumentException($"{dotNetType.GetType()} is not supported.") + }; + } + + protected static string DotNetTypeName(INamedTypeSymbol dotNetType) + => dotNetType.IsGenericType ? $"{dotNetType.OriginalDefinition.Name}[{BuildDotnetGenericTypeConstraintsString(dotNetType)}]" : dotNetType.Name; + + protected static string PythonTypeName(PythonType type) + => type.DotNetType.IsGenericType ? $"{type.Name}[{BuildPythongGenericTypeConstraintsString(type.DotNetType)}]" : type.Name; + protected static string BuildDotnetGenericTypeConstraintsString(INamedTypeSymbol dotnetType) { var typeConstraints = dotnetType.TypeParameters.First().ConstraintTypes; diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyTestWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyTestWriter.cs index 2909595..c0bd3e0 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyTestWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyTestWriter.cs @@ -23,17 +23,19 @@ namespace Tableau.Migration.PythonGenerator.Writers { internal sealed class PythonPropertyTestWriter : PythonMemberWriter, IPythonPropertyTestWriter { + private const string DOTNET_OBJ = "dotnet"; + private const string PY_OBJ = "py"; + public void Write(IndentingStringBuilder builder, PythonType type, PythonProperty property) { - var dotnetObj = "dotnet"; - var pyObj = "py"; + var dotNetPropRef = property.IsStatic ? DotNetTypeName(type) : DOTNET_OBJ; if (property.Getter) { using (var getterBuilder = builder.AppendLineAndIndent($"def test_{property.Name}_getter(self):")) { BuildTestBody(type, getterBuilder); - AddGetterAsserts(getterBuilder, dotnetObj, pyObj, property); + AddGetterAsserts(getterBuilder, property, dotNetPropRef); } builder.AppendLine(); @@ -44,7 +46,7 @@ public void Write(IndentingStringBuilder builder, PythonType type, PythonPropert using (var setterBuilder = builder.AppendLineAndIndent($"def test_{property.Name}_setter(self):")) { BuildTestBody(type, setterBuilder); - AddSetterAsserts(setterBuilder, dotnetObj, pyObj, property); + AddSetterAsserts(setterBuilder, property, dotNetPropRef); } builder.AppendLine(); @@ -53,28 +55,19 @@ public void Write(IndentingStringBuilder builder, PythonType type, PythonPropert private static void BuildTestBody(PythonType type, IndentingStringBuilder builder) { - var dotnetObj = "dotnet"; - var pyObj = "py"; - - if (!type.DotNetType.IsGenericType) - { - builder.AppendLine($"dotnet = self.create({type.DotNetType.Name})"); - builder.AppendLine($"{pyObj} = {type.Name}({dotnetObj})"); - } - else - { - builder.AppendLine( - $"dotnet = self.create({type.DotNetType.OriginalDefinition.Name}[{BuildDotnetGenericTypeConstraintsString(type.DotNetType)}])"); - builder.AppendLine( - $"{pyObj} = {type.Name}[{BuildPythongGenericTypeConstraintsString(type.DotNetType)}]({dotnetObj})"); - } + builder.AppendLine($"{DOTNET_OBJ} = self.create({DotNetTypeName(type)})"); + builder.AppendLine($"{PY_OBJ} = {PythonTypeName(type)}({DOTNET_OBJ})"); } - private static void AddSetterAsserts(IndentingStringBuilder builder, string dotnetObj, string pyObj, - PythonProperty property) + private static void AddSetterAsserts(IndentingStringBuilder builder, PythonProperty property, string dotNetPropRef) { - var dotnetPropValue = $"{dotnetObj}.{property.DotNetProperty.Name}"; - var pythonPropValue = $"{pyObj}.{property.Name}"; + var dotnetPropValue = $"{dotNetPropRef}.{property.DotNetProperty.Name}"; + var pythonPropValue = $"{PY_OBJ}.{property.Name}"; + + if (property.IsStatic) + { + pythonPropValue = $"{PY_OBJ}.get_{property.Name}()"; + } var typeRef = property.Type; @@ -90,7 +83,7 @@ private static void AddSetterAsserts(IndentingStringBuilder builder, string dotn builder.AppendLine("# create test data"); - var dotnetType = property.DotNetProperty.Type; + var dotnetType = property.DotNetPropertyType; var element = dotnetType switch { @@ -111,7 +104,15 @@ private static void AddSetterAsserts(IndentingStringBuilder builder, string dotn builder.AppendLine(); builder.AppendLine("# set property to new test value"); - builder.AppendLine($"{pythonPropValue} = testCollection"); + + if (property.IsStatic) + { + builder.AppendLine($"{PY_OBJ}.set_{property.Name}(testCollection)"); + } + else + { + builder.AppendLine($"{pythonPropValue} = testCollection"); + } builder.AppendLine(); builder.AppendLine("# assert value"); @@ -125,7 +126,7 @@ private static void AddSetterAsserts(IndentingStringBuilder builder, string dotn builder.AppendLine("# create test data"); - var dotnetType = (INamedTypeSymbol)property.DotNetProperty.Type; + var dotnetType = (INamedTypeSymbol)property.DotNetPropertyType; if (!dotnetType.IsGenericType) { @@ -141,7 +142,14 @@ private static void AddSetterAsserts(IndentingStringBuilder builder, string dotn var wrapExp = ToPythonType(typeRef, "testValue"); builder.AppendLine("# set property to new test value"); - builder.AppendLine($"{pythonPropValue} = {wrapExp}"); + if(property.IsStatic) + { + builder.AppendLine($"{PY_OBJ}.set_{property.Name}({wrapExp})"); + } + else + { + builder.AppendLine($"{pythonPropValue} = {wrapExp}"); + } builder.AppendLine(); builder.AppendLine("# assert value"); @@ -151,11 +159,14 @@ private static void AddSetterAsserts(IndentingStringBuilder builder, string dotn } } - private static void AddGetterAsserts(IndentingStringBuilder builder, string dotnetObj, string pyObj, - PythonProperty property) + private static void AddGetterAsserts(IndentingStringBuilder builder, PythonProperty property, string dotNetPropRef) { - var dotnetPropValue = $"{dotnetObj}.{property.DotNetProperty.Name}"; - var pythonPropValue = $"{pyObj}.{property.Name}"; + var dotnetPropValue = $"{dotNetPropRef}.{property.DotNetProperty.Name}"; + var pythonPropValue = $"{PY_OBJ}.{property.Name}"; + if (property.IsStatic) + { + pythonPropValue = $"{PY_OBJ}.get_{property.Name}()"; + } var typeRef = property.Type; diff --git a/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs b/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs index b6982c4..3e4c80a 100644 --- a/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs +++ b/src/Tableau.Migration.PythonGenerator/Writers/PythonPropertyWriter.cs @@ -36,12 +36,25 @@ public void Write(IndentingStringBuilder builder, PythonType type, PythonPropert { var typeDeclaration = ToPythonTypeDeclaration(type, property.Type); + var thisParam = property.IsStatic ? "cls" : "self"; + if (property.Getter) { - builder.AppendLine("@property"); - using (var getterBuilder = builder.AppendLineAndIndent($"def {property.Name}(self) -> {typeDeclaration}:")) + string getterPrefix; + if(property.IsStatic) { - BuildGetterBody(property, getterBuilder); + builder.AppendLine("@classmethod"); + getterPrefix = "get_"; + } + else + { + builder.AppendLine("@property"); + getterPrefix = string.Empty; + } + + using (var getterBuilder = builder.AppendLineAndIndent($"def {getterPrefix}{property.Name}({thisParam}) -> {typeDeclaration}:")) + { + BuildGetterBody(type, property, getterBuilder); } builder.AppendLine(); @@ -49,43 +62,62 @@ public void Write(IndentingStringBuilder builder, PythonType type, PythonPropert if (property.Setter) { - builder.AppendLine($"@{property.Name}.setter"); + string setterPrefix; + if (property.IsStatic) + { + builder.AppendLine("@classmethod"); + setterPrefix = "set_"; + } + else + { + builder.AppendLine($"@{property.Name}.setter"); + setterPrefix = string.Empty; + } + var paramName = "value"; - using (var setterBuilder = builder.AppendLineAndIndent($"def {property.Name}(self, {paramName}: {typeDeclaration}) -> None:")) + using (var setterBuilder = builder.AppendLineAndIndent($"def {setterPrefix}{property.Name}({thisParam}, {paramName}: {typeDeclaration}) -> None:")) { - BuildSetterBody(property, setterBuilder, paramName); + BuildSetterBody(type, property, setterBuilder, paramName); } builder.AppendLine(); } } - private void BuildGetterBody(PythonProperty property, IndentingStringBuilder getterBuilder) + private void BuildGetterBody(PythonType type, PythonProperty property, IndentingStringBuilder getterBuilder) { _docWriter.Write(getterBuilder, property.Documentation); - var getterExpression = ToPythonType(property.Type, $"self.{PythonTypeWriter.DOTNET_OBJECT}.{property.DotNetProperty.Name}"); + var getterExpression = ToPythonType(property.Type, DotNetPropertyInvocation(type, property)); getterBuilder.AppendLine($"return {getterExpression}"); } + private static string DotNetPropertyReference(PythonType type, PythonProperty property) + => property.IsStatic? DotNetTypeName(type) : $"self.{PythonTypeWriter.DOTNET_OBJECT}"; + + private static string DotNetPropertyInvocation(PythonType type, PythonProperty property) + => $"{DotNetPropertyReference(type, property)}.{property.DotNetProperty.Name}"; + private static void BuildForLoop(IndentingStringBuilder builder, string condition, Action body) { var forLoopBuilder = builder.AppendLineAndIndent($"for {condition}:"); body(forLoopBuilder); } + private static void BuildIfBlock(IndentingStringBuilder builder, string condition, Action body) { var ifBuilder = builder.AppendLineAndIndent($"if {condition}:"); body(ifBuilder); } + private static void BuildElseBlock(IndentingStringBuilder builder, Action body) { var elseBuilder = builder.AppendLineAndIndent($"else:"); body(elseBuilder); } - private void BuildSetterBody(PythonProperty property, IndentingStringBuilder setterBuilder, string paramName) + private void BuildSetterBody(PythonType type, PythonProperty property, IndentingStringBuilder setterBuilder, string paramName) { _docWriter.Write(setterBuilder, property.Documentation); @@ -105,7 +137,7 @@ private void BuildSetterBody(PythonProperty property, IndentingStringBuilder set BuildIfBlock(setterBuilder, $"{paramName} is None", (builder) => { - builder.AppendLine($"self.{PythonTypeWriter.DOTNET_OBJECT}.{property.DotNetProperty.Name} = {collectionTypeAlias}[{dotnetType.Name}]()"); + builder.AppendLine($"{DotNetPropertyInvocation(type, property)} = {collectionTypeAlias}[{dotnetType.Name}]()"); }); BuildElseBlock(setterBuilder, (builder) => @@ -131,25 +163,25 @@ private void BuildSetterBody(PythonProperty property, IndentingStringBuilder set } }); - if (conversionMode == ConversionMode.WrapArray) + if (conversionMode is ConversionMode.WrapArray) { - builder.AppendLine($"self.{PythonTypeWriter.DOTNET_OBJECT}.{property.DotNetProperty.Name} = dotnet_collection.ToArray()"); + builder.AppendLine($"{DotNetPropertyInvocation(type, property)} = dotnet_collection.ToArray()"); return; } - builder.AppendLine($"self.{PythonTypeWriter.DOTNET_OBJECT}.{property.DotNetProperty.Name} = dotnet_collection"); + builder.AppendLine($"{DotNetPropertyInvocation(type, property)} = dotnet_collection"); }); break; } case ConversionMode.Enum: { var setterExpression = ToDotNetType(property.Type, paramName); - setterBuilder.AppendLine($"self.{PythonTypeWriter.DOTNET_OBJECT}.{property.DotNetProperty.Name}.value__ = {property.Type.Name}({setterExpression})"); + setterBuilder.AppendLine($"{DotNetPropertyInvocation(type, property)}.value__ = {property.Type.Name}({setterExpression})"); break; } default: { var setterExpression = ToDotNetType(property.Type, paramName); - setterBuilder.AppendLine($"self.{PythonTypeWriter.DOTNET_OBJECT}.{property.DotNetProperty.Name} = {setterExpression}"); + setterBuilder.AppendLine($"{DotNetPropertyInvocation(type, property)} = {setterExpression}"); break; } } @@ -157,7 +189,7 @@ private void BuildSetterBody(PythonProperty property, IndentingStringBuilder set static string? GetCollectionTypeAlias(ConversionMode conversionMode, string typeRefName) { string? collectionTypeAlias; - if (conversionMode == ConversionMode.WrapArray) + if (conversionMode is ConversionMode.WrapArray) { collectionTypeAlias = PythonMemberGenerator.LIST_REFERENCE.ImportAlias; } diff --git a/src/Tableau.Migration.PythonGenerator/appsettings.json b/src/Tableau.Migration.PythonGenerator/appsettings.json index f3e0605..d7c374a 100644 --- a/src/Tableau.Migration.PythonGenerator/appsettings.json +++ b/src/Tableau.Migration.PythonGenerator/appsettings.json @@ -45,6 +45,25 @@ } ] }, + { + "namespace": "Tableau.Migration.Engine.Pipelines", + "types": [ + { + "type": "MigrationPipelineContentType", + "excludeMembers": [ + "WithPublishType", + "WithResultType", + "GetPublishTypeForInterface", + "GetContentTypeForInterface", + "GetPostPublishTypesForInterface" + ] + }, + { + "type": "ServerToCloudMigrationPipeline", + "excludeMembers": [ "BuildPipeline", "GetBatchMigrator" ] + } + ] + }, { "namespace": "Tableau.Migration.Content.Files", "types": [ diff --git a/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs b/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs index 2d6203e..4d755d9 100644 --- a/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs +++ b/src/Tableau.Migration/Api/IHttpResponseMessageExtensions.cs @@ -17,8 +17,10 @@ using System; using System.Collections.Immutable; +using System.Linq; 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; @@ -56,11 +58,14 @@ 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( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, + correlationId, tsError, sharedResourcesLocalizer); } @@ -118,9 +123,12 @@ public static IResult ToResult(this IHttpResponseMess if (restError is not null) { + var correlationId = response.Headers.GetCorrelationId(); + throw new RestException( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, + correlationId, restError, sharedResourcesLocalizer); } @@ -149,9 +157,12 @@ public static async Task> ToResultAsync(this if (restError is not null) { + var correlationId = response.Headers.GetCorrelationId(); + throw new RestException( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, + correlationId, restError, sharedResourcesLocalizer); } @@ -196,9 +207,12 @@ public static IPagedResult ToPagedResult(this IHttpRe if (restError is not null) { + var correlationId = response.Headers.GetCorrelationId(); + throw new RestException( response.RequestMessage?.Method, response.RequestMessage?.RequestUri, + correlationId, restError, sharedResourcesLocalizer); } @@ -245,9 +259,12 @@ public static async Task> ToPagedResultAsyncThe new project's details. /// The cancellation token to obey. /// The newly created project. - Task> CreateProjectAsync( - ICreateProjectOptions options, - CancellationToken cancel); + Task> CreateProjectAsync(ICreateProjectOptions options, CancellationToken cancel); /// /// Gets the project's default permissions. /// /// The project ID. /// The cancellation token to obey. - Task>> GetAllDefaultPermissionsAsync( - Guid projectId, - CancellationToken cancel); + Task>> GetAllDefaultPermissionsAsync(Guid projectId, CancellationToken cancel); /// /// Updates the project's default permissions. @@ -59,10 +55,7 @@ Task>> GetAllDefaultPermissio /// The project ID. /// The new permissions. /// The cancellation token to obey. - Task>> UpdateAllDefaultPermissionsAsync( - Guid projectId, - IReadOnlyDictionary permissions, - CancellationToken cancel); + Task UpdateAllDefaultPermissionsAsync(Guid projectId, IReadOnlyDictionary permissions, CancellationToken cancel); /// /// Updates the project after publishing. diff --git a/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs index 653851a..5925b2d 100644 --- a/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs @@ -25,6 +25,8 @@ 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; @@ -96,10 +98,10 @@ internal static IServiceCollection AddMigrationApiClient(this IServiceCollection services.AddScoped(p => p.GetRequiredService().ContentReferenceFinderFactory); services.AddScoped(typeof(BulkApiContentReferenceCache<>)); - //Content caches + //Content caches. services.AddScoped(); - //Server schedules content cache + //Server schedules content caches. services.AddScoped>(); services.AddScoped>(p => p.GetRequiredService>()); services.AddScoped>(p => p.GetRequiredService>()); @@ -111,6 +113,11 @@ 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/Permissions/DefaultPermissionsApiClient.cs b/src/Tableau.Migration/Api/Permissions/DefaultPermissionsApiClient.cs index 18b8bc1..3252c0e 100644 --- a/src/Tableau.Migration/Api/Permissions/DefaultPermissionsApiClient.cs +++ b/src/Tableau.Migration/Api/Permissions/DefaultPermissionsApiClient.cs @@ -61,11 +61,7 @@ private IPermissionsApiClient EnsurePermissionsClient(string contentTypeUrlSegme return client; }); } - - public Task> GetPermissionsAsync( - string contentTypeUrlSegment, - Guid projectId, - CancellationToken cancel) + public Task> GetPermissionsAsync(string contentTypeUrlSegment, Guid projectId, CancellationToken cancel) => EnsurePermissionsClient(contentTypeUrlSegment).GetPermissionsAsync(projectId, cancel); public Task> CreatePermissionsAsync( @@ -84,14 +80,7 @@ public Task DeleteCapabilityAsync( CancellationToken cancel) => EnsurePermissionsClient(contentTypeUrlSegment).DeleteCapabilityAsync(projectId, granteeId, granteeType, capability, cancel); - public Task DeleteAllPermissionsAsync( - string contentTypeUrlSegment, - Guid projectId, - IPermissions permissions, - CancellationToken cancel) - => EnsurePermissionsClient(contentTypeUrlSegment).DeleteAllPermissionsAsync(projectId, permissions, cancel); - - public Task> UpdatePermissionsAsync( + public Task UpdatePermissionsAsync( string contentTypeUrlSegment, Guid projectId, IPermissions permissions, @@ -133,15 +122,13 @@ public async Task>> GetAllPer resultBuilder.Build().Errors); } - public async Task>> UpdateAllPermissionsAsync( + public async Task UpdateAllPermissionsAsync( Guid projectId, IReadOnlyDictionary permissions, CancellationToken cancel) { var resultBuilder = new ResultBuilder(); - var updatedPermissions = ImmutableDictionary.CreateBuilder(StringComparer.OrdinalIgnoreCase); - foreach (var permission in permissions) { var updatePermissionsResult = await UpdatePermissionsAsync(permission.Key, projectId, permission.Value, cancel).ConfigureAwait(false); @@ -152,17 +139,9 @@ public async Task>> UpdateAll { continue; } - updatedPermissions.Add(permission.Key, updatePermissionsResult.Value); } - var result = resultBuilder.Build(); - - if (!result.Success) - { - return Result>.Failed(result.Errors); - } - - return Result>.Succeeded(updatedPermissions.ToImmutable()); + return resultBuilder.Build(); } } } diff --git a/src/Tableau.Migration/Api/Permissions/IDefaultPermissionsApiClient.cs b/src/Tableau.Migration/Api/Permissions/IDefaultPermissionsApiClient.cs index 0630dac..c488f81 100644 --- a/src/Tableau.Migration/Api/Permissions/IDefaultPermissionsApiClient.cs +++ b/src/Tableau.Migration/Api/Permissions/IDefaultPermissionsApiClient.cs @@ -47,9 +47,7 @@ Task> GetPermissionsAsync( /// The ID of the project. /// The cancellation token to obey. /// The permissions result with content type . - Task>> GetAllPermissionsAsync( - Guid projectId, - CancellationToken cancel); + Task>> GetAllPermissionsAsync(Guid projectId, CancellationToken cancel); /// /// Creates the content type's default permissions for the project with the specified ID. @@ -83,20 +81,6 @@ Task DeleteCapabilityAsync( ICapability capability, CancellationToken cancel); - /// - /// Remove all content type's default for a project. - /// - /// The permissions' content type URL segment. - /// The ID of the project. - /// - /// The cancellation token to obey. - /// - Task DeleteAllPermissionsAsync( - string contentTypeUrlSegment, - Guid projectId, - IPermissions permissions, - CancellationToken cancel); - /// /// Updates the content type's default permissions for the project with the specified ID. /// @@ -105,7 +89,7 @@ Task DeleteAllPermissionsAsync( /// The permissions of the content item. /// The cancellation token to obey. /// The permissions result with . - Task> UpdatePermissionsAsync( + Task UpdatePermissionsAsync( string contentTypeUrlSegment, Guid projectId, IPermissions permissions, @@ -118,7 +102,7 @@ Task> UpdatePermissionsAsync( /// The permissions of the content items, keyed by the content type's URL segment. /// The cancellation token to obey. /// The permissions result with . - Task>> UpdateAllPermissionsAsync( + Task UpdateAllPermissionsAsync( Guid projectId, IReadOnlyDictionary permissions, CancellationToken cancel); diff --git a/src/Tableau.Migration/Api/Permissions/IPermissionsApiClient.cs b/src/Tableau.Migration/Api/Permissions/IPermissionsApiClient.cs index 22fe99e..33acba5 100644 --- a/src/Tableau.Migration/Api/Permissions/IPermissionsApiClient.cs +++ b/src/Tableau.Migration/Api/Permissions/IPermissionsApiClient.cs @@ -33,9 +33,7 @@ public interface IPermissionsApiClient /// The ID of the content item. /// The cancellation token to obey. /// The permissions result with . - Task> GetPermissionsAsync( - Guid contentItemId, - CancellationToken cancel); + Task> GetPermissionsAsync(Guid contentItemId, CancellationToken cancel); /// /// Creates the permissions for the content item with the specified ID. @@ -44,10 +42,7 @@ Task> GetPermissionsAsync( /// The permissions of the content item. /// The cancellation token to obey. /// The permissions result with . - Task> CreatePermissionsAsync( - Guid contentItemId, - IPermissions permissions, - CancellationToken cancel); + Task> CreatePermissionsAsync(Guid contentItemId, IPermissions permissions, CancellationToken cancel); /// /// Remove a for a user/group on a content item. @@ -65,18 +60,6 @@ Task DeleteCapabilityAsync( ICapability capability, CancellationToken cancel); - /// - /// Remove all for a content item. - /// - /// Id of the content item. - /// - /// The cancellation token to obey. - /// - Task DeleteAllPermissionsAsync( - Guid contentItemId, - IPermissions permissions, - CancellationToken cancel); - /// /// Updates the permissions for the content item with the specified ID. /// @@ -84,9 +67,6 @@ Task DeleteAllPermissionsAsync( /// The permissions of the content item. /// The cancellation token to obey. /// The permissions result with . - Task> UpdatePermissionsAsync( - Guid contentItemId, - IPermissions permissions, - CancellationToken cancel); + Task UpdatePermissionsAsync(Guid contentItemId, IPermissions permissions, CancellationToken cancel); } } \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Permissions/PermissionsApiClient.cs b/src/Tableau.Migration/Api/Permissions/PermissionsApiClient.cs index 604609f..c1cad33 100644 --- a/src/Tableau.Migration/Api/Permissions/PermissionsApiClient.cs +++ b/src/Tableau.Migration/Api/Permissions/PermissionsApiClient.cs @@ -16,7 +16,6 @@ // using System; -using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -49,16 +48,7 @@ public PermissionsApiClient( _sharedResourcesLocalizer = sharedResourcesLocalizer; } - internal virtual ImmutableArray GetCapabilitiesToDelete( - IGranteeCapability[] sourceItems, - IGranteeCapability[] destinationItems) - { - return destinationItems.Except(sourceItems).ToImmutableArray(); - } - - public async virtual Task> GetPermissionsAsync( - Guid id, - CancellationToken cancel) + public async Task> GetPermissionsAsync(Guid id, CancellationToken cancel) { return await _restRequestBuilderFactory .CreatePermissionsUri(_uriBuilder, id) @@ -68,10 +58,7 @@ public async virtual Task> GetPermissionsAsync( .ConfigureAwait(false); } - public async virtual Task> CreatePermissionsAsync( - Guid contentItemId, - IPermissions permissions, - CancellationToken cancel) + public async Task> CreatePermissionsAsync(Guid contentItemId, IPermissions permissions, CancellationToken cancel) { // The REST API will error if you try to update with no grantees. if (!permissions.GranteeCapabilities.Any()) @@ -86,7 +73,7 @@ public async virtual Task> CreatePermissionsAsync( .ConfigureAwait(false); } - public async virtual Task DeleteCapabilityAsync( + public async Task DeleteCapabilityAsync( Guid contentItemId, Guid granteeId, GranteeType granteeType, @@ -101,97 +88,14 @@ public async virtual Task DeleteCapabilityAsync( .ConfigureAwait(false); } - public async Task DeleteAllPermissionsAsync( - Guid contentItemId, - IPermissions destinationPermissions, - CancellationToken cancel) - { - return await DeletePermissionsAsync( - contentItemId, - new Content.Permissions.Permissions(), - destinationPermissions, - cancel) - .ConfigureAwait(false); - } - - public async virtual Task DeletePermissionsAsync( - Guid contentItemId, - IPermissions sourcePermissions, - IPermissions destinationPermissions, - CancellationToken cancel) - { - var itemsToDelete = GetCapabilitiesToDelete( - sourcePermissions.GranteeCapabilities, - destinationPermissions.GranteeCapabilities); - - if (itemsToDelete.IsNullOrEmpty()) - { - return Result.Succeeded(); - } - - var resultBuilder = new ResultBuilder(); - - foreach (var item in itemsToDelete) - { - var granteeId = item.GranteeId; - - foreach (var capability in item.Capabilities) - { - var deleteResult = await DeleteCapabilityAsync( - contentItemId, - granteeId, - item.GranteeType, - capability, - cancel) - .ConfigureAwait(false); - - if (!deleteResult.Success) - { - resultBuilder.Add(deleteResult); - } - } - } - - var result = resultBuilder.Build(); - - if (!result.Success) - { - return Result.Failed(result.Errors); - } - return Result.Succeeded(); - } - - public async virtual Task> UpdatePermissionsAsync( - Guid contentItemId, - IPermissions sourcePermissions, - CancellationToken cancel) + public async Task UpdatePermissionsAsync(Guid contentItemId, IPermissions permissions, CancellationToken cancel) { - var destinationPermissionsResult = await GetPermissionsAsync( - contentItemId, - cancel) - .ConfigureAwait(false); - - if (!destinationPermissionsResult.Success) - { - return Result.Failed(destinationPermissionsResult.Errors); - } - - var deleteResult = await DeletePermissionsAsync( - contentItemId, - sourcePermissions, - destinationPermissionsResult.Value, - cancel) - .ConfigureAwait(false); - - if (!deleteResult.Success) - { - return Result.Failed(deleteResult.Errors); - } - - return await CreatePermissionsAsync( - contentItemId, - sourcePermissions, - cancel) + return await _restRequestBuilderFactory + .CreatePermissionsUri(_uriBuilder, contentItemId) + .ForPostRequest() + .WithXmlContent(new PermissionsAddRequest(permissions)) + .SendAsync(cancel) + .ToResultAsync(_serializer, _sharedResourcesLocalizer, cancel) .ConfigureAwait(false); } } diff --git a/src/Tableau.Migration/Api/ProjectsApiClient.cs b/src/Tableau.Migration/Api/ProjectsApiClient.cs index d7debec..e79c66a 100644 --- a/src/Tableau.Migration/Api/ProjectsApiClient.cs +++ b/src/Tableau.Migration/Api/ProjectsApiClient.cs @@ -265,7 +265,7 @@ public async Task>> GetAllDef .ConfigureAwait(false); } - public async Task>> UpdateAllDefaultPermissionsAsync( + public async Task UpdateAllDefaultPermissionsAsync( Guid projectId, IReadOnlyDictionary permissions, CancellationToken cancel) diff --git a/src/Tableau.Migration/Api/Rest/RestException.cs b/src/Tableau.Migration/Api/Rest/RestException.cs index 13b3c01..86f6c96 100644 --- a/src/Tableau.Migration/Api/Rest/RestException.cs +++ b/src/Tableau.Migration/Api/Rest/RestException.cs @@ -52,23 +52,31 @@ public class RestException : Exception, IEquatable /// public readonly string? Summary; + /// + /// Gets the request Correlation ID + /// + public readonly string? CorrelationId; + /// /// Creates a new instance. /// /// This should only be used for deserialization. /// The http method that generated the current error. /// The request URI that generated the current error. + /// The request Correlation ID /// The returned from the Tableau API. /// Message for base Exception. internal RestException( HttpMethod? httpMethod, Uri? requestUri, + string? correlationId, Error error, string exceptionMessage) : base(exceptionMessage) { HttpMethod = httpMethod; RequestUri = requestUri; + CorrelationId = correlationId; Code = error.Code; Detail = error.Detail; Summary = error.Summary; @@ -79,19 +87,22 @@ internal RestException( /// /// The http method that generated the current error. /// The request URI that generated the current error. + /// The request Correlation ID /// The returned from the Tableau API. /// A string localizer. public RestException( HttpMethod? httpMethod, Uri? requestUri, + string? correlationId, Error error, ISharedResourcesLocalizer sharedResourcesLocalizer) - : this(httpMethod, requestUri, error, FormatError(httpMethod, requestUri, error, sharedResourcesLocalizer)) + : this(httpMethod, requestUri, correlationId, error, FormatError(httpMethod, requestUri, correlationId, error, sharedResourcesLocalizer)) { } private static string FormatError( HttpMethod? httpMethod, Uri? requestUri, + string? correlationId, Error error, ISharedResourcesLocalizer sharedResourcesLocalizer) { @@ -101,6 +112,7 @@ private static string FormatError( sharedResourcesLocalizer[SharedResourceKeys.RestExceptionContent], httpMethod, requestUri, + correlationId, error.Code ?? nullValue, error.Summary ?? nullValue, error.Detail ?? nullValue); diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/PermissionsRestApiSimulatorBase.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/PermissionsRestApiSimulatorBase.cs index 8eeb532..49fcecb 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Api/PermissionsRestApiSimulatorBase.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/PermissionsRestApiSimulatorBase.cs @@ -47,6 +47,11 @@ public abstract class PermissionsRestApiSimulatorBase /// public MethodSimulator QueryPermissions { get; } + /// + /// Gets the simulated replace permission API method. + /// + public MethodSimulator ReplacePermissions { get; } + /// /// Creates a new object. /// @@ -67,6 +72,10 @@ public PermissionsRestApiSimulatorBase( QueryPermissions = simulator.SetupRestGet( SiteEntityUrl(ContentTypeUrlPrefix, "permissions"), new RestPermissionsGetResponseBuilder(simulator.Data, simulator.Serializer, ContentTypeUrlPrefix, getContent)); + + ReplacePermissions = simulator.SetupRestPost( + SiteEntityUrl(ContentTypeUrlPrefix, "permissions"), + new RestPermissionsCreateResponseBuilder(simulator.Data, simulator.Serializer, ContentTypeUrlPrefix, getContent)); } } } diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/ProjectsRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/ProjectsRestApiSimulator.cs index 56f1f5d..15b8cc8 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Api/ProjectsRestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/ProjectsRestApiSimulator.cs @@ -60,6 +60,11 @@ public sealed class ProjectsRestApiSimulator : PermissionsRestApiSimulatorBase

public MethodSimulator QueryDefaultProjectPermissions { get; } + ///

+ /// Gets the simulated replace project default permission API method. + /// + public MethodSimulator ReplaceDefaultProjectPermissions { get; } + /// /// Gets the simulated update project API method. /// @@ -143,6 +148,10 @@ public ProjectsRestApiSimulator(TableauApiResponseSimulator simulator) }; }); + ReplaceDefaultProjectPermissions = simulator.SetupRestPost( + _defaultProjectPermissionsRegex, + new RestDefaultPermissionsCreateResponseBuilder(simulator.Data, simulator.Serializer)); + UpdateProject = simulator.SetupRestPut( SiteEntityUrl(ContentTypeUrlPrefix), UpdateProjectFromRequest); } diff --git a/src/Tableau.Migration/Api/Simulation/TableauData.cs b/src/Tableau.Migration/Api/Simulation/TableauData.cs index ee0c239..311197c 100644 --- a/src/Tableau.Migration/Api/Simulation/TableauData.cs +++ b/src/Tableau.Migration/Api/Simulation/TableauData.cs @@ -508,9 +508,7 @@ internal void AddDataSource( ///
/// The metadata /// A byte array representing the workbook. If null, empty array is used - internal void AddWorkbook( - WorkbookResponse.WorkbookType workbook, - byte[]? fileData) + internal void AddWorkbook(WorkbookResponse.WorkbookType workbook, byte[]? fileData) { Workbooks.Add(workbook); WorkbookFiles[workbook.Id] = fileData ?? Array.Empty(); @@ -520,8 +518,7 @@ internal void AddWorkbook( /// Adds a view to simulated dataset. ///
/// The metadata - internal void AddView( - WorkbookResponse.WorkbookType.ViewReferenceType view) + internal void AddView(WorkbookResponse.WorkbookType.ViewReferenceType view) { Views.Add(view); } @@ -531,13 +528,12 @@ internal void AddView( /// /// The metadata /// A byte array representing the custom view. If null, empty array is used - internal void AddCustomView( - CustomViewResponse.CustomViewType customView, - byte[]? fileData) + internal void AddCustomView(CustomViewResponse.CustomViewType customView, byte[]? fileData) { CustomViews.Add(customView); CustomViewFiles[customView.Id] = fileData ?? []; } + internal void AddDefaultProjectPermissions(Guid projectId, string contentTypeUrlSegment, PermissionsType permissions) { DefaultProjectPermissions.AddOrUpdate( diff --git a/src/Tableau.Migration/Api/TasksApiClient.cs b/src/Tableau.Migration/Api/TasksApiClient.cs index 74ca31a..bc15eb5 100644 --- a/src/Tableau.Migration/Api/TasksApiClient.cs +++ b/src/Tableau.Migration/Api/TasksApiClient.cs @@ -46,6 +46,7 @@ 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, @@ -54,7 +55,8 @@ public TasksApiClient( IServerSessionProvider sessionProvider, ILoggerFactory loggerFactory, ISharedResourcesLocalizer sharedResourcesLocalizer, - IHttpContentSerializer serializer) + IHttpContentSerializer serializer, + IExtractRefreshTaskConverter extractRefreshTaskConverter) : base( restRequestBuilderFactory, finderFactory, @@ -65,6 +67,7 @@ public TasksApiClient( _sessionProvider = sessionProvider; _contentCacheFactory = contentCacheFactory; _serializer = serializer; + _extractRefreshTaskConverter = extractRefreshTaskConverter; } #region - ITasksApiClient - @@ -124,7 +127,7 @@ async Task> ICloudTasksApiClient.CreateExtract // Since we published with a content reference, we expect the reference returned is valid/knowable. Guard.AgainstNull(contentReference, () => contentReference); - return CloudExtractRefreshTask.Create(task, r.Schedule, contentReference); + return CloudExtractRefreshTask.Create(task, r.Schedule, contentReference); }, SharedResourcesLocalizer, cancel) .ConfigureAwait(false); @@ -163,12 +166,8 @@ public Task> PullAsync( IServerExtractRefreshTask contentItem, CancellationToken cancel) { - ICloudExtractRefreshTask cloudExtractRefreshTask = new CloudExtractRefreshTask( - contentItem.Id, - contentItem.Type, - contentItem.ContentType, - contentItem.Content, - new CloudSchedule(contentItem.Schedule.Frequency, contentItem.Schedule.FrequencyDetails)); + + var cloudExtractRefreshTask = _extractRefreshTaskConverter.Convert(contentItem); return Task.FromResult(new ResultBuilder() .Build(cloudExtractRefreshTask)); @@ -229,7 +228,7 @@ private async Task>> GetAllExtractRe Func>> responseItemFactory, CancellationToken cancel) where TResponse : TableauServerResponse - where TExtractRefreshTask: IExtractRefreshTask + where TExtractRefreshTask : IExtractRefreshTask where TSchedule : ISchedule { return await RestRequestBuilderFactory diff --git a/src/Tableau.Migration/Constants.cs b/src/Tableau.Migration/Constants.cs index 8e83427..3d472b0 100644 --- a/src/Tableau.Migration/Constants.cs +++ b/src/Tableau.Migration/Constants.cs @@ -115,6 +115,8 @@ public static class Constants /// internal const string USER_AGENT_PREFIX = "TableauMigrationSDK"; + internal const string REQUEST_CORRELATION_ID_HEADER = "X-Correlation-Id"; + #endregion } } diff --git a/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs b/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs index 37322f5..1d6377b 100644 --- a/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs +++ b/src/Tableau.Migration/Content/Permissions/DefaultPermissionsContentTypeUrlSegments.cs @@ -45,11 +45,6 @@ public class DefaultPermissionsContentTypeUrlSegments : StringEnum public const string Flows = "flows"; - /// - /// Gets the metric content type URL path segment. - /// - public const string Metrics = "metrics"; - /// /// Gets the database content type URL path segment. /// diff --git a/src/Tableau.Migration/Content/Schedules/Cloud/CloudSchedule.cs b/src/Tableau.Migration/Content/Schedules/Cloud/CloudSchedule.cs index 13633e2..38c84ba 100644 --- a/src/Tableau.Migration/Content/Schedules/Cloud/CloudSchedule.cs +++ b/src/Tableau.Migration/Content/Schedules/Cloud/CloudSchedule.cs @@ -28,5 +28,9 @@ public CloudSchedule(ICloudScheduleType response) public CloudSchedule(string frequency, IFrequencyDetails frequencyDetails) : base(frequency, frequencyDetails, null) { } + + public CloudSchedule(ISchedule schedule) + : base(schedule.Frequency, schedule.FrequencyDetails, schedule.NextRunAt) + { } } } \ No newline at end of file diff --git a/src/Tableau.Migration/Content/Schedules/Cloud/CloudScheduleValidator.cs b/src/Tableau.Migration/Content/Schedules/Cloud/CloudScheduleValidator.cs new file mode 100644 index 0000000..6276417 --- /dev/null +++ b/src/Tableau.Migration/Content/Schedules/Cloud/CloudScheduleValidator.cs @@ -0,0 +1,354 @@ +// +// 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; +using System.Linq; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Content.Schedules.Cloud +{ + internal class CloudScheduleValidator : IScheduleValidator + { + private readonly ILogger _logger; + private readonly ISharedResourcesLocalizer _localizer; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance for logging. + /// The localizer instance for localization. + public CloudScheduleValidator(ILogger logger, ISharedResourcesLocalizer localizer) + { + _logger = logger; + _localizer = localizer; + } + + /// + /// Validates that the cloud schedule is valid based on the required of the "Create Cloud Extract Refresh Task" RestAPI. + /// https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_extract_and_encryption.htm#create_cloud_extract_refresh_task + /// + /// Cloud schedule to validate. + public void Validate(ICloudSchedule schedule) + { + // + // This validation process is not exhaustive. Additional checks may be required to ensure complete validity. + // + + Guard.AgainstNull(schedule, nameof(schedule)); + + switch (schedule.Frequency) + { + case ScheduleFrequencies.Hourly: + ValidateHourly(schedule); + break; + case ScheduleFrequencies.Daily: + ValidateDaily(schedule); + break; + case ScheduleFrequencies.Weekly: + ValidateWeekly(schedule); + break; + case ScheduleFrequencies.Monthly: + ValidateMonthly(schedule); + break; + + case null: + case "": + LogAndThrow(schedule, new ArgumentException(_localizer[SharedResourceKeys.FrequencyNotSetError])); + break; + + default: + LogAndThrow(schedule, new ArgumentException(_localizer[SharedResourceKeys.FrequencyNotSupportedError])); + break; + } + } + + private void LogAndThrow(ICloudSchedule schedule, Exception exception) + { + _logger.LogError(exception, _localizer[SharedResourceKeys.InvalidScheduleError, schedule]); + throw exception; + } + + #region - Frequency Specific Validation - + + private void ValidateHourly(ICloudSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Hourly); + + ValidateStartTime(schedule); + + ValidateEndTime(schedule); + + ValidateTimeDifference(schedule); + + ValidateHourOrMinuteIsSetOnce(schedule); + + ValidateAtLeastOneWeekday(schedule); + + ValidateWeekDayValues(schedule); + } + + private void ValidateDaily(ICloudSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Daily); + + ValidateStartTime(schedule); + + ValidateAtLeastOneWeekday(schedule); + + ValidateWeekDayValues(schedule); + + // Hours is not required + // If hours exist, only the last one is used + // if hours exist, it must be 2,4,6,8,12,24 + + var intervals = schedule.FrequencyDetails.Intervals; + + // Validate that there is exactly one hours interval + var hoursIntervals = intervals.Where(i => i.Hours.HasValue).ToList(); + if (hoursIntervals.Count > 1) + { +#pragma warning disable CA2254 // Template should be a static expression - Suppressing as this message is used outside of logging as well + _logger.LogWarning(_localizer[SharedResourceKeys.ScheduleShouldOnlyHaveOneHoursIntervalWarning, schedule]); +#pragma warning restore CA2254 // Template should be a static expression + } + + // Validate that the hours interval is one of the valid values + var hourInterval = intervals.Where(i => i.Hours.HasValue).LastOrDefault(); + if (hourInterval is not null && !IsValidHours(hourInterval.Hours!.Value)) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidHourlyIntervalForCloudError, schedule.Frequency])); + } + + + + if (hourInterval is not null) + { + // Note: Docs say that if hours is less then 24, then end is required. + // However, when actually trying this, any value where hours has a valid requires an end time. + ValidateEndTime(schedule); + + ValidateTimeDifference(schedule); + } + else + { + // If hours does not exist, then end must not exist + ValidateNoEndTime(schedule); + } + } + + private void ValidateWeekly(ICloudSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Weekly); + + ValidateStartTime(schedule); + + var intervals = schedule.FrequencyDetails.Intervals; + + // Validate that there is exactly one weekday interval + var weekDayIntervals = intervals.Where(i => !string.IsNullOrEmpty(i.WeekDay)).ToList(); + if (weekDayIntervals.Count != 1) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustHaveExactlyOneWeekdayIntervalError, schedule.Frequency])); + } + + // Validate that the weekday is one of the valid values + var weekDay = weekDayIntervals.First().WeekDay!; + if (!IsValidWeekDay(weekDay)) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidWeekdayError])); + } + + ValidateNoEndTime(schedule); + } + + private void ValidateMonthly(ICloudSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Monthly); + + ValidateStartTime(schedule); + + var intervals = schedule.FrequencyDetails.Intervals; + + // Validate that there is at least one interval + if (!intervals.Any()) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.AtLeastOneIntervalError, schedule.Frequency])); + } + + // Validate that at least one interval has MonthDay set + if (!intervals.Any(i => !string.IsNullOrEmpty(i.MonthDay))) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.AtLeastOneValidMonthDayError])); + } + + foreach (var interval in intervals) + { + // Validate monthDay by day number + if (!string.IsNullOrEmpty(interval.MonthDay) && IsValidMonthDayNumber(interval.MonthDay)) + { + if (interval.MonthDay == IntervalValues.OccurrenceOfWeekday.LastDay.ToString() && intervals.Count(i => i.MonthDay == IntervalValues.OccurrenceOfWeekday.LastDay.ToString()) > 1) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustOnlyHaveOneIntervalWithLastDayError, schedule.Frequency])); + } + } + // Validate monthDay by occurrence of weekday + else if (!string.IsNullOrEmpty(interval.MonthDay) && IsValidMonthDayOccurrence(interval.MonthDay) && IsValidWeekDay(interval.WeekDay)) + { + if (interval.MonthDay == IntervalValues.OccurrenceOfWeekday.LastDay.ToString() && intervals.Count(i => i.MonthDay == IntervalValues.OccurrenceOfWeekday.LastDay.ToString() && i.WeekDay == interval.WeekDay) > 1) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustOnlyHaveOneIntervalWithLastDayError, schedule.Frequency])); + } + } + else + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidScheduleForMonthlyError])); + } + } + + // Ensure there is no end time specified + ValidateNoEndTime(schedule); + } + + #endregion + + #region - Validation Helper Methods - + private void ValidateExpectedFrequency(ICloudSchedule schedule, string expectedFrequency) + { + if (!ScheduleFrequencies.IsAMatch(schedule.Frequency, expectedFrequency)) + { + LogAndThrow(schedule, new ArgumentException(_localizer[SharedResourceKeys.FrequencyNotSetError])); + } + } + + private void ValidateStartTime(ICloudSchedule schedule) + { + // Docs say that start time can be anything, but API responds states it must be quarter hour. + // In reality, 5 minute increments are all that can be set via RestAPI and UI, and those work. + // No need to check anything. + + if (!schedule.FrequencyDetails.StartAt.HasValue) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, schedule.Frequency])); + } + } + + private void ValidateEndTime(ICloudSchedule schedule) + { + // Docs say that start time can be anything, but API responds states it must be quarter hour. + // In reality, 5 minute increments are all that can be set via RestAPI and UI, and those work. + // No need to check anything. + + if (!schedule.FrequencyDetails.EndAt.HasValue) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustHaveEndAtTimeError, schedule.Frequency])); + } + } + + private void ValidateNoEndTime(ICloudSchedule schedule) + { + if (schedule.FrequencyDetails.EndAt.HasValue) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustNotHaveEndAtTimeError, schedule.Frequency])); + } + } + + private void ValidateAtLeastOneWeekday(ICloudSchedule schedule) + { + if (!schedule.FrequencyDetails.Intervals.Any(interval => !string.IsNullOrEmpty(interval.WeekDay))) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.AtLeastOneValidWeekdayError, schedule.Frequency])); + } + } + private void ValidateHourOrMinuteIsSetOnce(ICloudSchedule schedule) + { + var intervals = schedule.FrequencyDetails.Intervals; + + // Check that there is exactly one interval where either hours or minutes is set, but not both + var validIntervals = intervals.Where(i => (i.Hours.HasValue && !i.Minutes.HasValue) || (!i.Hours.HasValue && i.Minutes.HasValue)).ToList(); + + if (validIntervals.Count != 1) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ExactlyOneHourOrMinutesError])); + } + + var interval = validIntervals.First(); + + // Check that if hours is set, it must be 1 + if (interval.Hours.HasValue && interval.Hours != 1) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.IntervalMustBe1HourOr60MinError, schedule.Frequency])); + } + + // Check that if minutes is set, it must be 60 + if (interval.Minutes.HasValue && interval.Minutes != 60) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.IntervalMustBe1HourOr60MinError, schedule.Frequency])); + } + } + + private void ValidateWeekDayValues(ICloudSchedule schedule) + { + if (schedule.FrequencyDetails.Intervals.Any(interval => !string.IsNullOrEmpty(interval.WeekDay) && !IsValidWeekDay(interval.WeekDay))) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidWeekdayError])); + + } + } + + private bool IsValidWeekDay(string? weekDay) + { + return IntervalValues.WeekDaysValues.Contains(weekDay); + } + + private bool IsValidHours(int hours) + { + return IntervalValues.CloudHoursValues.Contains(hours); + } + + private bool IsValidMonthDayNumber(string? monthDay) + { + return IntervalValues.MonthDayNumberValues.Contains(monthDay); + } + + private bool IsValidMonthDayOccurrence(string? occurrence) + { + return IntervalValues.MonthDayOccurrenceValues.Contains(occurrence); + } + + /// + /// Validates that the time difference is exactly 60 minutes. + /// + private void ValidateTimeDifference(ICloudSchedule schedule) + { + var startTime = schedule.FrequencyDetails.StartAt!.Value; + var endTime = schedule.FrequencyDetails.EndAt!.Value; + + var timeDifference = (endTime - startTime).TotalMinutes; + + if (timeDifference % 60 != 0) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.StartEndTimeDifferenceError])); + } + } + + #endregion + + + } +} diff --git a/src/Tableau.Migration/Content/Schedules/ExtractRefreshTaskConverterBase.cs b/src/Tableau.Migration/Content/Schedules/ExtractRefreshTaskConverterBase.cs new file mode 100644 index 0000000..33cc7bb --- /dev/null +++ b/src/Tableau.Migration/Content/Schedules/ExtractRefreshTaskConverterBase.cs @@ -0,0 +1,52 @@ +// +// 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; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Content.Schedules +{ + 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; } + + protected ExtractRefreshTaskConverterBase( + ILogger> logger, + ISharedResourcesLocalizer localizer) + { + Logger = logger; + Localizer = localizer; + } + + /// + public TTargetTask Convert(TSourceTask source) + { + var targetSchedule = ConvertSchedule(source.Schedule); + return ConvertExtractRefreshTask(source, targetSchedule); + } + + protected abstract TTargetSchedule ConvertSchedule(TSourceSchedule sourceSchedule); + + protected abstract TTargetTask ConvertExtractRefreshTask(TSourceTask source, TTargetSchedule targetSchedule); + } +} diff --git a/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs b/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs index 9630f75..66d5793 100644 --- a/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs +++ b/src/Tableau.Migration/Content/Schedules/FrequencyDetails.cs @@ -17,8 +17,8 @@ using System; using System.Collections.Generic; -using System.Collections.Immutable; using System.Linq; +using System.Text; using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models.Responses; @@ -39,11 +39,22 @@ public FrequencyDetails(TimeOnly? startAt, TimeOnly? endAt, IEnumerable)intervals) { } + + public override string ToString() + { + var sb = new StringBuilder(); + + sb.AppendLine($"StartAt: {StartAt}"); + sb.AppendLine($"EndAt: {EndAt}"); + sb.AppendLine($"Interval Count {Intervals.Count()}:"); + sb.AppendLine(string.Join(Environment.NewLine, Intervals.Select(i => i.ToString()))); + return sb.ToString(); + } } } diff --git a/src/Tableau.Migration/Content/Schedules/IExtractRefreshTaskConverter.cs b/src/Tableau.Migration/Content/Schedules/IExtractRefreshTaskConverter.cs new file mode 100644 index 0000000..48685f3 --- /dev/null +++ b/src/Tableau.Migration/Content/Schedules/IExtractRefreshTaskConverter.cs @@ -0,0 +1,42 @@ +// +// 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; + +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/Schedules/ILoggerExtensions.cs b/src/Tableau.Migration/Content/Schedules/ILoggerExtensions.cs deleted file mode 100644 index 08c798c..0000000 --- a/src/Tableau.Migration/Content/Schedules/ILoggerExtensions.cs +++ /dev/null @@ -1,49 +0,0 @@ -// -// 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; -using System.Collections.Generic; -using Microsoft.Extensions.Logging; - -namespace Tableau.Migration.Content.Schedules -{ - internal static class ILoggerExtensions - { - public static bool LogIntervalsChanges( - this ILogger logger, - string localizedMessage, - Guid refreshTaskId, - IList oldIntervals, - IList newIntervals, - IEqualityComparer>? comparer = null) - { - comparer ??= ScheduleComparers.Intervals; - - if (comparer.Equals(oldIntervals, newIntervals)) - { - return false; - } - - logger.LogWarning( - localizedMessage, - refreshTaskId, - string.Join($",{Environment.NewLine}", oldIntervals), - string.Join($",{Environment.NewLine}", newIntervals)); - return true; - } - } -} diff --git a/src/Tableau.Migration/Content/Schedules/IScheduleValidator.cs b/src/Tableau.Migration/Content/Schedules/IScheduleValidator.cs new file mode 100644 index 0000000..c0d0b29 --- /dev/null +++ b/src/Tableau.Migration/Content/Schedules/IScheduleValidator.cs @@ -0,0 +1,25 @@ +// +// 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. +// + +namespace Tableau.Migration.Content.Schedules +{ + internal interface IScheduleValidator + where TSchedule : ISchedule + { + void Validate(TSchedule schedule); + } +} diff --git a/src/Tableau.Migration/Content/Schedules/IntervalValues.cs b/src/Tableau.Migration/Content/Schedules/IntervalValues.cs index a3a8743..edd4f69 100644 --- a/src/Tableau.Migration/Content/Schedules/IntervalValues.cs +++ b/src/Tableau.Migration/Content/Schedules/IntervalValues.cs @@ -15,6 +15,7 @@ // limitations under the License. // +using System; using System.Collections.Immutable; using System.Linq; using Tableau.Migration.Api.Rest.Models.Types; @@ -23,34 +24,44 @@ namespace Tableau.Migration.Content.Schedules { internal static class IntervalValues { - public static readonly IImmutableList HoursValues = new int?[] { 1, 2, 4, 6, 8, 12, 24 } - .Prepend(null) + public static readonly IImmutableList ServerHoursValues = new int[] { 1, 2, 4, 6, 8, 12, 24 } .ToImmutableList(); - public static readonly IImmutableList MinutesValues = new int?[] { 15, 30, 60 } - .Prepend(null) + public static readonly IImmutableList CloudHoursValues = new int[] { 2, 4, 6, 8, 12, 24 } .ToImmutableList(); - public static readonly IImmutableList WeekDaysValues = WeekDays.GetAll() - .Prepend(null) + public static readonly IImmutableList MinutesValues = new int[] { 15, 30, 60 } .ToImmutableList(); - public const string First = "First"; - public const string Second = "Second"; - public const string Third = "Third"; - public const string Fourth = "Fourth"; - public const string Fifth = "Fifth"; - public const string LastDay = "LastDay"; + public static readonly IImmutableList WeekDaysValues = WeekDays.GetAll() + .ToImmutableList(); + + public static readonly IImmutableList MonthDaysValues = Enumerable.Range(1, 31) + .Select(d => d.ToString()) + .Append(OccurrenceOfWeekday.First.ToString()) + .Append(OccurrenceOfWeekday.Second.ToString()) + .Append(OccurrenceOfWeekday.Third.ToString()) + .Append(OccurrenceOfWeekday.Fourth.ToString()) + .Append(OccurrenceOfWeekday.Fifth.ToString()) + .Append(OccurrenceOfWeekday.LastDay.ToString()) + .ToImmutableList(); - public static readonly IImmutableList MonthDaysValues = Enumerable.Range(1, 31) + public static readonly IImmutableList MonthDayNumberValues = Enumerable.Range(1, 31) .Select(d => d.ToString()) - .Prepend(null) - .Append(First) - .Append(Second) - .Append(Third) - .Append(Fourth) - .Append(Fifth) - .Append(LastDay) + .Append("LastDay") .ToImmutableList(); + + public static readonly IImmutableList MonthDayOccurrenceValues = Enum.GetNames(typeof(OccurrenceOfWeekday)) + .ToImmutableList(); + + internal enum OccurrenceOfWeekday + { + First, + Second, + Third, + Fourth, + Fifth, + LastDay + } } } diff --git a/src/Tableau.Migration/Content/Schedules/InvalidScheduleException.cs b/src/Tableau.Migration/Content/Schedules/InvalidScheduleException.cs new file mode 100644 index 0000000..625ff16 --- /dev/null +++ b/src/Tableau.Migration/Content/Schedules/InvalidScheduleException.cs @@ -0,0 +1,45 @@ +// +// 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; + +namespace Tableau.Migration.Content.Schedules +{ + /// + /// Exception thrown when a schedule is invalid. + /// + public class InvalidScheduleException : EquatableException + { + /// + /// Initializes a new instance of the class. + /// + public InvalidScheduleException() { } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public InvalidScheduleException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public InvalidScheduleException(string message, Exception innerException) : base(message, innerException) { } + } +} diff --git a/src/Tableau.Migration/Content/Schedules/ScheduleBase.cs b/src/Tableau.Migration/Content/Schedules/ScheduleBase.cs index ed2af3d..a8fa3ba 100644 --- a/src/Tableau.Migration/Content/Schedules/ScheduleBase.cs +++ b/src/Tableau.Migration/Content/Schedules/ScheduleBase.cs @@ -15,6 +15,8 @@ // limitations under the License. // +using System.Linq; +using System.Text; using Tableau.Migration.Api.Rest.Models; namespace Tableau.Migration.Content.Schedules @@ -29,7 +31,7 @@ internal abstract class ScheduleBase : ISchedule /// public string? NextRunAt { get; } - + /// /// Creates a new instance. /// @@ -52,7 +54,18 @@ public ScheduleBase(string frequency, IFrequencyDetails frequencyDetails, string { Frequency = Guard.AgainstNullEmptyOrWhiteSpace(frequency, nameof(frequency)); NextRunAt = nextRunAt; - FrequencyDetails = frequencyDetails; + FrequencyDetails = new FrequencyDetails(frequencyDetails.StartAt, frequencyDetails.EndAt, frequencyDetails.Intervals.ToList()); + } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"Frequency: {Frequency}"); + sb.AppendLine($"Next Run At: {NextRunAt}"); + sb.AppendLine($"Frequency Details:\n{FrequencyDetails}"); + + return sb.ToString(); + } } } diff --git a/src/Tableau.Migration/Content/Schedules/ScheduleComparers.cs b/src/Tableau.Migration/Content/Schedules/ScheduleComparers.cs index 5d25f0c..20d468a 100644 --- a/src/Tableau.Migration/Content/Schedules/ScheduleComparers.cs +++ b/src/Tableau.Migration/Content/Schedules/ScheduleComparers.cs @@ -15,10 +15,42 @@ // limitations under the License. // +using System; + namespace Tableau.Migration.Content.Schedules { - internal class ScheduleComparers + internal class ScheduleComparers : ComparerBase { - public static readonly IntervalComparer Intervals = new(); + public static readonly IntervalComparer IntervalsComparer = new(); + + protected override int CompareItems(ISchedule? x, ISchedule? y) + { + if (x is null && y is null) + return 0; + + if (x is null) + return -1; + + if (y is null) + return 1; + + var frequencyComparison = string.Compare(x.Frequency, y.Frequency, StringComparison.Ordinal); + if (frequencyComparison != 0) + return frequencyComparison; + + var startAtComparison = Nullable.Compare(x.FrequencyDetails.StartAt, y.FrequencyDetails.StartAt); + if (startAtComparison != 0) + return startAtComparison; + + var endAtComparison = Nullable.Compare(x.FrequencyDetails.EndAt, y.FrequencyDetails.EndAt); + if (endAtComparison != 0) + return endAtComparison; + + var intervalsComparison = IntervalsComparer.Compare(x.FrequencyDetails.Intervals, y.FrequencyDetails.Intervals); + if (intervalsComparison != 0) + return intervalsComparison; + + return string.Compare(x.NextRunAt, y.NextRunAt, StringComparison.Ordinal); + } } } diff --git a/src/Tableau.Migration/Content/Schedules/Server/ServerExtractRefreshTask.cs b/src/Tableau.Migration/Content/Schedules/Server/ServerExtractRefreshTask.cs index b1f5923..4902405 100644 --- a/src/Tableau.Migration/Content/Schedules/Server/ServerExtractRefreshTask.cs +++ b/src/Tableau.Migration/Content/Schedules/Server/ServerExtractRefreshTask.cs @@ -78,7 +78,7 @@ private static async Task CreateAsync( return new ServerExtractRefreshTask( response.Id, - taskFromCache== null ? string.Empty: taskFromCache.Type, + taskFromCache == null ? string.Empty : taskFromCache.Type, response.GetContentType(), content, schedule); diff --git a/src/Tableau.Migration/Content/Schedules/Server/ServerScheduleValidator.cs b/src/Tableau.Migration/Content/Schedules/Server/ServerScheduleValidator.cs new file mode 100644 index 0000000..62c8d94 --- /dev/null +++ b/src/Tableau.Migration/Content/Schedules/Server/ServerScheduleValidator.cs @@ -0,0 +1,255 @@ +// +// 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; +using System.Linq; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Content.Schedules.Server +{ + internal class ServerScheduleValidator : IScheduleValidator + { + private readonly ILogger _logger; + private readonly ISharedResourcesLocalizer _localizer; + + /// + /// Initializes a new instance of the class. + /// + /// The logger instance for logging. + /// The localizer instance for localization. + public ServerScheduleValidator(ILogger logger, ISharedResourcesLocalizer localizer) + { + _logger = logger; + _localizer = localizer; + } + + /// + /// Validates that the server schedule is valid based on the required of the "Create Server Schedule" RestAPI. + /// https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_jobs_tasks_and_schedules.htm#create_schedule + /// + /// Server schedule to validate. + public void Validate(IServerSchedule schedule) + { + // + // This validation process is not exhaustive. Additional checks may be required to ensure complete validity. + // + + Guard.AgainstNull(schedule, nameof(schedule)); + + switch (schedule.Frequency) + { + case ScheduleFrequencies.Hourly: + ValidateHourly(schedule); + break; + case ScheduleFrequencies.Daily: + ValidateDaily(schedule); + break; + case ScheduleFrequencies.Weekly: + ValidateWeekly(schedule); + break; + case ScheduleFrequencies.Monthly: + ValidateMonthly(schedule); + break; + + case null: + case "": + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.FrequencyNotSetError])); + break; + + default: + LogAndThrow(schedule, new ArgumentException(_localizer[SharedResourceKeys.FrequencyNotSupportedError])); + break; + } + } + private void LogAndThrow(IServerSchedule schedule, Exception exception) + { + var scheduleStr = schedule.ToString(); + + _logger.LogError(exception, _localizer[SharedResourceKeys.InvalidScheduleError, schedule]); + throw exception; + } + + private void ValidateHourly(IServerSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Hourly); + + ValidateStartTime(schedule); + + ValidateAtLeastOneIntervalWithHoursOrMinutes(schedule); + + ValidateEndTime(schedule); + + foreach (var interval in schedule.FrequencyDetails.Intervals) + { + if (interval.Hours.HasValue && interval.Minutes.HasValue) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.BothHoursAndMinutesIntervalError])); + } + + if (interval.Minutes.HasValue) + { + // Documentation states that only 15 and 30 minutes are supported. + // Testing shows that 60 minutes is also valid. + var minutes = interval.Minutes.Value; + if (minutes != 15 && minutes != 30 && minutes != 60) + { + _logger.LogWarning(_localizer[SharedResourceKeys.InvalidMinuteIntervalWarning], schedule); + } + } + } + } + + private void ValidateDaily(IServerSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Daily); + + ValidateStartTime(schedule); + + if (schedule.FrequencyDetails.Intervals.Count() > 0) + { + _logger.LogWarning(_localizer[SharedResourceKeys.IntervalsIgnoredWarning, schedule.Frequency]); + } + + } + + private void ValidateWeekly(IServerSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Weekly); + + ValidateStartTime(schedule); + + // Validate that there are between 1 and 7 intervals + if (!(schedule.FrequencyDetails.Intervals.Count() >= 1 && schedule.FrequencyDetails.Intervals.Count() <= 7)) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.WeeklyScheduleIntervalError])); + } + + // Validate that weekday intervals are valid + if (schedule.FrequencyDetails.Intervals.Any(interval => !interval.WeekDay.IsNullOrEmpty() && !IsValidWeekDay(interval.WeekDay))) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidWeekdayError])); + } + } + + private void ValidateMonthly(IServerSchedule schedule) + { + ValidateExpectedFrequency(schedule, ScheduleFrequencies.Monthly); + + ValidateStartTime(schedule); + + ValidateAtLeastOneInterval(schedule); + + // Checks that intervals with MonthDay set are either "LastDay" or a number between 1 and 31 + if (schedule.FrequencyDetails.Intervals + .Where(interval => interval.MonthDay != "LastDay") + .Any(interval => !int.TryParse(interval.MonthDay, out var monthDay) || monthDay < 1 || monthDay > 31)) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidMonthDayError])); + } + + } + + #region - Validation Helper Methods - + private void ValidateExpectedFrequency(IServerSchedule schedule, string expectedFrequency) + { + if (!ScheduleFrequencies.IsAMatch(schedule.Frequency, expectedFrequency)) + { + LogAndThrow(schedule, new ArgumentException(_localizer[SharedResourceKeys.FrequencyNotExpectedError, expectedFrequency])); + } + } + + + private void ValidateStartTime(IServerSchedule schedule) + { + // Docs say that start time can be anything, but API responds states it must be quarter hour. + // In reality, 5 minute increments are all that can be set via RestAPI and UI, and those work. + // No need to check anything. + + if (!schedule.FrequencyDetails.StartAt.HasValue) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, schedule.Frequency])); + } + } + + + private void ValidateEndTime(IServerSchedule schedule) + { + // Docs say that start time can be anything, but API responds states it must be quarter hour. + // In reality, 5 minute increments are all that can be set via RestAPI and UI, and those work. + // No need to check anything. + + if (!schedule.FrequencyDetails.EndAt.HasValue) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.ScheduleMustHaveEndAtTimeError, schedule.Frequency])); + } + } + + private void ValidateAtLeastOneInterval(IServerSchedule schedule) + { + // Validate that there is at least one interval + if (schedule.FrequencyDetails.Intervals.Count() <= 0) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.AtLeastOneIntervalError, schedule.Frequency])); + } + } + + private void ValidateAtLeastOneIntervalWithHoursOrMinutes(IServerSchedule schedule) + { + // Validate that there is at least one interval + if (schedule.FrequencyDetails.Intervals.Count(interval => interval.Hours.HasValue || interval.Minutes.HasValue) <= 0) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.AtLeastOneIntervalWithHourOrMinutesError, schedule.Frequency])); + } + + var intervals = schedule.FrequencyDetails.Intervals.Where(interval => interval.Hours.HasValue || interval.Minutes.HasValue); + foreach (var interval in intervals) + { + if (interval.Hours.HasValue && !IsValidHour(interval.Hours.Value)) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidHourlyIntervalForServerError])); + } + + if (interval.Minutes.HasValue && !IsValidMinutes(interval.Minutes.Value)) + { + LogAndThrow(schedule, new InvalidScheduleException(_localizer[SharedResourceKeys.InvalidMinuteIntervalError])); + } + } + } + + private bool IsValidWeekDay(string? weekDay) + { + return IntervalValues.WeekDaysValues.Contains(weekDay); + } + + private bool IsValidHour(int hour) + { + return IntervalValues.ServerHoursValues.Contains(hour); + } + + private bool IsValidMinutes(int minutes) + { + if (minutes == 15 || minutes == 30) + return true; + + return false; + } + + #endregion + } +} diff --git a/src/Tableau.Migration/Content/Schedules/ServerToCloudExtractRefreshTaskConverter.cs b/src/Tableau.Migration/Content/Schedules/ServerToCloudExtractRefreshTaskConverter.cs new file mode 100644 index 0000000..5ccea33 --- /dev/null +++ b/src/Tableau.Migration/Content/Schedules/ServerToCloudExtractRefreshTaskConverter.cs @@ -0,0 +1,271 @@ +// +// 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; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Content.Schedules +{ + /// + /// Converter for converting ServerExtractRefreshTask to CloudExtractRefreshTask. + /// + internal class ServerToCloudExtractRefreshTaskConverter : + ExtractRefreshTaskConverterBase + { + private readonly IScheduleValidator _serverScheduleValidator; + private readonly IScheduleValidator _cloudScheduleValidator; + + private ScheduleComparers _scheduleComparers = new ScheduleComparers(); + + /// + /// 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( + IScheduleValidator serverScheduleValidator, + IScheduleValidator cloudScheduleValidator, + ILogger logger, + ISharedResourcesLocalizer localizer) + : base(logger, localizer) + { + _serverScheduleValidator = serverScheduleValidator; + _cloudScheduleValidator = cloudScheduleValidator; + } + + /// + /// 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) + { + _serverScheduleValidator.Validate(sourceSchedule); + + var cloudSchedule = new CloudSchedule(sourceSchedule); + + var changeMessage = new StringBuilder(); + + switch (cloudSchedule.Frequency) + { + case ScheduleFrequencies.Hourly: + ConvertHourly(cloudSchedule, changeMessage); + break; + + case ScheduleFrequencies.Daily: + ConvertDaily(cloudSchedule, changeMessage); + break; + + case ScheduleFrequencies.Weekly: + ConvertWeekly(cloudSchedule, changeMessage); + break; + + case ScheduleFrequencies.Monthly: + // Monthly schedules require no conversion + break; + + case null: + case "": + throw new InvalidScheduleException(Localizer[SharedResourceKeys.FrequencyNotSetError]); + + default: + 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; + } + + /// + /// 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); + } + + private void ConvertHourly(CloudSchedule schedule, StringBuilder changeMessage) + { + if (schedule.FrequencyDetails.Intervals.Any(interval => interval.Hours.HasValue && interval.Hours.Value != 1)) + { + // 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]); + ConvertDaily(schedule, changeMessage); + return; + } + + // If schedule has no weekday intervals, add all weekdays + if (!schedule.FrequencyDetails.Intervals.Any(interval => !interval.WeekDay.IsNullOrEmpty())) + { + 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]); + } + + + 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()) + { + // 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; + } + } + + private void ConvertDaily(CloudSchedule schedule, StringBuilder changeMessage) + { + // Hours is not required + // If hours exist, only the last one is used + // if hours exist, it must be 2,4,6,8,12,24 + // if hours exist, and end time must be provided + // 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) + { + schedule.FrequencyDetails.Intervals = new List() { 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(); + if (hourInterval is not null) + { + if (!IsValidHour(hourInterval.Hours!.Value)) + { + 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!]); + } + } + + if (schedule.FrequencyDetails.Intervals.Any(interval => interval.Hours.HasValue)) + { + // We have intervals with hours + if (schedule.FrequencyDetails.EndAt is null) + { + // End is always required if hours are set + schedule.FrequencyDetails.EndAt = schedule.FrequencyDetails.StartAt; // Ending 24h after start + changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedAddedEndAtMessage]); + } + } + else + { + // We have no intervals with hours, remove end time + if (schedule.FrequencyDetails.EndAt is not null) + { + schedule.FrequencyDetails.EndAt = null; + changeMessage.AppendLine(Localizer[SharedResourceKeys.ScheduleUpdatedRemovedEndAtMessage]); + } + } + + // If schedule has no weekday intervals, add all weekdays + if (!schedule.FrequencyDetails.Intervals.Any(interval => !interval.WeekDay.IsNullOrEmpty())) + { + 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]); + return; + } + } + + private void ConvertWeekly(CloudSchedule schedule, StringBuilder changeMessage) + { + if (schedule.FrequencyDetails.Intervals.Count(interval => !interval.WeekDay.IsNullOrEmpty()) > 1) + { + // 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]); + ConvertDaily(schedule, changeMessage); + } + } + + public bool IsValidHour(int hour) + { + return IntervalValues.CloudHoursValues.Contains(hour); + } + + public int FindNearestValidHour(int hour) + { + return IntervalValues.CloudHoursValues + .OrderBy(h => Math.Abs(h - hour)) + .FirstOrDefault(); + } + } +} diff --git a/src/Tableau.Migration/Engine/Actions/PreflightAction.cs b/src/Tableau.Migration/Engine/Actions/PreflightAction.cs index af99b25..6fc6f5c 100644 --- a/src/Tableau.Migration/Engine/Actions/PreflightAction.cs +++ b/src/Tableau.Migration/Engine/Actions/PreflightAction.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2024, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -15,11 +15,14 @@ // limitations under the License. // +using System; using System.Threading; using System.Threading.Tasks; 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; namespace Tableau.Migration.Engine.Actions @@ -29,7 +32,9 @@ namespace Tableau.Migration.Engine.Actions /// public class PreflightAction : IMigrationAction { + private readonly IServiceProvider _services; private readonly PreflightOptions _options; + private readonly IMigrationHookRunner _hooks; private readonly IMigration _migration; private readonly ILogger _logger; private readonly ISharedResourcesLocalizer _localizer; @@ -37,15 +42,20 @@ public class PreflightAction : IMigrationAction /// /// Creates a new object. /// + /// The migration-scoped service provider. /// The preflight options. /// The current migration. + /// The hook runner /// A logger. /// A localizer. - public PreflightAction(IOptions options, IMigration migration, - ILogger logger, ISharedResourcesLocalizer localizer) + public PreflightAction(IServiceProvider services, IOptions options, + IMigration migration, IMigrationHookRunner hooks, + ILogger logger, ISharedResourcesLocalizer localizer) { + _services = services; _options = options.Value; _migration = migration; + _hooks = hooks; _logger = logger; _localizer = localizer; } @@ -98,9 +108,22 @@ public async Task ExecuteAsync(CancellationToken cancel) //TODO (W-12586258): Preflight action should validate that hook factories return the right type. //TODO (W-12586258): Preflight action should validate endpoints beyond simple initialization. + var preflightResultBuilder = new ResultBuilder(); + var settingsResult = await ManageSettingsAsync(cancel).ConfigureAwait(false); - return MigrationActionResult.FromResult(settingsResult); + if (!settingsResult.Success) + { + return MigrationActionResult.FromResult(settingsResult); + } + + // Call user-registered initializer last so the hook can rely that all engine initialization/validation is complete. + IInitializeMigrationHookResult initResult = InitializeMigrationHookResult.Succeeded(_services); + + initResult = await _hooks.ExecuteAsync(initResult, cancel).ConfigureAwait(false); + + preflightResultBuilder.Add(settingsResult, initResult); + return MigrationActionResult.FromResult(preflightResultBuilder.Build()); } } } diff --git a/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs index 846322a..70018fc 100644 --- a/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs @@ -78,7 +78,7 @@ Task> GetPermissionsAsync(Type type, IContentReference con /// The permissions of the content item. /// The cancellation token. /// The permissions result with . - Task> UpdatePermissionsAsync(IContentReference contentItem, IPermissions permissions, CancellationToken cancel) + Task UpdatePermissionsAsync(IContentReference contentItem, IPermissions permissions, CancellationToken cancel) where TContent : IPermissionsContent; /// @@ -89,7 +89,7 @@ Task> UpdatePermissionsAsync(IContentReference c /// The permissions of the content item. /// The cancellation token. /// The permissions result with . - Task> UpdatePermissionsAsync(Type type, IContentReference contentItem, + Task UpdatePermissionsAsync(Type type, IContentReference contentItem, IPermissions permissions, CancellationToken cancel); /// diff --git a/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs index 0c33355..ed3d448 100644 --- a/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs @@ -67,14 +67,14 @@ public async Task PublishBatchAsync(IEnumerable pub } /// - public async Task> UpdatePermissionsAsync(IContentReference contentItem, IPermissions permissions, CancellationToken cancel) + public async Task UpdatePermissionsAsync(IContentReference contentItem, IPermissions permissions, CancellationToken cancel) where TContent : IPermissionsContent { return await UpdatePermissionsAsync(typeof(TContent), contentItem, permissions, cancel).ConfigureAwait(false); } /// - public async Task> UpdatePermissionsAsync(Type type, IContentReference contentItem, IPermissions permissions, CancellationToken cancel) + public async Task UpdatePermissionsAsync(Type type, IContentReference contentItem, IPermissions permissions, CancellationToken cancel) { var apiClient = SiteApi.GetPermissionsApiClient(type); return await apiClient.UpdatePermissionsAsync(contentItem.Id, permissions, cancel).ConfigureAwait(false); diff --git a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs index eda24da..9dd5e80 100644 --- a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs +++ b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBase.cs @@ -44,7 +44,7 @@ public ContentFilterBase( { Localizer = localizer; Logger = logger; - _typeName = GetType().Name; + _typeName = GetType().GetFormattedName(); } /// diff --git a/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHook.cs b/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHook.cs new file mode 100644 index 0000000..8cab788 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHook.cs @@ -0,0 +1,25 @@ +// +// 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. +// + +namespace Tableau.Migration.Engine.Hooks +{ + /// + /// Interface representing a hook called before a migration begins. + /// + public interface IInitializeMigrationHook : IMigrationHook + { } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs b/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs new file mode 100644 index 0000000..413d404 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/IInitializeMigrationHookResult.cs @@ -0,0 +1,39 @@ +// +// 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; + +namespace Tableau.Migration.Engine.Hooks +{ + /// + /// object for an . + /// + public interface IInitializeMigrationHookResult : IResult + { + /// + /// Gets the migration-scoped service provider. + /// + IServiceProvider ScopedServices { get; } + + /// + /// Creates a new object with the given errors. + /// + /// The errors that caused the failure. + /// The new object. + IInitializeMigrationHookResult ToFailure(params Exception[] errors); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs b/src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs new file mode 100644 index 0000000..2fc0e32 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/InitializeMigrationHookResult.cs @@ -0,0 +1,38 @@ +// +// 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; +using System.Linq; + +namespace Tableau.Migration.Engine.Hooks +{ + internal record InitializeMigrationHookResult : Result, IInitializeMigrationHookResult + { + public IServiceProvider ScopedServices { get; } + + protected InitializeMigrationHookResult(bool success, IServiceProvider scopedServices, params Exception[] errors) + : base(success, errors) + { + ScopedServices = scopedServices; + } + + public static InitializeMigrationHookResult Succeeded(IServiceProvider scopedServices) => new(true, scopedServices); + + public IInitializeMigrationHookResult ToFailure(params Exception[] errors) + => new InitializeMigrationHookResult(false, ScopedServices, Errors.Concat(errors).ToArray()); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformer.cs deleted file mode 100644 index bd55975..0000000 --- a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformer.cs +++ /dev/null @@ -1,52 +0,0 @@ -// -// 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; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Tableau.Migration.Api.Rest.Models.Types; -using Tableau.Migration.Content.Schedules.Cloud; - - -using Tableau.Migration.Resources; - -namespace Tableau.Migration.Engine.Hooks.Transformers.Default -{ - /// - /// Transformer that changes extract refresh tasks to cloud supported ones. - /// - public class CloudIncrementalRefreshTransformer( - ISharedResourcesLocalizer localizer, - ILogger logger) - : ContentTransformerBase(localizer, logger) - { - - /// - public override Task TransformAsync( - ICloudExtractRefreshTask itemToTransform, - CancellationToken cancel) - { - // Convert Server Incremental Refresh to Cloud Incremental Refresh - if (itemToTransform.Type == ExtractRefreshType.ServerIncrementalRefresh) - { - itemToTransform.Type = ExtractRefreshType.CloudIncrementalRefresh; - } - - return Task.FromResult(itemToTransform); - } - } -} diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformer.cs deleted file mode 100644 index ad8a8ad..0000000 --- a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformer.cs +++ /dev/null @@ -1,74 +0,0 @@ -// -// 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; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Tableau.Migration.Content.Schedules; -using Tableau.Migration.Content.Schedules.Cloud; - - -using Tableau.Migration.Resources; - -namespace Tableau.Migration.Engine.Hooks.Transformers.Default -{ - /// - /// Transformer that changes extract refresh tasks to cloud supported ones. - /// - public class CloudScheduleCompatibilityTransformer - : ContentTransformerBase - where TWithSchedule : IWithSchedule - { - /// - /// Creates a new object. - /// - /// A string localizer. - /// The logger used to log messages. - public CloudScheduleCompatibilityTransformer( - ISharedResourcesLocalizer localizer, - ILogger> logger) - : base(localizer, logger) - { } - - /// - public override Task TransformAsync( - TWithSchedule itemToTransform, - CancellationToken cancel) - { - var currentFrequency = itemToTransform.Schedule.Frequency; - var currentIntervals = itemToTransform.Schedule.FrequencyDetails.Intervals; - - if (currentFrequency.IsCloudCompatible(currentIntervals)) - { - return Task.FromResult(itemToTransform); - } - - var newIntervals = currentFrequency.ToCloudCompatible(currentIntervals); - - if (Logger.LogIntervalsChanges( - Localizer[SharedResourceKeys.IntervalsChangedWarning], - itemToTransform.Id, - currentIntervals, - newIntervals)) - { - itemToTransform.Schedule.FrequencyDetails.Intervals = newIntervals; - } - - return Task.FromResult(itemToTransform); - } - } -} diff --git a/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs index 661df53..1a1aadb 100644 --- a/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs @@ -136,8 +136,6 @@ internal static IServiceCollection AddMigrationEngine(this IServiceCollection se services.AddScoped(); services.AddScoped(typeof(OwnershipTransformer<>)); services.AddScoped(); - services.AddScoped(typeof(CloudScheduleCompatibilityTransformer<>)); - services.AddScoped(); services.AddScoped(); services.AddScoped(typeof(EncryptExtractTransformer<>)); @@ -145,7 +143,7 @@ internal static IServiceCollection AddMigrationEngine(this IServiceCollection se services.AddScoped(); services.AddScoped(typeof(WorkbookReferenceTransformer<>)); services.AddScoped(); - + services.AddScoped(typeof(OwnerItemPostPublishHook<,>)); services.AddScoped(typeof(PermissionsItemPostPublishHook<,>)); services.AddScoped(typeof(TagItemPostPublishHook<,>)); diff --git a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestContentTypePartition.cs b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestContentTypePartition.cs index 7576ab3..cc837e7 100644 --- a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestContentTypePartition.cs +++ b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestContentTypePartition.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; namespace Tableau.Migration.Engine.Manifest { @@ -29,5 +30,17 @@ public interface IMigrationManifestContentTypePartition : IReadOnlyCollection Type ContentType { get; } + + /// + /// Gets the number of entries that are expected to be migrated for the partition's content type. + /// This value is based on total count returned by the source, and may change as batches are migrated. + /// + int ExpectedTotalCount { get; } + + /// + /// Gets the total counts for all the manifest entries in the partition by status. + /// + /// The total count of entries by status. + ImmutableDictionary GetStatusTotals(); } } diff --git a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryBuilder.cs b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryBuilder.cs index 6ec1f2b..530da0f 100644 --- a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryBuilder.cs +++ b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryBuilder.cs @@ -36,9 +36,10 @@ public interface IMigrationManifestEntryBuilder /// The result item to return. /// The source content items to create or link manifest entries for. /// A factory function to produce result items for, useful for linking a created manifest entry with the source content item it is associated with. + /// The updated expected total entry count. This should reflect the total number of content items that will be processed, regardless of paging or filtering. /// An immutable array of results returned by for each new entry. ImmutableArray CreateEntries(IReadOnlyCollection sourceContentItems, - Func resultFactory) + Func resultFactory, int expectedTotalCount) where TItem : IContentReference; /// @@ -59,6 +60,13 @@ Task MapEntriesAsync(IEnumerable s /// The old destination information. void DestinationInfoUpdated(IMigrationManifestEntryEditor entry, IContentReference? oldDestinationInfo); + /// + /// Registers a status change update for an entry to update totals. + /// + /// The manifest entry that was updated. + /// The old status. + void StatusUpdated(IMigrationManifestEntryEditor entry, MigrationManifestEntryStatus oldStatus); + /// /// Registers that migration failed for a content item for logging. /// diff --git a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs index baecf64..b8705b4 100644 --- a/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs +++ b/src/Tableau.Migration/Engine/Manifest/IMigrationManifestEntryEditor.cs @@ -25,6 +25,12 @@ namespace Tableau.Migration.Engine.Manifest /// public interface IMigrationManifestEntryEditor : IMigrationManifestEntry { + /// + /// Resets the status to . + /// + /// The current entry editor, for fluent API usage. + IMigrationManifestEntryEditor ResetStatus(); + /// /// Sets the intended mapped destination location to the manifest entry. /// Clears the information if the mapped location is different. diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs index 500fe80..1346a69 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifest.cs @@ -42,7 +42,7 @@ public class MigrationManifest : IMigrationManifestEditor /// /// The latest manifest version number. /// - public const uint LatestManifestVersion = 3; + public const uint LatestManifestVersion = 4; /// /// Creates a new object. diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs index 2f620b6..0ed5f0b 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestContentTypePartition.cs @@ -47,6 +47,8 @@ public class MigrationManifestContentTypePartition : IMigrationManifestContentTy private readonly ConcurrentDictionary _entriesByMappedLocation = new(); private readonly ConcurrentDictionary _entriesByDestinationId = new(); + private readonly ConcurrentDictionary _statusTotals = new(); + /// /// Creates a new object. /// @@ -60,6 +62,16 @@ public MigrationManifestContentTypePartition(Type type, _localizer = localizer; _logger = logger; + + foreach(var status in Enum.GetValues()) + { + _statusTotals[status] = 0; + } + } + + private void IncrementStatus(MigrationManifestEntryStatus status) + { + _statusTotals.AddOrUpdate(status, 1, (k, v) => v + 1); } #region - IMigrationManifestContentTypePartitionEditor Implementation - @@ -95,8 +107,12 @@ public IMigrationManifestContentTypePartitionEditor CreateEntries(IReadOnlyColle { _entriesBySourceContentUrl.Add(clonedEntry.Source.ContentUrl, clonedEntry); } + + IncrementStatus(clonedEntry.Status); } + ExpectedTotalCount = Count; + return this; } @@ -109,7 +125,7 @@ public IMigrationManifestEntryBuilder GetEntryBuilder(int totalItemCount) /// public ImmutableArray CreateEntries(IReadOnlyCollection sourceContentItems, - Func resultFactory) + Func resultFactory, int expectedTotalCount) where TItem : IContentReference { var results = ImmutableArray.CreateBuilder(sourceContentItems.Count); @@ -126,6 +142,8 @@ public ImmutableArray CreateEntries(IReadOnlyCo { _entriesBySourceContentUrl.Add(sourceItem.ContentUrl, manifestEntry); } + + IncrementStatus(manifestEntry.Status); } else { @@ -154,6 +172,9 @@ public ImmutableArray CreateEntries(IReadOnlyCo results.Add(result); } + //Set expected total count, but it should never be less than the actual count. + ExpectedTotalCount = Count > expectedTotalCount ? Count : expectedTotalCount; + return results.ToImmutable(); } @@ -194,12 +215,19 @@ public void DestinationInfoUpdated(IMigrationManifestEntryEditor entry, IContent _entriesByMappedLocation[entry.MappedLocation] = entry; } + /// + public void StatusUpdated(IMigrationManifestEntryEditor entry, MigrationManifestEntryStatus oldStatus) + { + _statusTotals.AddOrUpdate(oldStatus, 0, (k, v) => v - 1); + IncrementStatus(entry.Status); + } + /// public void MigrationFailed(IMigrationManifestEntryEditor entry) { foreach (var error in entry.Errors) { - _logger.LogError(_localizer[SharedResourceKeys.MigrationItemErrorLogMessage], ContentType, entry.Source.Location, error); + _logger.LogError(_localizer[SharedResourceKeys.MigrationItemErrorLogMessage], ContentType, entry.Source.Location, error, error.Data.GetContentsAsString()); } } @@ -210,6 +238,14 @@ public void MigrationFailed(IMigrationManifestEntryEditor entry) /// public Type ContentType { get; } + /// + public int ExpectedTotalCount { get; private set; } + + /// + public ImmutableDictionary GetStatusTotals() + // ConcurrentDictionary _statusTotals.ToArray().ToImmutableDictionary(); + #region - IEquatable Implementation - /// diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs index 9b9f044..f2ddfdb 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntry.cs @@ -49,28 +49,28 @@ public MigrationManifestEntry(IMigrationManifestEntryBuilder entryBuilder, /// Creates a new object. /// /// The entry builder to notify with changes. - /// An entry from a previous migration manifest to copy values from. + /// An entry from to copy values from. public MigrationManifestEntry(IMigrationManifestEntryBuilder entryBuilder, - IMigrationManifestEntry previousMigrationEntry) + IMigrationManifestEntry copy) { _entryBuilder = entryBuilder; - Source = previousMigrationEntry.Source; - MappedLocation = previousMigrationEntry.MappedLocation; - Status = previousMigrationEntry.Status; - Destination = previousMigrationEntry.Destination; - HasMigrated = previousMigrationEntry.HasMigrated; - _errors = previousMigrationEntry.Errors.ToImmutableArray(); + Source = copy.Source; + MappedLocation = copy.MappedLocation; + _status = copy.Status; + Destination = copy.Destination; + HasMigrated = copy.HasMigrated; + _errors = copy.Errors.ToImmutableArray(); } /// /// Creates a new object. /// /// The entry builder to notify with changes. - /// An entry from a previous migration manifest to copy values from. + /// An entry to copy values from. /// The content item's updated source information, as a stub. public MigrationManifestEntry(IMigrationManifestEntryBuilder entryBuilder, - IMigrationManifestEntry previousMigrationEntry, ContentReferenceStub sourceReference) - : this(entryBuilder, previousMigrationEntry) + IMigrationManifestEntry copy, ContentReferenceStub sourceReference) + : this(entryBuilder, copy) { Source = sourceReference; } @@ -98,7 +98,21 @@ public virtual IContentReference? Destination private IContentReference? _destination; /// - public virtual MigrationManifestEntryStatus Status { get; private set; } + public virtual MigrationManifestEntryStatus Status + { + get => _status; + set + { + var oldStatus = _status; + _status = value; + + if(oldStatus != _status) + { + _entryBuilder.StatusUpdated(this, oldStatus); + } + } + } + private MigrationManifestEntryStatus _status; /// public virtual bool HasMigrated { get; private set; } @@ -167,6 +181,17 @@ public override int GetHashCode() #region - IMigrationManifestEntryEditor Implementation - + /// + public virtual IMigrationManifestEntryEditor ResetStatus() + { + // Mapped location, destination info, and long-term migrated flag are not reset between migrations. + + _errors = ImmutableArray.Empty; + Status = MigrationManifestEntryStatus.Pending; + + return this; + } + /// public virtual IMigrationManifestEntryEditor MapToDestination(ContentLocation destinationLocation) { diff --git a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs index 76d27d4..227c679 100644 --- a/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs +++ b/src/Tableau.Migration/Engine/Manifest/MigrationManifestEntryCollection.cs @@ -46,7 +46,18 @@ public MigrationManifestEntryCollection(ISharedResourcesLocalizer localizer, ILo _localizer = localizer; _partitionLogger = loggerFactory.CreateLogger(); - copy?.CopyTo(this); + if(copy is not null) + { + copy.CopyTo(this); + + foreach (var partition in _partitions) + { + foreach(var entry in partition) + { + entry.ResetStatus(); + } + } + } } #region - IMigrationManifestEntryCollection Implementation - diff --git a/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs b/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs index b6dc936..acfa4d1 100644 --- a/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs +++ b/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs @@ -23,7 +23,6 @@ using Microsoft.Extensions.DependencyInjection; using Tableau.Migration.Api.Simulation; 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; @@ -165,8 +164,6 @@ public IMigrationPlanBuilder AppendDefaultExtensions() Transformers.Add(typeof(OwnershipTransformer<>), GetPublishTypesByInterface()); Transformers.Add(); Transformers.Add(); - Transformers.Add(); - Transformers.Add(typeof(CloudScheduleCompatibilityTransformer<>), GetPublishTypesByInterface>()); Transformers.Add(typeof(WorkbookReferenceTransformer<>), GetPublishTypesByInterface()); Transformers.Add(); Transformers.Add(typeof(EncryptExtractTransformer<>), GetPublishTypesByInterface()); diff --git a/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs b/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs index d57481e..1dd4b36 100644 --- a/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs +++ b/src/Tableau.Migration/Engine/Migrators/ContentMigrator.cs @@ -97,7 +97,7 @@ public async Task MigrateAsync(CancellationToken cancel) var manifestEntryBuilder = manifestPartition.GetEntryBuilder(sourcePage.TotalCount); while (!sourcePage.Value.IsNullOrEmpty()) { - var batchItems = manifestEntryBuilder.CreateEntries(sourcePage.Value, BuildMigrationItem); + var batchItems = manifestEntryBuilder.CreateEntries(sourcePage.Value, BuildMigrationItem, sourcePage.TotalCount); cancel.ThrowIfCancellationRequested(); diff --git a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs index 754d006..16ebb80 100644 --- a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs +++ b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineRunner.cs @@ -29,6 +29,11 @@ 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. /// @@ -45,6 +50,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); @@ -53,10 +59,13 @@ public async Task ExecuteAsync(IMigrationPipeline pipeline, Cancellatio //Exit pipeline early if requested by the action or a hook. if (actionResult.PerformNextAction == false) { + CurrentAction = null; return actionResult; } } + CurrentAction = null; + return resultBuilder.Build(); } } diff --git a/src/Tableau.Migration/EquatableException.cs b/src/Tableau.Migration/EquatableException.cs new file mode 100644 index 0000000..12cfb78 --- /dev/null +++ b/src/Tableau.Migration/EquatableException.cs @@ -0,0 +1,78 @@ +// +// 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; + +namespace Tableau.Migration +{ + /// + /// A base class for custom exceptions that implements IEquatable to allow for equality comparison. + /// + /// The type of the derived exception class. + public abstract class EquatableException : Exception, IEquatable where T : EquatableException + { + /// + /// Initializes a new instance of the class. + /// + protected EquatableException() : base() { } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + protected EquatableException(string message) : base(message) { } + + /// + /// Initializes a new instance of the class with a specified error message and a reference to the inner exception that is the cause of this exception. + /// + /// The error message that explains the reason for the exception. + /// The exception that is the cause of the current exception. + public EquatableException(string message, Exception innerException) : base(message, innerException) { } + + /// + public override bool Equals(object? obj) + { + return Equals(obj as T); + } + + /// + 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 override int GetHashCode() + { + return HashCode.Combine(GetType(), Message); + } + } +} diff --git a/src/Tableau.Migration/HttpHeaderExtensions.cs b/src/Tableau.Migration/HttpHeaderExtensions.cs new file mode 100644 index 0000000..6efd583 --- /dev/null +++ b/src/Tableau.Migration/HttpHeaderExtensions.cs @@ -0,0 +1,35 @@ +// +// 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.Linq; +using System.Net.Http.Headers; + +namespace Tableau.Migration +{ + static internal class HttpHeaderExtensions + { + public static string? GetCorrelationId(this HttpHeaders header) + { + if (header != null && header.TryGetValues(Constants.REQUEST_CORRELATION_ID_HEADER, out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } + } +} diff --git a/src/Tableau.Migration/IDictionaryExtensions.cs b/src/Tableau.Migration/IDictionaryExtensions.cs new file mode 100644 index 0000000..724c2b4 --- /dev/null +++ b/src/Tableau.Migration/IDictionaryExtensions.cs @@ -0,0 +1,59 @@ +// +// 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; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Tableau.Migration +{ + internal static class IDictionaryExtensions + { + public static string GetContentsAsString(this IDictionary dictionary) + { + if (dictionary.Count == 0) + { + return "Empty"; + } + + StringBuilder sb = new StringBuilder(); + foreach (var kvp in dictionary) + { + sb.AppendLine($"Key: {kvp.Key}, Value: {kvp.Value}"); + } + return sb.ToString(); + } + + public static string GetContentsAsString(this IDictionary dictionary) + { + if (dictionary.Count == 0) + { + return "Empty"; + } + + StringBuilder sb = new StringBuilder(); + foreach (DictionaryEntry entry in dictionary) + { + sb.AppendLine($"Key: {entry.Key}, Value: {entry.Value}"); + } + return sb.ToString(); + } + } +} diff --git a/src/Tableau.Migration/Interop/Hooks/ISyncInitializeMigrationHook.cs b/src/Tableau.Migration/Interop/Hooks/ISyncInitializeMigrationHook.cs new file mode 100644 index 0000000..ca31be1 --- /dev/null +++ b/src/Tableau.Migration/Interop/Hooks/ISyncInitializeMigrationHook.cs @@ -0,0 +1,38 @@ +// +// 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 Tableau.Migration.Engine.Hooks; + +namespace Tableau.Migration.Interop.Hooks +{ + /// + /// Interface representing a hook called synchronously before a migration begins. + /// + public interface ISyncInitializeMigrationHook : ISyncMigrationHook, IInitializeMigrationHook + { + /// + /// Executes a hook callback. + /// + /// The input context from the migration engine or previous hook. + /// + /// The context, + /// potentially modified to pass on to the next hook or migration engine, + /// or null to continue passing the same context as . + /// + new IInitializeMigrationHookResult? Execute(IInitializeMigrationHookResult ctx); + } +} diff --git a/src/Tableau.Migration/Interop/InteropHelper.cs b/src/Tableau.Migration/Interop/InteropHelper.cs index 5055392..e01899d 100644 --- a/src/Tableau.Migration/Interop/InteropHelper.cs +++ b/src/Tableau.Migration/Interop/InteropHelper.cs @@ -122,6 +122,29 @@ public static IEnumerable GetProperties(Type type) return properties.Select(p => p.Name); } + /// + /// Gets the fields of a class. + /// + /// The type to get fields from. + /// The field names. + public static IEnumerable GetFields() + => GetFields(typeof(T)); + + /// + /// Gets the fields of a class. + /// + /// The type to get fields from. + /// The field names. + public static IEnumerable GetFields(Type type) + { + if (type.ContainsGenericParameters) + { + return GetFields(MakeSampleGenericType(type)); + } + + return type.GetFields().Select(p => p.Name); + } + /// /// Gets all the names and values of an enumeration. /// diff --git a/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs b/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs index e22d638..17116e9 100644 --- a/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs +++ b/src/Tableau.Migration/JsonConverters/RestExceptionJsonConverter.cs @@ -44,6 +44,7 @@ public override RestException Read(ref Utf8JsonReader reader, Type typeToConvert HttpMethod? httpMethod = null; Uri? requestUri = null; string? code = null; + string? correlationId = null; string? detail = null; string? summary = null; string? exceptionMessage = null; @@ -77,6 +78,10 @@ public override RestException Read(ref Utf8JsonReader reader, Type typeToConvert code = reader.GetString(); break; + case nameof(RestException.CorrelationId): + correlationId = reader.GetString(); + break; + case nameof(RestException.Detail): detail = reader.GetString(); break; @@ -99,7 +104,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, new Error { Code = code, Detail = detail, Summary = summary }, exceptionMessage); + return new RestException(httpMethod, requestUri, correlationId, new Error { Code = code, Detail = detail, Summary = summary }, exceptionMessage); } /// @@ -127,6 +132,11 @@ public override void Write(Utf8JsonWriter writer, RestException value, JsonSeria writer.WriteString(nameof(RestException.Code), value.Code); } + if (value.CorrelationId != null) + { + writer.WriteString(nameof(RestException.CorrelationId), value.CorrelationId); + } + if (value.Detail != null) { writer.WriteString(nameof(RestException.Detail), value.Detail); diff --git a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs index bfa66b4..b59f05f 100644 --- a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs +++ b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableManifestEntry.cs @@ -48,7 +48,7 @@ public class SerializableManifestEntry : IMigrationManifestEntry /// /// Gets or sets the status of the migration for this entry. /// - public int Status { get; set; } + public string? Status { get; set; } /// /// Gets or sets a value indicating whether the content has been migrated. @@ -74,7 +74,7 @@ internal SerializableManifestEntry(IMigrationManifestEntry entry) Source = new SerializableContentReference(entry.Source); MappedLocation = new SerializableContentLocation(entry.MappedLocation); Destination = entry.Destination == null ? null : new SerializableContentReference(entry.Destination); - Status = (int)entry.Status; + Status = entry.Status.ToString(); HasMigrated = entry.HasMigrated; Errors = entry.Errors.Select(e => new SerializableException(e)).ToList(); @@ -86,7 +86,7 @@ internal SerializableManifestEntry(IMigrationManifestEntry entry) IContentReference? IMigrationManifestEntry.Destination => Destination?.AsContentReferenceStub(); - MigrationManifestEntryStatus IMigrationManifestEntry.Status => (MigrationManifestEntryStatus)Status; + MigrationManifestEntryStatus IMigrationManifestEntry.Status => (MigrationManifestEntryStatus)Enum.Parse(typeof(MigrationManifestEntryStatus), Status!); bool IMigrationManifestEntry.HasMigrated => HasMigrated; diff --git a/src/Tableau.Migration/Net/DefaultHttpResponseMessage.cs b/src/Tableau.Migration/Net/DefaultHttpResponseMessage.cs index 2517de7..7726b47 100644 --- a/src/Tableau.Migration/Net/DefaultHttpResponseMessage.cs +++ b/src/Tableau.Migration/Net/DefaultHttpResponseMessage.cs @@ -19,6 +19,7 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using Tableau.Migration.Net.Rest; namespace Tableau.Migration.Net { @@ -54,7 +55,17 @@ public DefaultHttpResponseMessage(HttpResponseMessage response) public IHttpResponseMessage EnsureSuccessStatusCode() { - _response.EnsureSuccessStatusCode(); + try + { + _response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException ex) + { + // Adds the request to the exception data. + ex.Data.Add("RequestMessage", RequestMessage?.ToSanitizedString()); + throw; + } + return this; } diff --git a/src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHandler.cs b/src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHandler.cs new file mode 100644 index 0000000..a4f1b71 --- /dev/null +++ b/src/Tableau.Migration/Net/Handlers/RequestCorrelationIdHandler.cs @@ -0,0 +1,58 @@ +// +// 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; +using System.Diagnostics; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration; + +/// +/// A handler that adds a unique request ID to the HTTP request and response headers. +/// +public class RequestCorrelationIdHandler : DelegatingHandler +{ + /// + /// Sends an HTTP request with a unique request ID and adds the same ID to the response headers. + /// + /// The HTTP request message. + /// A cancellation token to cancel the operation. + /// The HTTP response message. + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var correlationId = Guid.NewGuid().ToString(); + request.Headers.Add(Constants.REQUEST_CORRELATION_ID_HEADER, correlationId); + + // Start a new activity with the correlation ID + var activity = new Activity("HttpRequest"); + activity.AddTag(Constants.REQUEST_CORRELATION_ID_HEADER, correlationId); + activity.Start(); + + // Set the current activity + Activity.Current = activity; + + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + + response.Headers.Add(Constants.REQUEST_CORRELATION_ID_HEADER, correlationId); + + // Stop the activity + activity.Stop(); + + return response; + } +} diff --git a/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs index 3b9d87e..e614a74 100644 --- a/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs @@ -60,6 +60,7 @@ internal static IServiceCollection AddHttpServices(this IServiceCollection servi .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 @@ -102,7 +103,7 @@ internal static IServiceCollection AddHttpServices(this IServiceCollection servi builder.Build(pipelineBuilder, options, ref onPipelineDisposed); - if(onPipelineDisposed is not null) + if (onPipelineDisposed is not null) { ctx.OnPipelineDisposed(onPipelineDisposed); } @@ -112,6 +113,7 @@ internal static IServiceCollection AddHttpServices(this IServiceCollection servi httpClientBuilder .AddHttpMessageHandler() .AddHttpMessageHandler() + .AddHttpMessageHandler() .AddHttpMessageHandler(); //Must be last for simulation to function. //Bootstrap and scope state tracking services. diff --git a/src/Tableau.Migration/Net/NetworkTraceLogger.cs b/src/Tableau.Migration/Net/NetworkTraceLogger.cs index 0b49a85..ce17d72 100644 --- a/src/Tableau.Migration/Net/NetworkTraceLogger.cs +++ b/src/Tableau.Migration/Net/NetworkTraceLogger.cs @@ -48,7 +48,8 @@ internal class NetworkTraceLogger "Expires", "Last-Modified", "Status", - "User-Agent" + "User-Agent", + Constants.REQUEST_CORRELATION_ID_HEADER }, StringComparer.OrdinalIgnoreCase); @@ -88,12 +89,16 @@ await AddHttpContentDetailsAsync( cancel) .ConfigureAwait(false); + var correlationId = response.Headers.GetCorrelationId(); + _logger.LogInformation( _localizer[SharedResourceKeys.NetworkTraceLogMessage], request.Method, request.RequestUri, response.StatusCode, - detailsBuilder.ToString()); + correlationId, + detailsBuilder.ToString() + ); } public async Task WriteNetworkExceptionLogsAsync( @@ -114,11 +119,14 @@ await AddHttpContentDetailsAsync( AddHttpExceptionDetails(detailsBuilder, exception); + var correlationId = request.Headers.GetCorrelationId(); + _logger.LogError( _localizer[SharedResourceKeys.NetworkTraceExceptionLogMessage], request.Method, request.RequestUri, exception.Message, + correlationId, detailsBuilder.ToString()); } @@ -256,7 +264,7 @@ async Task WriteContentAsync(HttpContent contentToWrite) } else { - if(contentToWrite.Headers.ContentLength > int.MaxValue) + if (contentToWrite.Headers.ContentLength > int.MaxValue) { detailsBuilder.AppendLine(_localizer[SharedResourceKeys.NetworkTraceTooLargeDetails]); } @@ -272,7 +280,7 @@ async Task WriteContentAsync(HttpContent contentToWrite) else { detailsBuilder.AppendLine(_localizer[SharedResourceKeys.NetworkTraceNotDisplayedDetails]); - } + } } if (content is MultipartFormDataContent multipartContent) diff --git a/src/Tableau.Migration/Net/Rest/HttpRequestMessageExtensions.cs b/src/Tableau.Migration/Net/Rest/HttpRequestMessageExtensions.cs index 9fc933d..12a7257 100644 --- a/src/Tableau.Migration/Net/Rest/HttpRequestMessageExtensions.cs +++ b/src/Tableau.Migration/Net/Rest/HttpRequestMessageExtensions.cs @@ -15,7 +15,10 @@ // limitations under the License. // +using System; using System.Net.Http; +using System.Text; +using System.Threading.Tasks; namespace Tableau.Migration.Net.Rest { @@ -30,5 +33,22 @@ public static void SetRestAuthenticationToken(this HttpRequestMessage request, s request.Headers.TryAddWithoutValidation(RestHeaders.AuthenticationToken, token); } } + + /// + /// The default ToString() method for HttpRequestMessage includes the X-Tableau-Auth header, which is a security risk. + /// + public static string ToSanitizedString(this HttpRequestMessage request) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + + var sb = new StringBuilder(); + + sb.AppendLine($"Method: {request.Method}"); + sb.AppendLine($"RequestUri: {request.RequestUri}"); + sb.AppendLine($"Version: {request.Version}"); + sb.AppendLine($"Content: {request.Content?.ReadAsStringAsync().GetAwaiter().GetResult() ?? "null"}"); + + return sb.ToString(); + } } } diff --git a/src/Tableau.Migration/Resources/SharedResourceKeys.cs b/src/Tableau.Migration/Resources/SharedResourceKeys.cs index 941ea9c..ee75551 100644 --- a/src/Tableau.Migration/Resources/SharedResourceKeys.cs +++ b/src/Tableau.Migration/Resources/SharedResourceKeys.cs @@ -129,6 +129,8 @@ internal static class SharedResourceKeys public const string IntervalsChangedWarning = "IntervalsChangedWarning"; + public const string IntervalNotChangedDebugMessage = "IntervalNotChangedDebugMessage"; + public const string Found = "Found"; public const string NotFound = "NotFound"; @@ -144,5 +146,75 @@ internal static class SharedResourceKeys public const string DuplicateContentTypeConfigurationMessage = "DuplicateContentTypeConfigurationMessage"; public const string UnknownExtractRefreshContentTypeWarning = "UnknownExtractRefreshContentTypeWarning"; + + public const string FrequencyNotSetError = "FrequencyNotSetError"; + + public const string FrequencyNotSupportedError = "FrequencyNotSupportedError"; + + public const string InvalidScheduleError = "InvalidScheduleError"; + + public const string AtLeastOneIntervalError = "AtLeastOneIntervalError"; + + public const string AtLeastOneIntervalWithHourOrMinutesError = "AtLeastOneIntervalWithHourOrMinutesError"; + + public const string BothHoursAndMinutesIntervalError = "BothHoursAndMinutesIntervalError"; + + public const string InvalidHourlyIntervalForServerError = "InvalidHourlyIntervalForServerError"; + + public const string InvalidHourlyIntervalForCloudError = "InvalidHourlyIntervalForCloudError"; + + public const string InvalidMinuteIntervalError = "InvalidMinuteIntervalError"; + + public const string InvalidMinuteIntervalWarning = "InvalidMinuteIntervalWarning"; + + public const string IntervalsIgnoredWarning = "IntervalsIgnoredWarning"; + + public const string WeeklyScheduleIntervalError = "WeeklyScheduleIntervalError"; + + public const string InvalidWeekdayError = "InvalidWeekdayError"; + + public const string ScheduleMustHaveStartAtTimeError = "ScheduleMustHaveStartAtTimeError"; + + public const string ScheduleMustHaveEndAtTimeError = "ScheduleMustHaveEndAtTimeError"; + + public const string ScheduleMustNotHaveEndAtTimeError = "ScheduleMustNotHaveEndAtTimeError"; + + public const string InvalidMonthDayError = "InvalidMonthDayError"; + + public const string FrequencyNotExpectedError = "FrequencyNotExpectedError"; + + public const string AtLeastOneValidWeekdayError = "AtLeastOneValidWeekdayError"; + + public const string AtLeastOneValidMonthDayError = "AtLeastOneValidMonthDayError"; + + public const string ScheduleShouldOnlyHaveOneHoursIntervalWarning = "ScheduleShouldOnlyHaveOneHoursIntervalWarning"; + + public const string ScheduleMustHaveExactlyOneWeekdayIntervalError = "ScheduleMustHaveExactlyOneWeekdayIntervalError"; + + public const string ScheduleMustOnlyHaveOneIntervalWithLastDayError = "ScheduleMustOnlyHaveOneIntervalWithLastDayError"; + + public const string InvalidScheduleForMonthlyError = "InvalidScheduleForMonthlyError"; + + public const string ExactlyOneHourOrMinutesError = "ExactlyOneHourOrMinutesError"; + + public const string IntervalMustBe1HourOr60MinError = "IntervalMustBe1HourOr60MinError"; + + public const string StartEndTimeDifferenceError = "StartEndTimeDifferenceError"; + + public const string ReplacingHourlyIntervalMessage = "ReplacingHourlyIntevalMessage"; + + public const string ScheduleUpdateFailedError = "ScheduleUpdateFailedError"; + + public const string ScheduleUpdatedMessage = "ScheduleUpdatedMessage"; + + public const string ScheduleUpdatedFrequencyToDailyMessage = "ScheduleUpdatedFrequencyToDailyMessage"; + + public const string ScheduleUpdatedAddedWeekdayMessage = "ScheduleUpdatedAddedWeekdayMessage"; + + public const string ScheduleUpdatedAddedEndAtMessage = "ScheduleUpdatedAddedEndAtMessage"; + + public const string ScheduleUpdatedRemovedEndAtMessage = "ScheduleUpdatedRemovedEndAtMessage"; + + public const string ScheduleUpdatedHoursMessage = "ScheduleUpdatedHoursMessage"; } } diff --git a/src/Tableau.Migration/Resources/SharedResources.resx b/src/Tableau.Migration/Resources/SharedResources.resx index a0c186e..dd1a13c 100644 --- a/src/Tableau.Migration/Resources/SharedResources.resx +++ b/src/Tableau.Migration/Resources/SharedResources.resx @@ -163,13 +163,13 @@ An error occurred during migration. Error: {Exception} - An error occurred migrating {ContentType} item "{SourcePath}". Error: {Exception} + An error occurred migrating {ContentType} item "{SourcePath}". Error: {Exception}. Data: {Data} - HTTP {Method} {RequestUri} failed. Error: "{ErrorMessage}". Details: {Details} + HTTP {Method} {RequestUri} failed. Error: "{ErrorMessage}". Correlation ID: {RequestId}. Details: {Details}. - HTTP {Method} {RequestUri} responded {ResponseStatus}. Details: {Details} + HTTP {Method} {RequestUri} responded {ResponseStatus}. Correlation ID: {RequestId}. Details: {Details}. <Not Displayed> @@ -192,9 +192,10 @@ An error was returned from the Tableau API: URL: {0} {1} -Code: {2} -Summary: {3} -Detail: {4} +Correlation Id: {2} +Code: {3} +Summary: {4} +Detail: {5} # Exception @@ -261,8 +262,14 @@ Detail: {4} The following intervals were changed for Extract Refresh Task ID {RefreshTaskId} due to cloud restrictions. +Frequency: {Frequency} Server: {OldIntervals} Cloud: {NewIntervals} + + + The following intervals are compatible with cloud restrictions. Extract Refresh Task ID {RefreshTaskId} was not changed. +Frequency: {Frequency} +Intervals: {Intervals} The owner with ID {0} was not found. @@ -318,4 +325,109 @@ Owner with ID {OwnerID}: {owner} The extract refresh task with ID {TaskId} references an unknown content type. The task will be ignored. + + {0} schedule must have at least one interval. + + + {0} schedule must have at least one interval with hours or minutes set. + + + Cannot specify both hours and minutes intervals. + + + Frequency is not set. + + + Frequency is not supported + + + {0} schedule should not have intervals. All intervals will be ignored. + + + Invalid hours interval specified. Must be 1, 2, 4, 6, 8, or 12. + + + Invalid hours interval specified. Must be 2, 4, 6, 8, 12, or 24. + + + Minutes must be 15 or 30. + + + Minutes should be 15 or 30 for best compatibility. {schedule} + + + Invalid Schedule: {0} + + + Invalid weekday specified. Must be Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, or Saturday. + + + Weekly schedule must have between 1 and 7 intervals. + + + {0} schedule must have a StartAt time. + + + {0} schedule must have an EndAt time. + + + {0} schedule must not have an EndAt time. + + + Invalid month day specified. Must be a number between 1 and 31 or 'LastDay'. + + + Frequency is not {0}. + + + {0} schedules must have at least one weekDay interval. Week day must be Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, or Saturday. + + + {0} schedule must have at least one interval with MonthDay set. + + + {0} schedule should only have one hours interval. Last one will be used. + + + {0} schedule must have exactly one weekday interval. + + + {0} schedule can only have one interval with LastDay. + + + Invalid interval for monthly schedule. Valid values for monthDay are 1 to 31 or LastDay, and for occurrence_of_weekday are First, Second, Third, Fourth, Fifth, or LastDay. + + + Schedule must have exactly one interval where either hours or minutes is set, but not both. + + + {0} schedule must have interval set to 1 hour or 60 minutes. + + + The difference between start and end times must be in increments of 60 minutes. + + + Updated intervals to 60 minutes. + + + Schedule convertion failed.\r\nSource:\r\n{0}\r\nFinal:\r\n{1} + + + Schedules have been update.\r\nSource:\r\n{0}\r\nFinal:\r\n{1}\r\nChanges:{2} + + + Updated frequency to Daily. + + + Updated schedule to include all weekdays. + + + Updated schedule to add EndAt property. + + + Updated schedule to remove EndAt property. + + + Updated schedule hours from {0} to {1} + \ No newline at end of file diff --git a/src/Tableau.Migration/Tableau.Migration.csproj b/src/Tableau.Migration/Tableau.Migration.csproj index 63ff1bb..480f77f 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 - net6.0;net8.0 + net8.0 Tableau.Migration True @@ -44,18 +44,18 @@ 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/TypeExtensions.cs b/src/Tableau.Migration/TypeExtensions.cs index 3669325..c4f4604 100644 --- a/src/Tableau.Migration/TypeExtensions.cs +++ b/src/Tableau.Migration/TypeExtensions.cs @@ -79,6 +79,26 @@ public static MethodInfo[] GetAllInterfaceMethods(this Type interfaceType, Bindi return FindAllInterfaceMethods(interfaceType, bindingAttr).ToArray(); } + /// + /// Gets the formatted name of the specified . + /// + /// The to get the formatted name for. + /// The formatted name of the . + public static string GetFormattedName(this Type type) + { + if (type.IsGenericType) + { + Type genericTypeDefinition = type.GetGenericTypeDefinition(); + Type[] genericArguments = type.GetGenericArguments(); + string genericArgumentsString = string.Join(", ", genericArguments.Select(t => t.GetFormattedName())); + return $"{genericTypeDefinition.Name.Substring(0, genericTypeDefinition.Name.IndexOf('`'))}<{genericArgumentsString}>"; + } + else + { + return type.Name; + } + } + #region - private implementations private static List FindAllInterfaceProperties(this Type interfaceType, BindingFlags bindingAttr = BindingFlags.Default) diff --git a/tests/Python.ExampleApplication.Tests/pyproject.toml b/tests/Python.ExampleApplication.Tests/pyproject.toml index 994186a..9426b49 100644 --- a/tests/Python.ExampleApplication.Tests/pyproject.toml +++ b/tests/Python.ExampleApplication.Tests/pyproject.toml @@ -32,4 +32,4 @@ dependencies = [ test = "pytest" [[tool.hatch.envs.test.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12"] \ No newline at end of file +python = ["3.9", "3.10", "3.11", "3.12"] \ No newline at end of file diff --git a/tests/Python.TestApplication/build.py b/tests/Python.TestApplication/build.py index 5d7101a..9f6b866 100644 --- a/tests/Python.TestApplication/build.py +++ b/tests/Python.TestApplication/build.py @@ -33,6 +33,6 @@ def build(): migration_project = abspath("../../tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj") shutil.rmtree(bin_path, True) - subprocess.run(["dotnet", "publish", migration_project, "-o", bin_path, "-f", "net6.0"]) + subprocess.run(["dotnet", "publish", migration_project, "-o", bin_path, "-f", "net8.0"]) sys.path.append(bin_path) diff --git a/tests/Python.TestApplication/pyproject.toml b/tests/Python.TestApplication/pyproject.toml index bd65135..579871e 100644 --- a/tests/Python.TestApplication/pyproject.toml +++ b/tests/Python.TestApplication/pyproject.toml @@ -36,4 +36,4 @@ dependencies = [ test = "pytest" [[tool.hatch.envs.test.matrix]] -python = ["3.8", "3.9", "3.10", "3.11", "3.12"] +python = ["3.9", "3.10", "3.11", "3.12"] diff --git a/tests/Tableau.Migration.TestApplication/ActivityEnricher.cs b/tests/Tableau.Migration.TestApplication/ActivityEnricher.cs new file mode 100644 index 0000000..5e8c377 --- /dev/null +++ b/tests/Tableau.Migration.TestApplication/ActivityEnricher.cs @@ -0,0 +1,42 @@ +// +// 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.Diagnostics; +using System.Linq; +using Serilog.Core; +using Serilog.Events; + +public class ActivityEnricher : ILogEventEnricher +{ + /// + /// Enriches the log event with activity tags. + /// + /// The log event to enrich. + /// The property factory. + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + var activity = Activity.Current; + if (activity != null && activity.Tags.Any()) + { + foreach (var tag in activity.Tags) + { + var formattedTags = string.Join(", ", activity.Tags.Select(tag => $"{tag.Key}: {tag.Value}")); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ActivityTags", formattedTags)); + } + } + } +} diff --git a/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs b/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs index cf5db04..169863c 100644 --- a/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs +++ b/tests/Tableau.Migration.TestApplication/Config/TestApplicationOptions.cs @@ -36,5 +36,7 @@ public sealed class TestApplicationOptions public string SkippedProject { get; set; } = string.Empty; public string SkippedMissingParentDestination { get; set; } = "Missing Parent"; + + public string[] SkipTypes { get; set; } = Array.Empty(); } } diff --git a/tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs b/tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs new file mode 100644 index 0000000..ea30505 --- /dev/null +++ b/tests/Tableau.Migration.TestApplication/Hooks/LogMigrationBatchSummaryHook.cs @@ -0,0 +1,59 @@ +// +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Manifest; +using Tableau.Migration.Engine.Migrators.Batch; +using Tableau.Migration.Engine.Pipelines; + +namespace Tableau.Migration.TestApplication.Hooks +{ + internal sealed class LogMigrationBatchSummaryHook : IContentBatchMigrationCompletedHook + where T : IContentReference + { + private readonly IMigrationManifest _manifest; + private readonly ILogger> _logger; + + public LogMigrationBatchSummaryHook(IMigrationManifest manifest, + ILogger> logger) + { + _manifest = manifest; + _logger = logger; + } + + /// + public Task?> ExecuteAsync(IContentBatchMigrationResult ctx, CancellationToken cancel) + { + 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); + + _logger.LogInformation("{ContentType} batch completed for {Count} non-skipped item(s). Total processed: {ProcessedCount} / {Total}", + contentTypeName, ctx.ItemResults.Count, processedCount, entries.ExpectedTotalCount); + + return Task.FromResult?>(ctx); + } + } +} diff --git a/tests/Tableau.Migration.TestApplication/Hooks/TimeLoggerAfterActionHook.cs b/tests/Tableau.Migration.TestApplication/Hooks/TimeLoggerAfterActionHook.cs index 5045e6a..b89bfa9 100644 --- a/tests/Tableau.Migration.TestApplication/Hooks/TimeLoggerAfterActionHook.cs +++ b/tests/Tableau.Migration.TestApplication/Hooks/TimeLoggerAfterActionHook.cs @@ -15,15 +15,13 @@ // limitations under the License. // -using Microsoft.Extensions.Logging; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Tableau.Migration.Engine.Actions; using Tableau.Migration.Engine.Hooks; +using Tableau.Migration.Engine.Pipelines; namespace Tableau.Migration.TestApplication.Hooks { @@ -33,15 +31,21 @@ namespace Tableau.Migration.TestApplication.Hooks internal class TimeLoggerAfterActionHook : IMigrationActionCompletedHook { private ILogger _logger; + private MigrationPipelineRunner _pipelineRunner; - public TimeLoggerAfterActionHook(ILogger logger ) + public TimeLoggerAfterActionHook( + ILogger logger, + IMigrationPipelineRunner pipelineRunner) { _logger = logger; + _pipelineRunner = (MigrationPipelineRunner)pipelineRunner; } public Task ExecuteAsync(IMigrationActionResult ctx, CancellationToken cancel) { - _logger.LogInformation("Migration action completed"); + string actionName = _pipelineRunner.CurrentAction?.GetType().GetFormattedName() ?? "Unknown"; + + _logger.LogInformation($"Action {actionName} completed"); return Task.FromResult((IMigrationActionResult?)ctx); } } diff --git a/tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs b/tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs new file mode 100644 index 0000000..5de4c2e --- /dev/null +++ b/tests/Tableau.Migration.TestApplication/Hooks/ViewerOwnerTransformer.cs @@ -0,0 +1,81 @@ +// +// 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; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.Search; +using Tableau.Migration.Engine.Hooks.Transformers; +using Tableau.Migration.Resources; +using Tableau.Migration.TestApplication.Config; + + +namespace Tableau.Migration.TestApplication.Hooks +{ + /// + /// There is a lot of content on Kibble that is owned by users that are licensed as "Viewer". + /// This can only happen if a user was a "Creator" or above and then was downgraded to "Viewer". + /// When a migration happens, we need to ensure that the owner of the content is not a "Viewer" as they can't be owners. + /// + public class ViewerOwnerTransformer : ContentTransformerBase + where TPublish : IContentReference, IWithOwner + { + private readonly ContentLocation _adminUser; + private readonly IDestinationContentReferenceFinder _destinationContentReferenceFinder; + + public ViewerOwnerTransformer( + ISharedResourcesLocalizer localizer, + ILogger> logger, + IDestinationContentReferenceFinder destinationContentReferenceFinder, + IOptions options) + : base(localizer, logger) + { + var _options = options.Value; + + _adminUser = ContentLocation.ForUsername(_options.SpecialUsers.AdminDomain, _options.SpecialUsers.AdminUsername); + _destinationContentReferenceFinder = destinationContentReferenceFinder; + } + + public async override Task TransformAsync(TPublish itemToTransform, CancellationToken cancel) + { + var owner = await _destinationContentReferenceFinder.FindByIdAsync(itemToTransform.Owner.Id, cancel) as IUser; + + if (owner == null) + { + throw new System.Exception("Owner not found"); + } + + if (owner.LicenseLevel == LicenseLevels.Viewer) + { + var adminUser = await _destinationContentReferenceFinder.FindBySourceLocationAsync(_adminUser, cancel); + + if (adminUser == null) + { + throw new System.Exception("Admin user not found"); + } + + itemToTransform.Owner = adminUser; + + } + + return itemToTransform; + } + } +} diff --git a/tests/Tableau.Migration.TestApplication/LogFileHelper.cs b/tests/Tableau.Migration.TestApplication/LogFileHelper.cs index eed6704..c402ac3 100644 --- a/tests/Tableau.Migration.TestApplication/LogFileHelper.cs +++ b/tests/Tableau.Migration.TestApplication/LogFileHelper.cs @@ -29,7 +29,7 @@ internal static class LogFileHelper private const string LOG_FILE_PREFIX = "Tableau.Migration.TestApplication"; - internal const string LOG_LINE_TEMPLATE = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff}|{Level}|{ThreadId}|{SourceContext} -\t{Message:lj}{NewLine}{Exception}"; + internal const string LOG_LINE_TEMPLATE = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}|{Level}|{ThreadId}|{SourceContext} -\t{Message:lj} {ActivityTags}{NewLine}{Exception}"; public static string GetFileNameTimeStamp() => Program.StartTime.ToString(FILENAME_TIMESTAMP_FORMAT); diff --git a/tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs b/tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs new file mode 100644 index 0000000..f95ca91 --- /dev/null +++ b/tests/Tableau.Migration.TestApplication/MigrationSummaryBuilder.cs @@ -0,0 +1,140 @@ +// +// 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; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Tableau.Migration.Engine.Manifest; +using Tableau.Migration.Engine.Pipelines; + +namespace Tableau.Migration.TestApplication +{ + internal static class MigrationSummaryBuilder + { + private const char KEY_VALUE_PAIR_SEPARATOR = ','; + private const char KEY_VALUE_SEPARATOR = ':'; + + public static string Build(MigrationResult result, DateTime startTime, DateTime endTime, TimeSpan elapsed) + { + var summaryBuilder = new StringBuilder(); + return summaryBuilder + .Append("Migration completed.") + .AppendLine() + .AppendLine() + .AppendLine($"Result {KEY_VALUE_SEPARATOR}{result.Status}") + .AppendLine($"Start Time{KEY_VALUE_SEPARATOR}{startTime}") + .AppendLine($"End Time {KEY_VALUE_SEPARATOR}{endTime}") + .AppendLine($"Duration {KEY_VALUE_SEPARATOR}{elapsed}") + .AppendLine() + .AppendContentMigrationResult(result) + .ToString(); + } + + private static StringBuilder AppendContentMigrationResult(this StringBuilder summaryBuilder, MigrationResult result) + { + summaryBuilder.AppendLine(); + + var contentTypeList = GetContentTypes(); + + foreach (var contentType in contentTypeList) + { + var typeResult = result.Manifest.Entries.ForContentType(contentType); + + if (typeResult is null) + { + continue; + } + + summaryBuilder.AppendResultRow(typeResult); + } + return summaryBuilder; + } + + private static StringBuilder AppendResultRow( + this StringBuilder summaryBuilder, + IMigrationManifestContentTypePartition typeResult) + { + var total = typeResult.ExpectedTotalCount; + + if (total == 0) + { + return summaryBuilder; + } + + var statusTotals = typeResult.GetStatusTotals().Where(i => i.Value > 0).ToImmutableDictionary(); + + var successStatusTotals = statusTotals.Where(k => k.Key.IsSuccess()).ToImmutableDictionary(); + var nonSuccessStatusTotals = statusTotals.Except(successStatusTotals).ToImmutableDictionary(); + + var successTotal = successStatusTotals.Sum(s => s.Value); + + var contentTypeName = MigrationPipelineContentType.GetConfigKeyForType(typeResult.ContentType); + + summaryBuilder + .AppendLine() + .Append( + $"{contentTypeName}:: {GetPercentage(successTotal, total)}% successful") + .Append( + $" ({GetMetricString("Total", total)}{KEY_VALUE_PAIR_SEPARATOR}{successStatusTotals.ToMetricString()})") + .AppendLine(); + + if (successTotal != total) + { + summaryBuilder.AppendLine($"\tDetails {nonSuccessStatusTotals.ToMetricString()}"); + } + + return summaryBuilder; + + static double GetPercentage(int count, double total) + => total != 0 ? Math.Round(count * 100 / total, 2) : 0; + } + + private static string ToMetricString(this ImmutableDictionary statusTotals) + { + if (statusTotals.IsEmpty) + { + return string.Empty; + } + + var resultBuilder = new StringBuilder(); + var firstItem = statusTotals.First(); + + resultBuilder.Append(firstItem.ToMetricString()); + + foreach (var item in statusTotals.Skip(1)) + { + resultBuilder.Append($"{KEY_VALUE_PAIR_SEPARATOR}{item.ToMetricString()}"); + } + + return resultBuilder.ToString(); + } + + private static bool IsSuccess(this MigrationManifestEntryStatus status) + => status == MigrationManifestEntryStatus.Migrated || status == MigrationManifestEntryStatus.Skipped; + + private static string ToMetricString(this KeyValuePair statusTotal) + => GetMetricString(statusTotal.Key.ToString(), statusTotal.Value); + + private static string GetMetricString(string metricName, int metricValue) + => $"{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 5603c0f..714adf6 100644 --- a/tests/Tableau.Migration.TestApplication/Program.cs +++ b/tests/Tableau.Migration.TestApplication/Program.cs @@ -23,7 +23,6 @@ using Microsoft.Extensions.Logging; using Serilog; using Serilog.Events; -using Tableau.Migration.Content; using Tableau.Migration.TestApplication.Config; using Tableau.Migration.TestApplication.Hooks; @@ -57,15 +56,11 @@ public static async Task Main(string[] args) public static IServiceCollection AddCustomizations(this IServiceCollection services) { services + .AddScoped(typeof(SkipFilter<>)) .AddScoped() .AddScoped() // print and log the time when an action was completed - - // I would like to have a AfterBatchMigrationCompletedHook without the content type - .AddScoped>() - .AddScoped>() - .AddScoped>() - .AddScoped>() - .AddScoped>() + .AddScoped(typeof(LogMigrationBatchSummaryHook<>)) + .AddScoped(typeof(SaveManifestAfterBatchMigrationCompletedHook<>)) .AddScoped() .AddScoped() .AddScoped() @@ -73,7 +68,9 @@ public static IServiceCollection AddCustomizations(this IServiceCollection servi .AddScoped() .AddScoped(typeof(SkipByParentLocationFilter<>)) .AddScoped(typeof(ContentWithinSkippedLocationMapping<>)) - .AddScoped(); + .AddScoped() + .AddScoped(typeof(ViewerOwnerTransformer<>)); + return services; } @@ -83,18 +80,14 @@ public static IServiceCollection ConfigureLogging(this IServiceCollection servic config => { config.ClearProviders(); - config.AddSerilog( - new LoggerConfiguration() - .Enrich.WithThreadId() - .WriteTo.File( - path: LogFileHelper.GetLogFilePath(ctx.Configuration.GetSection("log:folderPath").Value), - outputTemplate: LogFileHelper.LOG_LINE_TEMPLATE) + var serilogConfig = new LoggerConfiguration() + .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) - .WriteTo.Logger(lc => lc // Create a filter that writes certain loggers to the console .Filter.ByIncludingOnly((logEvent) => @@ -110,13 +103,26 @@ public static IServiceCollection ConfigureLogging(this IServiceCollection servic string[] sourceContextToPrint = [ "Tableau.Migration.TestApplication.TestApplication", - "Tableau.Migration.TestApplication.Hooks.TimeLoggerAfterActionHook" + "Tableau.Migration.TestApplication.Hooks.TimeLoggerAfterActionHook", + "Tableau.Migration.TestApplication.Hooks.LogMigrationBatchSummaryHook" ]; return sourceContextToPrint.Contains(sourceContext); }) - .WriteTo.Console()) - .CreateLogger()); + .WriteTo.Console()); + + var logPath = ctx.Configuration.GetSection("log:folderPath").Value; + if (!string.IsNullOrEmpty(logPath)) + { + serilogConfig = serilogConfig.WriteTo.File( + path: LogFileHelper.GetLogFilePath(logPath), + outputTemplate: LogFileHelper.LOG_LINE_TEMPLATE, + fileSizeLimitBytes: 250 * 1024 * 1024, // 250MB + rollOnFileSizeLimit: true, + retainedFileCountLimit: null); // Retain all log files + } + + config.AddSerilog(serilogConfig.CreateLogger()); }); return services; } diff --git a/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj b/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj index 81eca51..4c4875b 100644 --- a/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj +++ b/tests/Tableau.Migration.TestApplication/Tableau.Migration.TestApplication.csproj @@ -1,7 +1,7 @@  Exe - net6.0;net8.0 + net8.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 8722d38..5aa3a63 100644 --- a/tests/Tableau.Migration.TestApplication/TestApplication.cs +++ b/tests/Tableau.Migration.TestApplication/TestApplication.cs @@ -18,14 +18,14 @@ using System; using System.Diagnostics; using System.Linq; -using System.Text; +using System.Reflection; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules.Cloud; +using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Engine.Manifest; using Tableau.Migration.Engine.Pipelines; using Tableau.Migration.TestApplication.Config; @@ -44,6 +44,8 @@ internal sealed class TestApplication : IHostedService private readonly ILogger _logger; private readonly MigrationManifestSerializer _manifestSerializer; + private readonly Assembly _tableauMigrationAssembly; + public TestApplication( IHostApplicationLifetime appLifetime, IMigrationPlanBuilder planBuilder, @@ -60,6 +62,12 @@ public TestApplication( _options = options.Value; _logger = logger; _manifestSerializer = manifestSerializer; + + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + // Find the assembly by name + _tableauMigrationAssembly = assemblies.FirstOrDefault(a => a.GetName().Name == "Tableau.Migration") ?? throw new Exception("Could not find Tableau.Migration assembly"); + } public async Task StartAsync(CancellationToken cancel) @@ -74,6 +82,28 @@ public async Task StartAsync(CancellationToken cancel) .ToDestinationTableauCloud(_options.Destination.ServerUrl, _options.Destination.SiteContentUrl, _options.Destination.AccessTokenName, Environment.GetEnvironmentVariable("TABLEAU_MIGRATION_DESTINATION_TOKEN") ?? _options.Destination.AccessToken) .ForServerToCloud(); + if (!_options.SkipTypes.Any()) + { + _logger.LogInformation("No SkipFilter types provided. Skipping no content types."); + } + + // Add SkipFilter for each type in the configuration + foreach (string skipTypeStr in _options.SkipTypes) + { + var contentType = _tableauMigrationAssembly.GetTypes().FirstOrDefault(t => t.Name == skipTypeStr); + + if (contentType is null) + { + _logger.LogCritical($"Could not find type Tableau.Migration.Content.{skipTypeStr} to skip."); + Console.WriteLine("Press any key to exit"); + Console.ReadKey(); + _appLifetime.StopApplication(); + return; + } + + _planBuilder.Filters.Add(typeof(SkipFilter<>), new[] { new[] { contentType! } }); + _logger.LogInformation("Created SkipFilter for type {ContentType}", contentType.Name); + } if (_options.Destination.SiteContentUrl != "") { // Most likely means it's a Cloud site not a Server @@ -90,19 +120,27 @@ public async Task StartAsync(CancellationToken cancel) // A user has non-ASCII names in their username, which causes issues for now. // Filtering to make it past the issue. - _planBuilder.Filters.Add(); - _planBuilder.Mappings.Add(); + if (_options.SpecialUsers.Emails.Any()) + { + _planBuilder.Filters.Add(); + _planBuilder.Mappings.Add(); + } // Map unlicensed users to single admin _planBuilder.Mappings.Add(); - // Save manifest every every batch of every content type - _planBuilder.Hooks.Add>(); - _planBuilder.Hooks.Add>(); - _planBuilder.Hooks.Add>(); - _planBuilder.Hooks.Add>(); - _planBuilder.Hooks.Add>(); - _planBuilder.Hooks.Add>(); + // Save manifest every every batch of every content type. + var contentTypeArrays = ServerToCloudMigrationPipeline.ContentTypes.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, IDataSource>(); + _planBuilder.Transformers.Add, IWorkbook>(); // Log when a content type is done _planBuilder.Hooks.Add(); @@ -114,14 +152,6 @@ public async Task StartAsync(CancellationToken cancel) _planBuilder.Mappings.Add, IProject>(); _planBuilder.Mappings.Add, IDataSource>(); _planBuilder.Mappings.Add, IWorkbook>(); - // Skip content types we've already done. - // Uncomment as needed - //_planBuilder.Filters.Add(new SkipFilter()); - //_planBuilder.Filters.Add(new SkipFilter()); - //_planBuilder.Filters.Add(new SkipFilter()); - //_planBuilder.Filters.Add(new SkipFilter()); - //_planBuilder.Filters.Add(new SkipFilter()); - //_planBuilder.Filters.Add(new SkipFilter()); var prevManifest = await LoadManifest(_options.PreviousManifestPath, cancel); @@ -137,12 +167,11 @@ public async Task StartAsync(CancellationToken cancel) _timer.Stop(); + var endTime = DateTime.UtcNow; + await _manifestSerializer.SaveAsync(result.Manifest, manifestFilePath); - PrintResult(result); - _logger.LogInformation($"Migration Started: {startTime}"); - _logger.LogInformation($"Migration Finished: {DateTime.UtcNow}"); - _logger.LogInformation($"Elapsed: {_timer.Elapsed}"); + _logger.LogInformation(MigrationSummaryBuilder.Build(result, startTime, endTime, _timer.Elapsed)); Console.WriteLine("Press any key to exit"); Console.ReadKey(); @@ -151,35 +180,6 @@ public async Task StartAsync(CancellationToken cancel) public Task StopAsync(CancellationToken cancel) => Task.CompletedTask; - private void PrintResult(MigrationResult result) - { - _logger.LogInformation($"Result: {result.Status}"); - - // Print out total results - foreach (var type in ServerToCloudMigrationPipeline.ContentTypes) - { - var contentType = type.ContentType; - - var typeResult = result.Manifest.Entries.ForContentType(contentType); - - var countTotal = typeResult.Count; - var countMigrated = typeResult.Where(x => x.Status == MigrationManifestEntryStatus.Migrated).Count(); - var countSkipped = typeResult.Where(x => x.Status == MigrationManifestEntryStatus.Skipped).Count(); - var countErrored = typeResult.Where(x => x.Status == MigrationManifestEntryStatus.Error).Count(); - var countCancelled = typeResult.Where(x => x.Status == MigrationManifestEntryStatus.Canceled).Count(); - var countPending = typeResult.Where(x => x.Status == MigrationManifestEntryStatus.Pending).Count(); - - StringBuilder sb = new StringBuilder(); - sb.AppendLine($"{contentType.Name}"); - sb.AppendLine($"\t{countMigrated}/{countTotal} succeeded"); - sb.AppendLine($"\t{countSkipped}/{countTotal} skipped"); - sb.AppendLine($"\t{countErrored}/{countTotal} errored"); - sb.AppendLine($"\t{countCancelled}/{countTotal} cancelled"); - sb.AppendLine($"\t{countPending}/{countTotal} pending"); - - _logger.LogInformation(sb.ToString()); - } - } private async Task LoadManifest(string manifestFilepath, CancellationToken cancel) { diff --git a/tests/Tableau.Migration.TestApplication/appsettings.json b/tests/Tableau.Migration.TestApplication/appsettings.json index 8ec77b0..dfdc704 100644 --- a/tests/Tableau.Migration.TestApplication/appsettings.json +++ b/tests/Tableau.Migration.TestApplication/appsettings.json @@ -59,7 +59,17 @@ "adminDomain": "", "adminUsername": "", "emails": [ - + ] - } + }, + + "skipTypes": [ + //"IUser", + //"IGroup", + //"IProject", + //"IDataSource", + //"IWorkbook", + //"IServerExtractRefreshTask", + //"ICustomView" + ] } diff --git a/tests/Tableau.Migration.Tests/AutoFixtureTestBaseTests.cs b/tests/Tableau.Migration.Tests/AutoFixtureTestBaseTests.cs index d738770..d82fae7 100644 --- a/tests/Tableau.Migration.Tests/AutoFixtureTestBaseTests.cs +++ b/tests/Tableau.Migration.Tests/AutoFixtureTestBaseTests.cs @@ -34,9 +34,9 @@ public void Verify_CreateErrors_create_all_exceptions() var tableauMigrationAssembly = loadedAssemblies.Where(a => a.ManifestModule.Name == "Tableau.Migration.dll").First(); - // Find all the exception types in the Tableau.Migration assembly + // Find all the exception types in the Tableau.Migration assembly that are not abstract var exceptionTypes = tableauMigrationAssembly.GetTypes() - .Where(t => t.BaseType == typeof(Exception)) + .Where(t => t.BaseType == typeof(Exception) && !t.IsAbstract) .Where(t => t != typeof(MismatchException)) .ToList(); diff --git a/tests/Tableau.Migration.Tests/FixtureFactory.cs b/tests/Tableau.Migration.Tests/FixtureFactory.cs index c26b883..a8d9c20 100644 --- a/tests/Tableau.Migration.Tests/FixtureFactory.cs +++ b/tests/Tableau.Migration.Tests/FixtureFactory.cs @@ -122,7 +122,7 @@ void SetupInterval() return property switch { - hours => customized.With(i => i.Hours, GetRandomValue(IntervalValues.HoursValues)), + hours => customized.With(i => i.Hours, GetRandomValue(IntervalValues.ServerHoursValues)), minutes => customized.With(i => i.Minutes, GetRandomValue(IntervalValues.MinutesValues)), weekDay => customized.With(i => i.WeekDay, GetRandomValue(IntervalValues.WeekDaysValues)), monthDay => customized.With(i => i.MonthDay, GetRandomValue(IntervalValues.MonthDaysValues)), @@ -347,7 +347,7 @@ internal static SerializableManifestEntry CreateSerializableManifestEntry(IFixtu ret.Source = fixture.Create(); ret.Destination = fixture.Create(); ret.MappedLocation = ret.Destination.Location; - ret.Status = (int)fixture.Create(); + ret.Status = fixture.Create().ToString(); ret.SetErrors(CreateErrors(fixture)); return ret; @@ -409,7 +409,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)) + .Where(t => t.BaseType == typeof(Exception) && !t.IsAbstract) .Where(t => t != typeof(MismatchException)) // MismatchException will never be in a manifest .ToList(); diff --git a/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs b/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs index 1d625e6..6a0b4f6 100644 --- a/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs +++ b/tests/Tableau.Migration.Tests/Simulation/ServerToCloudSimulationTestBase.cs @@ -521,8 +521,6 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec End = "01:25:00", Intervals = [ new Server.ScheduleResponse.ScheduleType.FrequencyDetailsType.IntervalType { Hours = "1" }, - new Server.ScheduleResponse.ScheduleType.FrequencyDetailsType.IntervalType { WeekDay = WeekDays.Sunday }, - new Server.ScheduleResponse.ScheduleType.FrequencyDetailsType.IntervalType { WeekDay = WeekDays.Saturday } ] } }; @@ -563,6 +561,7 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec }; var monthlyMultipleDaysSchedule = new Server.ScheduleResponse.ScheduleType { + // Note: This type of schedule on Server can only be created via the UI, not the RestAPI. It is still a valid schedule though. Id = Guid.NewGuid(), Name = $"{ScheduleFrequencies.Monthly}_Multiple", Type = ScheduleTypes.Extract, @@ -580,7 +579,7 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec ] } }; - var monthlyLastSundaySchedule = new Server.ScheduleResponse.ScheduleType + var monthlyLastDaySchedule = new Server.ScheduleResponse.ScheduleType { Id = Guid.NewGuid(), Name = $"{ScheduleFrequencies.Monthly}_LastSunday", @@ -592,7 +591,7 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec FrequencyDetails = new Server.ScheduleResponse.ScheduleType.FrequencyDetailsType { Start = "01:35:00", - Intervals = [new Server.ScheduleResponse.ScheduleType.FrequencyDetailsType.IntervalType { WeekDay = WeekDays.Sunday, MonthDay = "LastDay" }] + Intervals = [new Server.ScheduleResponse.ScheduleType.FrequencyDetailsType.IntervalType { MonthDay = "LastDay" }] } }; var schedules = new List @@ -601,7 +600,7 @@ void CreateConnectionsForWorkbook(SimulatedWorkbookData workbookData, int connec dailySchedule, weeklySchedule, monthlyMultipleDaysSchedule, - monthlyLastSundaySchedule + monthlyLastDaySchedule }; foreach (var schedule in schedules) diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/CustomViewsMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/CustomViewsMigrationTests.cs index ba41b00..e640181 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/CustomViewsMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/CustomViewsMigrationTests.cs @@ -75,10 +75,7 @@ public async Task MigratesAllCustomViewsToCloudAsync() void AssertCustomViewMigrated(CustomViewResponse.CustomViewType sourceCustomView) { // Get destination custom view - var destinationCustomView = Assert.Single( - CloudDestinationApi.Data.CustomViews.Where(cv => - cv.Name == sourceCustomView.Name - )); + var destinationCustomView = Assert.Single(CloudDestinationApi.Data.CustomViews, cv => cv.Name == sourceCustomView.Name); Assert.NotEqual(sourceCustomView.Id, destinationCustomView.Id); Assert.Equal(sourceCustomView.Name, destinationCustomView.Name); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/DataSourceMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/DataSourceMigrationTests.cs index d476101..1b45e2b 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/DataSourceMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/DataSourceMigrationTests.cs @@ -73,11 +73,9 @@ public async Task MigratesAllDataSourcesToCloudAsync() void AssertDataSourceMigrated(DataSourceResponse.DataSourceType sourceDataSource) { - var destinationDataSource = Assert.Single( - CloudDestinationApi.Data.DataSources.Where(ds => + var destinationDataSource = Assert.Single(CloudDestinationApi.Data.DataSources, ds => ds.Name == sourceDataSource.Name && - ds.Description == sourceDataSource.Description - )); + ds.Description == sourceDataSource.Description); Assert.NotEqual(sourceDataSource.Id, destinationDataSource.Id); Assert.Equal(sourceDataSource.Name, destinationDataSource.Name); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/ExtractRefreshTaskMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/ExtractRefreshTaskMigrationTests.cs index f523417..14f0975 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/ExtractRefreshTaskMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/ExtractRefreshTaskMigrationTests.cs @@ -21,7 +21,6 @@ using Microsoft.Extensions.DependencyInjection; using Tableau.Migration.Api.Rest.Models.Responses.Server; using Tableau.Migration.Api.Rest.Models.Types; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Engine.Manifest; using Xunit; @@ -95,8 +94,7 @@ sourceWorkbook is not null && wb.Name == sourceWorkbook.Name); // Get destination extract refresh task - var destinationExtractRefreshTask = Assert.Single( - CloudDestinationApi.Data.CloudExtractRefreshTasks.Where(cert => + var destinationExtractRefreshTask = Assert.Single(CloudDestinationApi.Data.CloudExtractRefreshTasks, cert => ( cert.ExtractRefresh!.DataSource is not null && destinationDataSource is not null && @@ -106,8 +104,7 @@ destinationDataSource is not null && cert.ExtractRefresh!.Workbook is not null && destinationWorkbook is not null && cert.ExtractRefresh.Workbook.Id == destinationWorkbook.Id - ) - )); + )); var destinationExtractRefresh = destinationExtractRefreshTask.ExtractRefresh!; Assert.NotEqual(sourceExtractRefresh.Id, destinationExtractRefresh.Id); @@ -119,7 +116,12 @@ destinationWorkbook is not null && { Assert.Equal(extractRefreshType.Type, destinationExtractRefresh.Type); } + // Assert schedule information + // This can't be done completely without manually writting 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, destinationExtractRefresh.Schedule.Frequency); Assert.Equal(sourceSchedule.FrequencyDetails.Start, destinationExtractRefresh.Schedule.FrequencyDetails!.Start); if (sourceSchedule.FrequencyDetails.End is null) @@ -130,18 +132,6 @@ destinationWorkbook is not null && { Assert.Equal(sourceSchedule.FrequencyDetails.End, destinationExtractRefresh.Schedule.FrequencyDetails.End); } - Assert.Equal(sourceSchedule.FrequencyDetails.Intervals.Length, destinationExtractRefresh.Schedule.FrequencyDetails.Intervals.Length); - Assert.All( - destinationExtractRefresh.Schedule.FrequencyDetails.Intervals, - destinationInterval => - { - Assert.Single(sourceSchedule.FrequencyDetails.Intervals - .Where(sourceInterval => - (sourceInterval.Hours ?? string.Empty) == (destinationInterval.Hours ?? string.Empty) && - (sourceInterval.Minutes ?? string.Empty) == (destinationInterval.Minutes ?? string.Empty) && - (sourceInterval.WeekDay ?? string.Empty) == (destinationInterval.WeekDay ?? string.Empty) && - (sourceInterval.MonthDay ?? string.Empty) == (destinationInterval.MonthDay ?? string.Empty))); - }); } } } diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/GroupMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/GroupMigrationTests.cs index 18c933e..75fb1cb 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/GroupMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/GroupMigrationTests.cs @@ -64,13 +64,11 @@ public async Task MigratesAllGroupsToCloudAsync() Assert.Equal(groups.Count, result.Manifest.Entries.ForContentType().Where(e => e.Status == MigrationManifestEntryStatus.Migrated).Count()); - Assert.Single(result.Manifest.Entries.ForContentType().Where(e => e.Status == MigrationManifestEntryStatus.Skipped)); + Assert.Single(result.Manifest.Entries.ForContentType(), e => e.Status == MigrationManifestEntryStatus.Skipped); void AssertGroupMigrated(GroupsResponse.GroupType sourceGroup) { - var destinationGroup = Assert.Single( - CloudDestinationApi.Data.Groups.Where( - g => g.Name == sourceGroup.Name)); + var destinationGroup = Assert.Single(CloudDestinationApi.Data.Groups, g => g.Name == sourceGroup.Name); Assert.NotEqual(sourceGroup.Id, destinationGroup.Id); Assert.Equal(sourceGroup.Name, destinationGroup.Name); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/ProjectMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/ProjectMigrationTests.cs index ef790de..c072e0c 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/ProjectMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/ProjectMigrationTests.cs @@ -71,10 +71,9 @@ public async Task MigratesAllProjectsToCloudAsync() void AssertProjectMigrated(ProjectsResponse.ProjectType sourceProject) { - var destinationProject = Assert.Single( - CloudDestinationApi.Data.Projects.Where(p => + var destinationProject = Assert.Single(CloudDestinationApi.Data.Projects, p => p.Name == sourceProject.Name && - p.ParentProjectId is null == sourceProject.ParentProjectId is null)); + p.ParentProjectId is null == sourceProject.ParentProjectId is null); Assert.NotEqual(sourceProject.Id, destinationProject.Id); Assert.Equal(sourceProject.Name, destinationProject.Name); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs index 4f73701..cb4c168 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/UserMigrationTests.cs @@ -69,10 +69,9 @@ public async Task MigratesAllUsersToCloudAsync() void AssertUserMigrated(UsersResponse.UserType sourceUser) { - var destinationUser = Assert.Single( - CloudDestinationApi.Data.Users.Where( - u => u.Domain?.Name == sourceUser.Domain?.Name - && u.Name == sourceUser.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); diff --git a/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs b/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs index f4f8ce7..8da15ea 100644 --- a/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs +++ b/tests/Tableau.Migration.Tests/Simulation/Tests/WorkbookMigrationTests.cs @@ -76,10 +76,7 @@ public async Task MigratesAllWorkbooksToCloudAsync() void AssertWorkbookMigrated(WorkbookResponse.WorkbookType sourceWorkbook) { // Get destination workbook - var destinationWorkbook = Assert.Single( - CloudDestinationApi.Data.Workbooks.Where(ds => - ds.Name == sourceWorkbook.Name - )); + var destinationWorkbook = Assert.Single(CloudDestinationApi.Data.Workbooks, ds => ds.Name == sourceWorkbook.Name); Assert.NotEqual(sourceWorkbook.Id, destinationWorkbook.Id); Assert.Equal(sourceWorkbook.Name, destinationWorkbook.Name); @@ -106,11 +103,7 @@ void AssertWorkbookMigrated(WorkbookResponse.WorkbookType sourceWorkbook) void AssertWorkbookViewMigrated(WorkbookResponse.WorkbookType.ViewReferenceType sourceView) { // Get destination view - var destinationView = Assert.Single( - destinationWorkbook!.Views.Where(v => - { - return IViewReferenceTypeComparer.Instance.Equals(sourceView, v); - })); + var destinationView = Assert.Single(destinationWorkbook!.Views, v => IViewReferenceTypeComparer.Instance.Equals(sourceView, v)); Assert.NotEqual(sourceView.Id, destinationView.Id); diff --git a/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj b/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj index d88a72e..bfa6a27 100644 --- a/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj +++ b/tests/Tableau.Migration.Tests/Tableau.Migration.Tests.csproj @@ -1,6 +1,6 @@  - net6.0;net8.0 + net8.0 true CA2007 @@ -18,21 +18,21 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + - + 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 42312a3..ff14506 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestBase.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestBase.cs @@ -21,26 +21,15 @@ namespace Tableau.Migration.Tests.Unit.Api { - public abstract class ApiClientTestBase : ApiTestBase + public abstract class ApiClientTestBase : SiteApiTestBase where TApiClient : IContentApiClient { public string UrlPrefix { get; } = RestUrlPrefixes.GetUrlPrefix(); - protected readonly Guid SiteId = Guid.NewGuid(); - protected readonly Guid UserId = Guid.NewGuid(); - protected string SiteContentUrl => SiteConnectionConfiguration.SiteContentUrl; - protected TApiClient ApiClient => _apiClient ??= CreateClient(); private TApiClient? _apiClient; - public ApiClientTestBase() - { - MockSessionProvider.SetupGet(p => p.SiteContentUrl).Returns(() => SiteContentUrl); - MockSessionProvider.SetupGet(p => p.SiteId).Returns(() => SiteId); - MockSessionProvider.SetupGet(p => p.UserId).Returns(() => UserId); - } - protected TApiClient CreateClient() => Dependencies.CreateClient(); protected TApiClientImpl GetApiClient() diff --git a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs index a7d32d5..f7689a1 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/ApiClientTestDependencies.cs @@ -108,7 +108,6 @@ public ApiClientTestDependencies(IFixture autoFixture) MockScheduleCache = MockContentCacheFactory.SetupMockCache(autoFixture); - MockApiClientInput.SetupGet(i => i.SiteConnectionConfiguration).Returns(SiteConnectionConfiguration); MockSessionProvider.SetupGet(p => p.Version).Returns(TableauServerVersion); diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Permissions/DefaultPermissionsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Permissions/DefaultPermissionsApiClientTests.cs index 3e821a2..bae4a6a 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Permissions/DefaultPermissionsApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Permissions/DefaultPermissionsApiClientTests.cs @@ -97,30 +97,6 @@ public async Task Calls_inner_client(string contentTypeUrlSegment) } } - public class DeleteAllPermissionsAsync : DefaultPermissionsApiClientTest - { - [Theory] - [DefaultPermissionsContentTypeUrlSegmentData] - public async Task Calls_inner_client(string contentTypeUrlSegment) - { - var projectId = Create(); - var permissions = Create(); - - var mockResult = new Mock(); - - MockPermissionsClients[contentTypeUrlSegment] - .Setup(c => c.DeleteAllPermissionsAsync(projectId, permissions, Cancel)) - .ReturnsAsync(mockResult.Object); - - var result = await DefaultPermissionsClient.DeleteAllPermissionsAsync(contentTypeUrlSegment, projectId, permissions, Cancel); - - Assert.Same(mockResult.Object, result); - - MockPermissionsClients[contentTypeUrlSegment].VerifyAll(); - MockPermissionsClients[contentTypeUrlSegment].VerifyNoOtherCalls(); - } - } - public class DeleteCapabilityAsync : DefaultPermissionsApiClientTest { [Theory] diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Permissions/PermissionsApiClientTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Permissions/PermissionsApiClientTests.cs index 15620e5..274e66e 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Permissions/PermissionsApiClientTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Permissions/PermissionsApiClientTests.cs @@ -16,192 +16,70 @@ // using System; -using System.Collections.Immutable; -using System.Linq; +using System.Net.Http; using System.Threading.Tasks; -using Moq; using Tableau.Migration.Api.Permissions; using Tableau.Migration.Api.Rest; -using Tableau.Migration.Api.Rest.Models; -using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Content.Permissions; -using Tableau.Migration.Net; -using Tableau.Migration.Net.Rest; -using Tableau.Migration.Resources; using Xunit; namespace Tableau.Migration.Tests.Unit.Api.Permissions { public class PermissionsApiClientTests { - public abstract class PermissionsApiClientTest : AutoFixtureTestBase + public abstract class PermissionsApiClientTest : SiteApiTestBase { - protected readonly Mock MockRestRequestBuilderFactory = new(); - protected readonly Mock MockSerializer = new(); - protected readonly Mock MockUriBuilder = new(); - protected readonly Mock MockSharedResourcesLocalizer = new(); + protected const string ContentTypePrefix = "test"; - internal readonly Mock MockPermissionsClient; - internal readonly PermissionsApiClient PermissionsClient; + internal readonly PermissionsApiClient ApiClient; public PermissionsApiClientTest() { - MockPermissionsClient = new Mock( - MockRestRequestBuilderFactory.Object, - MockSerializer.Object, - MockUriBuilder.Object, - MockSharedResourcesLocalizer.Object) - { - CallBase = true - }; - - PermissionsClient = MockPermissionsClient.Object; - } - - #region - Test Helpers - - - public void SetupGetCapabilitiesToDelete(IGranteeCapability[] capabilities) - { - var deleteSetup = MockPermissionsClient - .Setup(x => - x.GetCapabilitiesToDelete( - It.IsAny(), - It.IsAny())); - - deleteSetup.Returns(capabilities.ToImmutableArray()); + var permissionsUriBuilder = new PermissionsUriBuilder(ContentTypePrefix); + ApiClient = new(RestRequestBuilderFactory, Serializer, permissionsUriBuilder, MockSharedResourcesLocalizer.Object); } + } - public void SetupDeleteCapabilityAsync(bool success = true) + public class GetPermissionsAsync : PermissionsApiClientTest + { + [Fact] + public async Task Success() { - var deleteSetup = MockPermissionsClient - .Setup(c => c.DeleteCapabilityAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - Cancel)); - - if (success) - { - deleteSetup.Returns(Task.FromResult(Result.Succeeded())); - return; - } + var id = Guid.NewGuid(); - deleteSetup.Returns(Task.FromResult(Result.Failed(new Exception()))); - } + SetupSuccessResponse(); - public void VerifyDeleteCapabilityAsync(Times times) - { - MockPermissionsClient - .Verify(c => c.DeleteCapabilityAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny(), - Cancel), - times); - } - public void SetupDeletePermissionsAsync(bool success = true) - { - var deleteSetup = MockPermissionsClient - .Setup(c => c.DeletePermissionsAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - Cancel)); - - if (success) - { - deleteSetup.Returns(Task.FromResult(Result.Succeeded())); - return; - } - - deleteSetup.Returns(Task.FromResult(Result.Failed(new Exception()))); - } + var result = await ApiClient.GetPermissionsAsync(id, Cancel); - public void SetupCreatePermissionsAsync(bool success, IPermissions? permissions = null) - { - var setup = MockPermissionsClient - .Setup(c => c.CreatePermissionsAsync( - It.IsAny(), - It.IsAny(), - Cancel)); + result.AssertSuccess(); - if (success) + MockHttpClient.AssertSingleRequest(r => { - Assert.NotNull(permissions); - - setup.Returns( - Task.FromResult>( - Result.Create(Result.Succeeded(), permissions))); - return; - } - - setup.Returns(Task.FromResult>(Result.Failed(new Exception()))); - } - - public void VerifyCreatePermissionsAsync(Times times) - { - MockPermissionsClient - .Verify(c => c.CreatePermissionsAsync( - It.IsAny(), - It.IsAny(), - Cancel), - times); + AssertSiteUri(r, $"test/{id}/permissions"); + r.AssertHttpMethod(HttpMethod.Get); + }); } - public void SetupUpdatePermissionsAsync(bool success, IPermissions? permissions = null) + [Fact] + public async Task ReponseError() { - var setup = MockPermissionsClient - .Setup(c => c.UpdatePermissionsAsync( - It.IsAny(), - It.IsAny(), - Cancel)); + SetupErrorResponse(); - if (success) - { - Assert.NotNull(permissions); + var result = await ApiClient.GetPermissionsAsync(Guid.NewGuid(), Cancel); - setup.Returns( - Task.FromResult>( - Result.Create(Result.Succeeded(), permissions))); - return; - } - - setup.Returns(Task.FromResult>(Result.Failed(new Exception()))); + result.AssertFailure(); } - public void SetupGetPermissionsAsync(bool success, IPermissions? permissions = null) + [Fact] + public async Task RequestException() { - var setup = MockPermissionsClient - .Setup(c => c.GetPermissionsAsync( - It.IsAny(), - Cancel)); + SetupExceptionResponse(); - if (success) - { - Assert.NotNull(permissions); - - setup.Returns( - Task.FromResult>( - Result.Create(Result.Succeeded(), permissions))); - return; - } + var result = await ApiClient.GetPermissionsAsync(Guid.NewGuid(), Cancel); - setup.Returns(Task.FromResult>(Result.Failed(new Exception()))); - } - - public void VerifyGetPermissionsAsync(Times times) - { - MockPermissionsClient - .Verify(c => c.GetPermissionsAsync( - It.IsAny(), - Cancel), - times); + result.AssertFailure(); } - - - #endregion } public class CreatePermissionsAsync : PermissionsApiClientTest @@ -209,65 +87,57 @@ public class CreatePermissionsAsync : PermissionsApiClientTest [Fact] public async Task Success() { + var id = Guid.NewGuid(); var destinationPermissions = Create(); - SetupCreatePermissionsAsync(true, destinationPermissions); + SetupSuccessResponse(); - var result = await PermissionsClient.CreatePermissionsAsync( - Guid.NewGuid(), - destinationPermissions, - Cancel); + var result = await ApiClient.CreatePermissionsAsync(id, destinationPermissions, Cancel); - Assert.True(result.Success); + result.AssertSuccess(); + + MockHttpClient.AssertSingleRequest(r => + { + AssertSiteUri(r, $"test/{id}/permissions"); + r.AssertHttpMethod(HttpMethod.Put); + }); } [Fact] - public async Task Does_not_create_when_no_grantees() + public async Task SkipsNoGrantees() { var destinationPermissions = Create(); destinationPermissions.GranteeCapabilities = Array.Empty(); - var result = await PermissionsClient.CreatePermissionsAsync( - Guid.NewGuid(), - destinationPermissions, - Cancel); + var result = await ApiClient.CreatePermissionsAsync(Guid.NewGuid(), destinationPermissions, Cancel); Assert.True(result.Success); - MockRestRequestBuilderFactory.VerifyNoOtherCalls(); + MockHttpClient.AssertNoRequests(); } - } - public class DeleteAllPermissionsAsync : PermissionsApiClientTest - { [Fact] - public async Task Success() + public async Task ReponseError() { var destinationPermissions = Create(); - SetupDeleteCapabilityAsync(true); + SetupErrorResponse(); - var result = await PermissionsClient.DeleteAllPermissionsAsync( - Guid.NewGuid(), - destinationPermissions, - Cancel); + var result = await ApiClient.CreatePermissionsAsync(Guid.NewGuid(), destinationPermissions, Cancel); - Assert.True(result.Success); + result.AssertFailure(); } [Fact] - public async Task Failure() + public async Task RequestException() { var destinationPermissions = Create(); - SetupDeleteCapabilityAsync(false); + SetupExceptionResponse(); - var result = await PermissionsClient.DeleteAllPermissionsAsync( - Guid.NewGuid(), - destinationPermissions, - Cancel); + var result = await ApiClient.CreatePermissionsAsync(Guid.NewGuid(), destinationPermissions, Cancel); - Assert.False(result.Success); + result.AssertFailure(); } } @@ -276,160 +146,78 @@ public class DeleteCapabilityAsync : PermissionsApiClientTest [Fact] public async Task Success() { - SetupDeleteCapabilityAsync(true); - - var result = await PermissionsClient.DeleteCapabilityAsync( - Guid.NewGuid(), - Guid.NewGuid(), - GranteeType.Group, - new Capability(new CapabilityType() - { - Name = PermissionsCapabilityNames.Read, - Mode = PermissionsCapabilityModes.Allow - }), - Cancel); - Assert.True(result.Success); - } - - [Fact] - public async Task Failure() - { - SetupDeleteCapabilityAsync(false); - - var result = await PermissionsClient.DeleteCapabilityAsync( - Guid.NewGuid(), - Guid.NewGuid(), - GranteeType.Group, - new Capability(new CapabilityType() - { - Name = PermissionsCapabilityNames.Read, - Mode = PermissionsCapabilityModes.Allow - }), - Cancel); - Assert.False(result.Success); - } - } - - public class DeletePermissionsAsync : PermissionsApiClientTest - { - public DeletePermissionsAsync() - { } - - [Fact] - public async Task Success() - { - var sourcePermissions = Create(); - var destinationPermissions = Create(); + SetupSuccessResponse(); - SetupDeleteCapabilityAsync(true); + var id = Guid.NewGuid(); + var result = await ApiClient.DeleteCapabilityAsync(id, Guid.NewGuid(), GranteeType.User, Create(), Cancel); - var result = await PermissionsClient.DeletePermissionsAsync( - Guid.NewGuid(), - sourcePermissions, - destinationPermissions, - Cancel); + result.AssertSuccess(); - Assert.True(result.Success); + MockHttpClient.AssertSingleRequest(r => + { + r.AssertHttpMethod(HttpMethod.Delete); + }); } [Fact] - public async Task Success_with_no_destination_permissions() + public async Task ReponseError() { - var sourcePermissions = Create(); - var destinationPermissions = Create(); - destinationPermissions.GranteeCapabilities = Array.Empty(); - - SetupDeletePermissionsAsync(true); + SetupErrorResponse(); - var result = await PermissionsClient.DeletePermissionsAsync( - Guid.NewGuid(), - sourcePermissions, - destinationPermissions, - Cancel); + var result = await ApiClient.DeleteCapabilityAsync(Guid.NewGuid(), Guid.NewGuid(), GranteeType.User, Create(), Cancel); - Assert.True(result.Success); + result.AssertFailure(); } [Fact] - public async Task Fails_upon_delete_capability_fail() + public async Task RequestException() { - var sourcePermissions = Create(); - var destinationPermissions = Create(); - - SetupDeleteCapabilityAsync(false); - SetupGetCapabilitiesToDelete(destinationPermissions.GranteeCapabilities); + SetupExceptionResponse(); - var result = await PermissionsClient.DeletePermissionsAsync( - Guid.NewGuid(), - sourcePermissions, - destinationPermissions, - Cancel); + var result = await ApiClient.DeleteCapabilityAsync(Guid.NewGuid(), Guid.NewGuid(), GranteeType.User, Create(), Cancel); - Assert.False(result.Success); - Assert.True(result.Errors.Count > 0); + result.AssertFailure(); } } public class UpdatePermissionsAsync : PermissionsApiClientTest { - public UpdatePermissionsAsync() - { } - [Fact] public async Task Success() { - var sourcePermissions = Create(); - var destinationPermissions = Create(); + var id = Guid.NewGuid(); - SetupGetPermissionsAsync(true, destinationPermissions); - SetupGetCapabilitiesToDelete(destinationPermissions.GranteeCapabilities); - SetupDeleteCapabilityAsync(true); - SetupCreatePermissionsAsync(true, destinationPermissions); + SetupSuccessResponse(); - var result = await PermissionsClient.UpdatePermissionsAsync( - Guid.NewGuid(), - sourcePermissions, - Cancel); + var result = await ApiClient.UpdatePermissionsAsync(id, Create(), Cancel); - Assert.True(result.Success); - - // Get permissions is called once. - VerifyGetPermissionsAsync(Times.Once()); - - // Delete capabilities is called the same number of times as - // the count of destination capabilities. - var capabilityCount = destinationPermissions.GranteeCapabilities.SelectMany(gc => gc.Capabilities).Count(); - VerifyDeleteCapabilityAsync(Times.Exactly(capabilityCount)); + result.AssertSuccess(); - // Create permissions is called once. - VerifyCreatePermissionsAsync(Times.Once()); + MockHttpClient.AssertSingleRequest(r => + { + AssertSiteUri(r, $"test/{id}/permissions"); + r.AssertHttpMethod(HttpMethod.Post); + }); } [Fact] - public async Task Fails_when_get_permissions_fails() + public async Task ReponseError() { - var sourcePermissions = Create(); - var destinationPermissions = Create(); + SetupErrorResponse(); - SetupGetPermissionsAsync(false); - SetupDeletePermissionsAsync(true); - SetupCreatePermissionsAsync(true, destinationPermissions); + var result = await ApiClient.UpdatePermissionsAsync(Guid.NewGuid(), Create(), Cancel); - var result = await PermissionsClient.UpdatePermissionsAsync( - Guid.NewGuid(), - sourcePermissions, - Cancel); - - Assert.False(result.Success); + result.AssertFailure(); + } - // Get permissions is called once. - VerifyGetPermissionsAsync(Times.Once()); + [Fact] + public async Task RequestException() + { + SetupExceptionResponse(); - // Delete capability is never called. - VerifyDeleteCapabilityAsync(Times.Never()); + var result = await ApiClient.UpdatePermissionsAsync(Guid.NewGuid(), Create(), Cancel); - // Create permissions is never called. - VerifyCreatePermissionsAsync(Times.Never()); + result.AssertFailure(); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/CommitWorkbookPublishRequestTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/CommitWorkbookPublishRequestTests.cs index a652a40..5b0adf9 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/CommitWorkbookPublishRequestTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/Models/Requests/CommitWorkbookPublishRequestTests.cs @@ -15,7 +15,6 @@ // limitations under the License. // -using System.Linq; using Tableau.Migration.Api.Models; using Tableau.Migration.Api.Rest.Models.Requests; using Xunit; @@ -48,7 +47,7 @@ public void Initializes() Assert.Equal(options.ProjectId, request.Workbook.Project.Id); - Assert.All(options.HiddenViewNames, v => Assert.Single(request.Workbook.Views.Where(wbv => wbv.Name == v && wbv.Hidden))); + Assert.All(options.HiddenViewNames, v => Assert.Single(request.Workbook.Views, wbv => wbv.Name == v && wbv.Hidden)); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs b/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs index af71056..28135fc 100644 --- a/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Api/Rest/RestExceptionTests.cs @@ -42,15 +42,19 @@ public void Initializes() .Returns(new LocalizedString("Error", "Error")); var error = Create(); + var correlationId = Guid.NewGuid().ToString(); + var exception = new RestException( HttpMethod.Get, new Uri("http://localhost"), + correlationId, error, mockLocalizer.Object); Assert.Equal(error.Code, exception.Code); Assert.Equal(error.Detail, exception.Detail); Assert.Equal(error.Summary, exception.Summary); + Assert.Equal(correlationId, exception.CorrelationId); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Api/SiteApiTestBase.cs b/tests/Tableau.Migration.Tests/Unit/Api/SiteApiTestBase.cs new file mode 100644 index 0000000..02b1ee3 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Api/SiteApiTestBase.cs @@ -0,0 +1,37 @@ +// +// 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; + +namespace Tableau.Migration.Tests.Unit.Api +{ + public abstract class SiteApiTestBase : ApiTestBase + { + protected readonly Guid SiteId = Guid.NewGuid(); + + protected readonly Guid UserId = Guid.NewGuid(); + + protected string SiteContentUrl => SiteConnectionConfiguration.SiteContentUrl; + + public SiteApiTestBase() + { + MockSessionProvider.SetupGet(p => p.SiteContentUrl).Returns(() => SiteContentUrl); + MockSessionProvider.SetupGet(p => p.SiteId).Returns(() => SiteId); + MockSessionProvider.SetupGet(p => p.UserId).Returns(() => UserId); + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Permissions/GranteeCapabilityTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Permissions/GranteeCapabilityTests.cs index 65f5cb9..6fb6967 100644 --- a/tests/Tableau.Migration.Tests/Unit/Content/Permissions/GranteeCapabilityTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Content/Permissions/GranteeCapabilityTests.cs @@ -106,11 +106,9 @@ public void Resolves_conflicting_modes() Assert.False(cap.Mode == PermissionsCapabilityModes.Allow && cap.Name == repeatedCapabilityName); }); - Assert.Single( - result.Where( - cap => - cap.Name == repeatedCapabilityName && - cap.Mode == PermissionsCapabilityModes.Deny)); + Assert.Single(result, cap => + cap.Name == repeatedCapabilityName && + cap.Mode == PermissionsCapabilityModes.Deny); } [Fact] public void Does_not_delete_all_allow() @@ -133,11 +131,9 @@ public void Does_not_delete_all_allow() // There should not be a capability that has a conflict with another (has the same name but different mode) Assert.Equal(uniqueNameCount + 1, result.Count); - Assert.Single( - result.Where( - cap => - cap.Name == repeatedCapabilityName && - cap.Mode == PermissionsCapabilityModes.Allow)); + Assert.Single(result, cap => + cap.Name == repeatedCapabilityName && + cap.Mode == PermissionsCapabilityModes.Allow); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs new file mode 100644 index 0000000..c89bbe1 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Cloud/CloudScheduleValidatorTests.cs @@ -0,0 +1,435 @@ +// +// 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; +using System.Collections.Generic; +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.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Content.Schedules.Cloud +{ + public class CloudScheduleValidatorTests + { + private readonly Mock> _loggerMock; + private readonly ISharedResourcesLocalizer _localizer; + private readonly CloudScheduleValidator _validator; + + public CloudScheduleValidatorTests() + { + // Create the real localizer + var services = new ServiceCollection(); + services.AddTableauMigrationSdk(); + var container = services.BuildServiceProvider(); + + _loggerMock = new Mock>(); + _localizer = container.GetRequiredService(); + _validator = new CloudScheduleValidator(_loggerMock.Object, _localizer); + } + + private Mock CreateMockSchedule(string frequency, string? start, string? end, List intervals) + { + var schedule = new Mock(); + var frequencyDetails = new Mock(); + + schedule.Setup(s => s.Frequency).Returns(frequency); + + if (start is not null) + frequencyDetails.Setup(f => f.StartAt).Returns(TimeOnly.Parse(start)); + if (end is not null) + frequencyDetails.Setup(f => f.EndAt).Returns(TimeOnly.Parse(end)); + + + frequencyDetails.Setup(f => f.Intervals).Returns(intervals); + + schedule.Setup(s => s.FrequencyDetails).Returns(frequencyDetails.Object); + return schedule; + } + + public class HourlyTests : CloudScheduleValidatorTests + { + private readonly string frequency = ScheduleFrequencies.Hourly; + + [Fact] + public void Validate_DoesNotThrow() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + // Act & Assert + var schedule = CreateMockSchedule(frequency, start: "12:00", end: "13:00", intervals); + _validator.Validate(schedule.Object); + } + + [Fact] + public void Validate_MissingStart() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: null, end: "13:00", new List { new Mock().Object }); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_MissingEnd() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveEndAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidStartEndDifference() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:30:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.StartEndTimeDifferenceError], exception.Message); + } + + [Fact] + public void Validate_HoursAndMinutesSet() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithMinutes(60), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ExactlyOneHourOrMinutesError], exception.Message); + } + + [Fact] + public void Validate_InvalidHourSet() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(2), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.IntervalMustBe1HourOr60MinError, frequency], exception.Message); + } + + [Fact] + public void Validate_MinutesSet() + { + // Arrange + var intervals = new List() + { + Interval.WithMinutes(30), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.IntervalMustBe1HourOr60MinError, frequency], exception.Message); + } + + [Fact] + public void Validate_NoWeekdaySet() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.AtLeastOneValidWeekdayError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidWeekday() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Thanksgiving") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.InvalidWeekdayError], exception.Message); + } + } + + public class DailyTests : CloudScheduleValidatorTests + { + private readonly string frequency = ScheduleFrequencies.Daily; + + [Fact] + public void Validate_DoesNotThrow() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(2), + Interval.WithWeekday("Monday") + }; + + // Act & Assert + var schedule = CreateMockSchedule(frequency, start: "12:00", end: "13:00", intervals); + _validator.Validate(schedule.Object); + } + + [Fact] + public void Validate_MissingStart() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(2), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: null, end: "13:00", new List { new Mock().Object }); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_NoWeekdaySet() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(2), + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.AtLeastOneValidWeekdayError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidWeekday() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(2), + Interval.WithWeekday("Thanksgiving") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.InvalidWeekdayError], exception.Message); + } + + [Fact] + public void Validate_InvalidHour() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(3), + Interval.WithWeekday("Tuesday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.InvalidHourlyIntervalForCloudError, frequency], exception.Message); + } + + [Fact] + public void Validate_MissingEnd() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(2), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveEndAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_MissingEnd_With24Hours() + { + // Note: The docs say if the hours are less then 24, then end is not required + // Testing on Tableau Cloud has shown that to be false. If hours are set, end is required. + + // Arrange + var intervals = new List() + { + Interval.WithHours(24), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveEndAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidStartEndDifference() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(2), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:30:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.StartEndTimeDifferenceError], exception.Message); + } + } + + public class WeeklyTests : CloudScheduleValidatorTests + { + private readonly string frequency = ScheduleFrequencies.Weekly; + + [Fact] + public void Validate_DoesNotThrow() + { + // Arrange + var intervals = new List() + { + Interval.WithWeekday("Wednesday") + }; + + // Act & Assert + var schedule = CreateMockSchedule(frequency, start: "12:00", end: null, intervals); + _validator.Validate(schedule.Object); + } + + [Fact] + public void Validate_InvalidWeekdayCount() + { + // Arrange + var intervals = new List() + { + Interval.WithWeekday("Monday"), + Interval.WithWeekday("Wednesday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveExactlyOneWeekdayIntervalError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidWeekday() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Thanksgiving") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.InvalidWeekdayError], exception.Message); + } + + [Fact] + public void Validate_HasEndTime() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustNotHaveEndAtTimeError, frequency], exception.Message); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ILoggerExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ILoggerExtensionsTests.cs deleted file mode 100644 index 4285902..0000000 --- a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ILoggerExtensionsTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -// -// 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; -using System.Collections.Generic; -using System.Collections.Immutable; -using Microsoft.Extensions.Logging; -using Tableau.Migration.Api.Rest.Models.Types; -using Tableau.Migration.Content.Schedules; -using Xunit; - -namespace Tableau.Migration.Tests.Unit.Content.Schedules -{ - public class ILoggerExtensionsTests - { - private static readonly string LogMessage = $"Guid: {{0}}{Environment.NewLine}Original:{Environment.NewLine}{{1}}{Environment.NewLine}Updated:{Environment.NewLine}{{2}}"; - - public abstract class ILoggerExtensionsTest : AutoFixtureTestBase - { - protected readonly TestLogger Logger = new(); - } - - public class LogIntervalsChanges : ILoggerExtensionsTest - { - [Fact] - public void Logs_changes_when_intervals_differ() - { - // Arrange - var originalIntervals = new List - { - Interval.WithHours(1), - Interval.WithMinutes(1), - Interval.WithWeekday(WeekDays.Monday), - Interval.WithWeekday(WeekDays.Tuesday) - }.ToImmutableList(); - var newIntervals = new List - { - Interval.WithHours(2), - Interval.WithMinutes(15), - Interval.WithMonthDay("24") - }.ToImmutableList(); - - // Act - var result = Logger.LogIntervalsChanges( - LogMessage, - Guid.NewGuid(), - originalIntervals, - newIntervals); - - // Assert - Assert.True(result); - - var message = Assert.Single(Logger.Messages); - Assert.Equal(LogLevel.Warning, message.LogLevel); - } - - [Fact] - public void Does_not_log_changes_when_intervals_same() - { - // Arrange - var intervals = new List() - { - Interval.WithHours(1), - Interval.WithMinutes(1), - Interval.WithWeekday(WeekDays.Monday) - }; - - // Act - var result = Logger.LogIntervalsChanges( - LogMessage, - Guid.NewGuid(), - intervals.ToImmutableList(), - intervals.ToImmutableList()); - - // Asserts - Assert.False(result); - Assert.Empty(Logger.Messages); - } - } - } -} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/IntervalComparerTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/IntervalComparerTests.cs new file mode 100644 index 0000000..03f08ce --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/IntervalComparerTests.cs @@ -0,0 +1,71 @@ +// +// 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.Collections.Generic; +using System.Collections.Immutable; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content.Schedules; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Content.Schedules +{ + public class IntervalComparerTests + { + [Fact] + public void Intervals_differ() + { + // Arrange + var originalIntervals = new List + { + Interval.WithHours(1), + Interval.WithMinutes(1), + Interval.WithWeekday(WeekDays.Monday), + Interval.WithWeekday(WeekDays.Tuesday) + }.ToImmutableList(); + var newIntervals = new List + { + Interval.WithHours(2), + Interval.WithMinutes(15), + Interval.WithMonthDay("24") + }.ToImmutableList(); + + // Act + var result = ScheduleComparers.IntervalsComparer.Equals(originalIntervals, newIntervals); + + // Assert + Assert.False(result); + } + + [Fact] + public void Intervals_same() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithMinutes(1), + Interval.WithWeekday(WeekDays.Monday) + }; + + // Act + var result = ScheduleComparers.IntervalsComparer.Equals(intervals, intervals); + + // Asserts + Assert.True(result); + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs new file mode 100644 index 0000000..fc010f7 --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/Server/ServerScheduleValidatorTests.cs @@ -0,0 +1,302 @@ +// +// 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; +using System.Collections.Generic; +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.Server; +using Tableau.Migration.Resources; +using Xunit; + +namespace Tableau.Migration.Tests.Content.Schedules.Server +{ + public class ServerScheduleValidatorTests + { + private readonly Mock> _loggerMock; + private readonly ISharedResourcesLocalizer _localizer; + private readonly ServerScheduleValidator _validator; + + public ServerScheduleValidatorTests() + { + // Create the real localizer + var services = new ServiceCollection(); + services.AddTableauMigrationSdk(); + var container = services.BuildServiceProvider(); + + _loggerMock = new Mock>(); + _localizer = container.GetRequiredService(); + _validator = new ServerScheduleValidator(_loggerMock.Object, _localizer); + } + + private Mock CreateMockSchedule(string frequency, string? start, string? end, List intervals) + { + var schedule = new Mock(); + var frequencyDetails = new Mock(); + + schedule.Setup(s => s.Frequency).Returns(frequency); + + if (start is not null) + frequencyDetails.Setup(f => f.StartAt).Returns(TimeOnly.Parse(start)); + if (end is not null) + frequencyDetails.Setup(f => f.EndAt).Returns(TimeOnly.Parse(end)); + + + frequencyDetails.Setup(f => f.Intervals).Returns(intervals); + + schedule.Setup(s => s.FrequencyDetails).Returns(frequencyDetails.Object); + return schedule; + } + + public class HourlyTests : ServerScheduleValidatorTests + { + private readonly string frequency = ScheduleFrequencies.Hourly; + + [Fact] + public void Validate_DoesNotThrow() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + _validator.Validate(schedule.Object); + } + + [Fact] + public void Validate_MissingStart() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: null, end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_MissingEnd() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(1), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveEndAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_NoIntervals() + { + // Arrange + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", new List()); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.AtLeastOneIntervalWithHourOrMinutesError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidInterval() + { + // Arrange + var badInterval = new Mock(); + badInterval.Setup(i => i.WeekDay).Returns((string?)null); + + //var intervals = new List() + //{ + // badInterval.Object + //}; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", new List { badInterval.Object }); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.AtLeastOneIntervalWithHourOrMinutesError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidHour() + { + // Arrange + var intervals = new List() + { + Interval.WithHours(3), + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.InvalidHourlyIntervalForServerError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidDay() + { + // Arrange + var intervals = new List() + { + Interval.WithWeekday("Thanksgiving") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: "13:00:00", intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.AtLeastOneIntervalWithHourOrMinutesError, frequency], exception.Message); + } + } + + public class DailyTests : ServerScheduleValidatorTests + { + private readonly string frequency = ScheduleFrequencies.Daily; + + [Fact] + public void Validate_DoesNotThrow() + { + // Arrange + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, new List()); + + // Act & Assert + _validator.Validate(schedule.Object); + } + + [Fact] + public void Validate_MissingStart() + { + // Arrange + var schedule = CreateMockSchedule(frequency, start: null, end: null, new List()); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, frequency], exception.Message); + } + } + + public class WeeklyTests : ServerScheduleValidatorTests + { + private readonly string frequency = ScheduleFrequencies.Weekly; + + [Fact] + public void Validate_DoesNotThrow() + { + // Arrange + var intervals = new List() + { + Interval.WithWeekday("Monday") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + _validator.Validate(schedule.Object); + } + + [Fact] + public void Validate_MissingStart() + { + // Arrange + var schedule = CreateMockSchedule(frequency, start: null, end: null, new List()); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_NoInterval() + { + // Arrange + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, new List()); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.WeeklyScheduleIntervalError], exception.Message); + } + } + + public class MonthlyWeeklyTests : ServerScheduleValidatorTests + { + private readonly string frequency = ScheduleFrequencies.Monthly; + + [Fact] + public void Validate_DoesNotThrow() + { + // Arrange + var intervals = new List() + { + Interval.WithMonthDay("1") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + _validator.Validate(schedule.Object); + } + + [Fact] + public void Validate_MissingStart() + { + // Arrange + var schedule = CreateMockSchedule(frequency, start: null, end: null, new List()); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.ScheduleMustHaveStartAtTimeError, frequency], exception.Message); + } + + [Fact] + public void Validate_InvalidMonthday() + { + // Arrange + var intervals = new List() + { + Interval.WithMonthDay("Thankgiving") + }; + + var schedule = CreateMockSchedule(frequency, start: "12:00:00", end: null, intervals); + + // Act & Assert + var exception = Assert.Throws(() => _validator.Validate(schedule.Object)); + Assert.Equal(_localizer[SharedResourceKeys.InvalidMonthDayError], exception.Message); + } + + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ServerToCloudExtractRefreshTaskConverterTests.cs b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ServerToCloudExtractRefreshTaskConverterTests.cs new file mode 100644 index 0000000..9ecfdca --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Content/Schedules/ServerToCloudExtractRefreshTaskConverterTests.cs @@ -0,0 +1,391 @@ +// +// 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; +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/Engine/Actions/PreflightActionTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Actions/PreflightActionTests.cs index ca19d4e..01b55fe 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Actions/PreflightActionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Actions/PreflightActionTests.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2024, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -16,6 +16,7 @@ // using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; @@ -24,6 +25,7 @@ using Tableau.Migration.Content; using Tableau.Migration.Engine.Actions; using Tableau.Migration.Engine.Endpoints; +using Tableau.Migration.Engine.Hooks; using Xunit; namespace Tableau.Migration.Tests.Unit.Engine.Actions @@ -50,6 +52,8 @@ public class ExecuteAsync : AutoFixtureTestBase protected Mock> MockLogger { get; } + protected Mock MockHookRunner { get; } + public ExecuteAsync() { Options = Freeze(); @@ -76,6 +80,10 @@ public ExecuteAsync() .ReturnsAsync(() => UpdateResult); MockLogger = Freeze>>(); + + MockHookRunner = Freeze>(); + MockHookRunner.Setup(x => x.ExecuteAsync(It.IsAny(), Cancel)) + .ReturnsAsync((IInitializeMigrationHookResult ctx, CancellationToken cancel) => ctx); } [Fact] @@ -94,6 +102,8 @@ public async Task SettingValidationDisabledAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Never); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Once); } [Fact] @@ -112,6 +122,8 @@ public async Task SourceSessionFailedAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Never); } [Fact] @@ -130,6 +142,8 @@ public async Task DestinationSessionFailedAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Never); } [Fact] @@ -155,6 +169,8 @@ public async Task CombinesEndpointErrorsAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Never); } [Fact] @@ -173,6 +189,8 @@ public async Task NotSourceAdminAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Once); } [Fact] @@ -191,6 +209,8 @@ public async Task NotDestinationAdminAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Once); } [Fact] @@ -210,6 +230,8 @@ public async Task ValidSameSettingAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Once); } [Fact] @@ -229,6 +251,8 @@ public async Task ExtractEncryptionValidSourceDisabledAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Once); } [Fact] @@ -248,6 +272,29 @@ public async Task ExtractEncryptionValidCompatibleAsync() MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Once); + } + + [Fact] + public async Task InitializeHookFailsAsync() + { + MockHookRunner.Setup(x => x.ExecuteAsync(It.IsAny(), Cancel)) + .ReturnsAsync((IInitializeMigrationHookResult ctx, CancellationToken cancel) => ctx.ToFailure(new Exception())); + + var action = Create(); + + var result = await action.ExecuteAsync(Cancel); + + result.AssertFailure(); + Assert.False(result.PerformNextAction); + + MockSource.Verify(x => x.GetSessionAsync(Cancel), Times.Once); + MockDestination.Verify(x => x.GetSessionAsync(Cancel), Times.Once); + + MockLogger.VerifyWarnings(Times.Never); + + MockHookRunner.Verify(x => x.ExecuteAsync(It.IsAny(), Cancel), Times.Once); } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestDestinationContentReferenceFinderTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestDestinationContentReferenceFinderTests.cs index 368d5f7..00d8980 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestDestinationContentReferenceFinderTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestDestinationContentReferenceFinderTests.cs @@ -63,7 +63,7 @@ public async Task FindsWithCachedMappedLocationAsync() var mappedLoc = Create(); var entry = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1) - .CreateEntries(new[] { sourceItem }, (i, e) => e); + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0); entry.Single().MapToDestination(mappedLoc); @@ -86,7 +86,7 @@ public async Task FindsById() sourceItem.Location = Create(); var entry = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1) - .CreateEntries(new[] { sourceItem }, (i, e) => e); + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0); var cacheItem = Create(); @@ -127,7 +127,7 @@ public async Task FindsByContentUrl() sourceItem.Location = Create(); var entry = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1) - .CreateEntries(new[] { sourceItem }, (i, e) => e); + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0); var cacheItem = Create(); @@ -166,7 +166,7 @@ public async Task FindsWithMappedLocationFromManifestAsync() var entryBuilder = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1); var entries = entryBuilder - .CreateEntries(new[] { sourceItem }, (i, e) => e); + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0); var mockMapping = Freeze>(); mockMapping.Setup(x => x.ExecuteAsync(It.IsAny>(), Cancel)) @@ -194,7 +194,7 @@ public async Task FindsWithMappedLocationNoDestinationAsync() var entryBuilder = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1); var entries = entryBuilder - .CreateEntries(new[] { sourceItem }, (i, e) => e); + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0); var mockMapping = Freeze>(); mockMapping.Setup(x => x.ExecuteAsync(It.IsAny>(), Cancel)) @@ -221,7 +221,7 @@ public async Task FindsWithCachedDestinationLocationAsync() var mappedLoc = Create(); var entries = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1) - .CreateEntries(new[] { sourceItem }, (i, e) => e); + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0); var entry = entries.Single().MapToDestination(mappedLoc); @@ -251,7 +251,7 @@ public async Task FindsWithManifestEntryDestinationInfoAsync() var destinationInfo = Create(); var entry = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1) - .CreateEntries(new[] { sourceItem }, (i, e) => e); + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0); entry.Single().DestinationFound(destinationInfo); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestSourceContentReferenceFinderTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestSourceContentReferenceFinderTests.cs index f409a7b..ba6aae6 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestSourceContentReferenceFinderTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Endpoints/Search/ManifestSourceContentReferenceFinderTests.cs @@ -65,7 +65,7 @@ public async Task FindsManifestReferenceAsync() var sourceItem = Create(); var entry = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1) - .CreateEntries(new[] { sourceItem }, (i, e) => e) + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0) .Single(); var result = await Finder.FindByIdAsync(sourceItem.Id, Cancel); @@ -108,7 +108,7 @@ public async Task FindsManifestReferenceAsync() var sourceItem = Create(); var entry = Manifest.Entries.GetOrCreatePartition().GetEntryBuilder(1) - .CreateEntries(new[] { sourceItem }, (i, e) => e) + .CreateEntries(new[] { sourceItem }, (i, e) => e, 0) .Single(); var result = await Finder.FindBySourceLocationAsync(sourceItem.Location, Cancel); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigrationHookResultTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigrationHookResultTests.cs new file mode 100644 index 0000000..e123adc --- /dev/null +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/InitializeMigrationHookResultTests.cs @@ -0,0 +1,75 @@ +// +// 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; +using System.Linq; +using Tableau.Migration.Engine.Hooks; +using Xunit; + +namespace Tableau.Migration.Tests.Unit.Engine.Hooks +{ + public sealed class InitializeMigrationHookResultTests + { + public sealed class Succeeded : AutoFixtureTestBase + { + [Fact] + public void Initializes() + { + var services = Create(); + + var r = InitializeMigrationHookResult.Succeeded(services); + + Assert.Same(services, r.ScopedServices); + } + } + + public sealed class ToFailure : AutoFixtureTestBase + { + [Fact] + public void SetsFailure() + { + var services = Create(); + + var errors = CreateMany().ToArray(); + + IInitializeMigrationHookResult r = InitializeMigrationHookResult.Succeeded(services); + r = r.ToFailure(errors); + + r.AssertFailure(); + + Assert.Equal(errors, r.Errors); + } + + [Fact] + public void AppendsErrors() + { + var services = Create(); + + var errors1 = CreateMany().ToArray(); + var errors2 = CreateMany().ToArray(); + + IInitializeMigrationHookResult r = InitializeMigrationHookResult.Succeeded(services); + r = r.ToFailure(errors1); + r = r.ToFailure(errors2); + + r.AssertFailure(); + + Assert.Equal(errors1.Concat(errors2), r.Errors); + } + } + } +} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformerTests.cs deleted file mode 100644 index 7a47e6e..0000000 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudIncrementalRefreshTransformerTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -// -// 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.Collections.Immutable; -using System.Threading.Tasks; -using Tableau.Migration.Api.Rest.Models.Types; -using Tableau.Migration.Content.Schedules; -using Tableau.Migration.Content.Schedules.Cloud; -using Tableau.Migration.Engine.Hooks.Transformers.Default; -using Xunit; - -namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Transformers.Default -{ - public class CloudIncrementalRefreshTransformerTests : AutoFixtureTestBase - { - protected readonly TestLogger Logger = new(); - protected readonly MockSharedResourcesLocalizer MockSharedResourcesLocalizer = new(); - protected readonly CloudIncrementalRefreshTransformer Transformer; - - public CloudIncrementalRefreshTransformerTests() - { - Transformer = new(MockSharedResourcesLocalizer.Object, Logger); - } - - [Fact] - public async Task Transforms_server_incremental_refresh() - { - // Arrange - var input = Create(); - input.Type = ExtractRefreshType.ServerIncrementalRefresh; - input.Schedule.FrequencyDetails.Intervals = ImmutableList.Create(Interval.WithMonthDay("10")); - input.Schedule.Frequency = ScheduleFrequencies.Monthly; - - // Act - var result = await Transformer.ExecuteAsync(input, Cancel); - - // Assert - Assert.NotNull(result); - Assert.Equal(ExtractRefreshType.CloudIncrementalRefresh, result.Type); - } - - [Fact] - public async Task Noop_full_refresh() - { - // Arrange - var input = Create(); - input.Type = ExtractRefreshType.FullRefresh; - input.Schedule.FrequencyDetails.Intervals = ImmutableList.Create(Interval.WithMonthDay("10")); - input.Schedule.Frequency = ScheduleFrequencies.Monthly; - - // Act - var result = await Transformer.ExecuteAsync(input, Cancel); - - // Assert - Assert.NotNull(result); - Assert.Equal(ExtractRefreshType.FullRefresh, result.Type); - } - } -} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformerTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformerTests.cs deleted file mode 100644 index f0fa912..0000000 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Hooks/Transformers/Default/CloudScheduleCompatibilityTransformerTests.cs +++ /dev/null @@ -1,101 +0,0 @@ -// -// 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.Collections.Immutable; -using System.Linq; -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.Engine.Hooks.Transformers.Default; -using Xunit; - -namespace Tableau.Migration.Tests.Unit.Engine.Hooks.Transformers.Default -{ - public class CloudScheduleCompatibilityTransformerTests : AutoFixtureTestBase - { - protected readonly TestLogger> Logger = new(); - protected readonly MockSharedResourcesLocalizer MockSharedResourcesLocalizer = new(); - protected readonly CloudScheduleCompatibilityTransformer Transformer; - - public CloudScheduleCompatibilityTransformerTests() - { - Transformer = new(MockSharedResourcesLocalizer.Object, Logger); - } - - [Theory] - [InlineData(ScheduleFrequencies.Monthly)] - [InlineData(ScheduleFrequencies.Daily)] - public async Task Skips_intervals_longer_than_1_hour(string frequency) - { - // Arrange - var input = Create(); - input.Type = ExtractRefreshType.FullRefresh; - input.Schedule.Frequency = frequency; - - // Act - var result = await Transformer.ExecuteAsync(input, Cancel); - - // Assert - Assert.NotNull(result); - Assert.Equal(input.Schedule.FrequencyDetails.Intervals.Count, result.Schedule.FrequencyDetails.Intervals.Count); - Assert.Empty(Logger.Messages.Where(m => m.LogLevel == LogLevel.Warning).ToList()); - } - - [Fact] - public async Task Transforms_intervals_shorter_than_1_hour() - { - // Arrange - var input = Create(); - input.Type = ExtractRefreshType.FullRefresh; - input.Schedule.FrequencyDetails.Intervals = ImmutableList.Create(Interval.WithMinutes(15)); - input.Schedule.Frequency = ScheduleFrequencies.Hourly; - - // Act - var result = await Transformer.ExecuteAsync(input, Cancel); - - // Assert - Assert.NotNull(result); - Assert.Single(result.Schedule.FrequencyDetails.Intervals); - Assert.Equal(Interval.WithHours(1), result.Schedule.FrequencyDetails.Intervals[0]); - Assert.Single(Logger.Messages.Where(m => m.LogLevel == LogLevel.Warning)); - } - - [Fact] - public async Task Transforms_weekly_intervals_with_multiple_weekdays() - { - // Arrange - var input = Create(); - input.Type = ExtractRefreshType.FullRefresh; - input.Schedule.FrequencyDetails.Intervals = ImmutableList.Create( - Interval.WithWeekday(WeekDays.Sunday), - Interval.WithWeekday(WeekDays.Monday)); - input.Schedule.Frequency = ScheduleFrequencies.Weekly; - - // Act - var result = await Transformer.ExecuteAsync(input, Cancel); - - // Assert - Assert.NotNull(result); - Assert.Single(result.Schedule.FrequencyDetails.Intervals); - Assert.Equal(Interval.WithWeekday(WeekDays.Sunday), result.Schedule.FrequencyDetails.Intervals[0]); - Assert.Single(Logger.Messages.Where(m => m.LogLevel == LogLevel.Warning)); - } - } -} diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs index 2e54973..58d22fe 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestContentTypePartitionTests.cs @@ -15,7 +15,9 @@ // limitations under the License. // +using System; using System.Collections; +using System.Collections.Concurrent; using System.Collections.Immutable; using System.Linq; using System.Threading; @@ -52,6 +54,19 @@ public MigrationManifestContentTypePartitionTest() protected MigrationManifestContentTypePartition CreateEmpty() => new(typeof(T), MockLocalizer.Object, MockLogger.Object); + + protected void AssertCorrectTotals() + { + var expectedTotals = ImmutableDictionary.CreateBuilder(); + foreach (var status in Enum.GetValues()) + { + expectedTotals[status] = Partition.Where(e => e.Status == status).Count(); + } + + var statusTotals = Partition.GetStatusTotals(); + + Assert.Equal(expectedTotals.ToImmutable(), statusTotals); + } } #endregion @@ -96,6 +111,8 @@ public void DeepCopiesFromPreviousManifest() Partition.CreateEntries(entries); Assert.Equal(entries.Length, Partition.Count); + Assert.Equal(entries.Length, Partition.ExpectedTotalCount); + Assert.All(entries, e => { Assert.True(Partition.BySourceLocation.ContainsKey(e.Source.Location)); @@ -107,6 +124,8 @@ public void DeepCopiesFromPreviousManifest() Assert.True(Partition.BySourceContentUrl.ContainsKey(e.Source.ContentUrl)); Assert.NotSame(e, Partition.BySourceContentUrl[e.Source.ContentUrl]); }); + + AssertCorrectTotals(); } [Fact] @@ -119,18 +138,26 @@ public void DeepCopiesFromPreviousManifestWithEmptyContentUrl() Partition.CreateEntries(entries); Assert.Equal(entries.Length, Partition.Count); + Assert.Equal(entries.Length, Partition.ExpectedTotalCount); + Assert.Empty(Partition.BySourceContentUrl); + + AssertCorrectTotals(); } [Fact] public void CreatesFromSourceItems() { var sourceItems = CreateMany().ToImmutableArray(); + var expectedTotalCount = 2 * sourceItems.Length; - var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry)); + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), expectedTotalCount); Assert.Equal(sourceItems.Length, Partition.Count); Assert.Equal(sourceItems.Length, results.Length); + Assert.Equal(expectedTotalCount, Partition.ExpectedTotalCount); + Assert.All(results, r => { var e = r.Entry; @@ -141,6 +168,8 @@ public void CreatesFromSourceItems() Assert.Same(e, Partition.BySourceId[e.Source.Id]); Assert.Same(e, Partition.BySourceContentUrl[e.Source.ContentUrl]); }); + + AssertCorrectTotals(); } [Fact] @@ -149,13 +178,18 @@ public void CreatesFromSourceItemsWithEmptyContentUrl() SetupEmptyContentUrls(); var sourceItems = CreateMany().ToImmutableArray(); + var expectedTotalCount = 2 * sourceItems.Length; - var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry)); + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), expectedTotalCount); Assert.Equal(sourceItems.Length, Partition.Count); Assert.Equal(sourceItems.Length, results.Length); + Assert.Equal(expectedTotalCount, Partition.ExpectedTotalCount); Assert.Empty(Partition.BySourceContentUrl); + + AssertCorrectTotals(); } [Fact] @@ -174,7 +208,8 @@ public void UpdatesSourceReferenceFromPreviousManifest() return newSourceItem; }).ToImmutableArray(); - var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry)); + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), previous.Length); Assert.All(sourceItems, i => { @@ -193,6 +228,8 @@ public void UpdatesSourceReferenceFromPreviousManifest() var result = results.Single(r => r.SourceItem == i); Assert.Same(newEntry, result.Entry); }); + + AssertCorrectTotals(); } [Fact] @@ -213,9 +250,12 @@ public void UpdatesSourceReferenceFromPreviousManifestWithEmptyContentUrl() return newSourceItem; }).ToImmutableArray(); - var results = Partition.CreateEntries(sourceItems, (item, entry) => (item, entry)); + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), previous.Length); Assert.Empty(Partition.BySourceContentUrl); + + AssertCorrectTotals(); } } @@ -305,7 +345,7 @@ public async Task MapsEntriesAsync() var entryBuilder = Partition.GetEntryBuilder(COUNT); var entries = entryBuilder - .CreateEntries(items, (i, e) => new ContentMigrationItem(i, e)); + .CreateEntries(items, (i, e) => new ContentMigrationItem(i, e), 0); var result = await entryBuilder.MapEntriesAsync(items, _mockMappingRunner.Object, _cancel); @@ -395,6 +435,77 @@ public void NoDestinationInfoRemoved() #endregion + #region - StatusUpdated - + + public sealed class StatusUpdated : MigrationManifestContentTypePartitionTest + { + [Fact] + public void Skipped() + { + var sourceItems = CreateMany().ToImmutableArray(); + + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), 0); + + results[0].Entry.SetSkipped(); + + var statusTotals = Partition.GetStatusTotals(); + + Assert.Equal(sourceItems.Length - 1, statusTotals[MigrationManifestEntryStatus.Pending]); + Assert.Equal(1, statusTotals[MigrationManifestEntryStatus.Skipped]); + } + + [Fact] + public void Failed() + { + var sourceItems = CreateMany().ToImmutableArray(); + + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), 0); + + results[0].Entry.SetFailed(CreateMany()); + + var statusTotals = Partition.GetStatusTotals(); + + Assert.Equal(sourceItems.Length - 1, statusTotals[MigrationManifestEntryStatus.Pending]); + Assert.Equal(1, statusTotals[MigrationManifestEntryStatus.Error]); + } + + [Fact] + public void Canceled() + { + var sourceItems = CreateMany().ToImmutableArray(); + + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), 0); + + results[0].Entry.SetCanceled(); + + var statusTotals = Partition.GetStatusTotals(); + + Assert.Equal(sourceItems.Length - 1, statusTotals[MigrationManifestEntryStatus.Pending]); + Assert.Equal(1, statusTotals[MigrationManifestEntryStatus.Canceled]); + } + + [Fact] + public void Migrated() + { + var sourceItems = CreateMany().ToImmutableArray(); + + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), 0); + + results[0].Entry.SetMigrated(); + + var statusTotals = Partition.GetStatusTotals(); + + Assert.Equal(sourceItems.Length - 1, statusTotals[MigrationManifestEntryStatus.Pending]); + Assert.Equal(1, statusTotals[MigrationManifestEntryStatus.Migrated]); + } + } + + #endregion + #region - Equality - public class EqualityTests : MigrationManifestContentTypePartitionTest @@ -406,8 +517,8 @@ public void Equal() MigrationManifestContentTypePartition p1 = CreateEmpty(); MigrationManifestContentTypePartition p2 = CreateEmpty(); - p1.CreateEntries(sourceItems, (item, entry) => (item, entry)); - p2.CreateEntries(sourceItems, (item, entry) => (item, entry)); + p1.CreateEntries(sourceItems, (item, entry) => (item, entry), 0); + p2.CreateEntries(sourceItems, (item, entry) => (item, entry), 0); Assert.True(p1.Equals(p1)); Assert.True(p1.Equals(p2)); @@ -427,8 +538,8 @@ public void DifferentTypeSameSource() MigrationManifestContentTypePartition p1 = CreateEmpty(); MigrationManifestContentTypePartition p2 = CreateEmpty(); - p1.CreateEntries(sourceItems, (item, entry) => (item, entry)); - p2.CreateEntries(sourceItems, (item, entry) => (item, entry)); + p1.CreateEntries(sourceItems, (item, entry) => (item, entry), 0); + p2.CreateEntries(sourceItems, (item, entry) => (item, entry), 0); Assert.False(p1.Equals(p2)); Assert.False(p2.Equals(p1)); @@ -448,8 +559,8 @@ public void SameTypeDifferentSource() MigrationManifestContentTypePartition p1 = CreateEmpty(); MigrationManifestContentTypePartition p2 = CreateEmpty(); - p1.CreateEntries(sourceItems1, (item, entry) => (item, entry)); - p2.CreateEntries(sourceItems2, (item, entry) => (item, entry)); + p1.CreateEntries(sourceItems1, (item, entry) => (item, entry), 0); + p2.CreateEntries(sourceItems2, (item, entry) => (item, entry), 0); Assert.False(p1.Equals(p2)); Assert.False(p2.Equals(p1)); @@ -463,6 +574,66 @@ public void SameTypeDifferentSource() } + #endregion + + #region - GetStatusTotals - + + public sealed class GetStatusTotals : MigrationManifestContentTypePartitionTest + { + [Fact] + public void AllStatusValuesIncluded() + { + var sourceItems = CreateMany().ToImmutableArray(); + + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), 0); + + var statusTotals = Partition.GetStatusTotals(); + + Assert.Equal(Enum.GetValues().Length, statusTotals.Count); + } + } + + #endregion + + #region - ExpectedTotalCount - + + public sealed class ExpectedTotalCount : MigrationManifestContentTypePartitionTest + { + [Fact] + public void CopiedEntries() + { + var entries = CreateMany().ToImmutableArray(); + + Partition.CreateEntries(entries); + + Assert.Equal(entries.Length, Partition.ExpectedTotalCount); + } + + [Fact] + public void FromPagedTotalCount() + { + var sourceItems = CreateMany().ToImmutableArray(); + var expectedTotalCount = 2 * sourceItems.Length; + + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), expectedTotalCount); + + Assert.Equal(expectedTotalCount, Partition.ExpectedTotalCount); + } + + [Fact] + public void NotLessThanActualCount() + { + var sourceItems = CreateMany().ToImmutableArray(); + + var results = Partition.CreateEntries(sourceItems, + (item, entry) => (item, entry), sourceItems.Length - 1); + + Assert.Equal(sourceItems.Length, Partition.ExpectedTotalCount); + } + } + #endregion } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs index 3bef19a..f89faac 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryCollectionTests.cs @@ -58,7 +58,7 @@ protected MigrationManifestEntryCollection CreateEmpty() #region - Ctor - - public class Ctor : MigrationManifestEntryCollectionTest + public sealed class Ctor : MigrationManifestEntryCollectionTest { [Fact] public void IntializesEmpty() @@ -70,10 +70,23 @@ public void IntializesEmpty() [Fact] public void CopiesFromPreviousEntries() { + var previousEntries = CreateMany().ToImmutableArray(); + var mockPreviousCollection = Create>(); + mockPreviousCollection.Setup(x => x.CopyTo(It.IsAny())) + .Callback(e => + { + e.GetOrCreatePartition(typeof(IWorkbook)).CreateEntries(previousEntries); + }); var c = new MigrationManifestEntryCollection(MockLocalizer.Object, MockLoggerFactory.Object, mockPreviousCollection.Object); mockPreviousCollection.Verify(x => x.CopyTo(c), Times.Once); + + Assert.Equal(previousEntries.Length, c.Count()); + foreach (var entry in c) + { + Assert.Equal(MigrationManifestEntryStatus.Pending, entry.Status); + } } } diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs index d4330a1..a8df3aa 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Manifest/MigrationManifestEntryTests.cs @@ -26,9 +26,9 @@ namespace Tableau.Migration.Tests.Unit.Engine.Manifest { - public class MigrationManifestEntryTests + public sealed class MigrationManifestEntryTests { - public class MigrationManifestEntryTest : AutoFixtureTestBase + public abstract class MigrationManifestEntryTest : AutoFixtureTestBase { public MigrationManifestEntryTest() { @@ -48,12 +48,14 @@ public MigrationManifestEntryTest() #region - Ctor - - public class Ctor : MigrationManifestEntryTest + public sealed class Ctor : MigrationManifestEntryTest { IMigrationManifestEntry CreateManifestEntry() { - var errors = new List(); - errors.Add(new Exception("Test Error")); + var errors = new List + { + new Exception("Test Error") + }; var ret = new Mock(); ret.Setup(x => x.Source).Returns(Create()); @@ -77,6 +79,8 @@ public void FromSourceReference() Assert.Null(e.Destination); Assert.Equal(MigrationManifestEntryStatus.Pending, e.Status); Assert.Empty(e.Errors); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, It.IsAny()), Times.Never); } [Fact] @@ -92,6 +96,8 @@ public void FromPreviousMigration() Assert.Equal(previousEntry.HasMigrated, e.HasMigrated); Assert.Equal(previousEntry.Destination, e.Destination); Assert.Equal(previousEntry.Errors, e.Errors); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, It.IsAny()), Times.Never); } [Fact] @@ -109,6 +115,8 @@ public void FromUpdatedPreviousMigration() Assert.Equal(previousEntry.HasMigrated, e.HasMigrated); Assert.Equal(previousEntry.Destination, e.Destination); Assert.Equal(previousEntry.Errors, e.Errors); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, It.IsAny()), Times.Never); } } @@ -116,7 +124,7 @@ public void FromUpdatedPreviousMigration() #region - MapToDestination - - public class MapToDestination : MigrationManifestEntryTest + public sealed class MapToDestination : MigrationManifestEntryTest { [Fact] public void MapsToDestination() @@ -169,7 +177,7 @@ public void RetainsDestinationInfoWithMatch() #region - DestinationFound - - public class DestinationFound : MigrationManifestEntryTest + public sealed class DestinationFound : MigrationManifestEntryTest { [Fact] public void SetsDestinationInfoAndMappedLocation() @@ -210,7 +218,7 @@ public void NotifiesOnUpdate() #region - SetSkipped - - public class SetSkipped : MigrationManifestEntryTest + public sealed class SetSkipped : MigrationManifestEntryTest { [Fact] public void SetsStatus() @@ -221,6 +229,8 @@ public void SetsStatus() Assert.Same(e, e2); Assert.Equal(MigrationManifestEntryStatus.Skipped, e.Status); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Pending), Times.Once); } } @@ -228,7 +238,7 @@ public void SetsStatus() #region - SetMigrated - - public class SetMigrated : MigrationManifestEntryTest + public sealed class SetMigrated : MigrationManifestEntryTest { [Fact] public void SetsStatusAndHasMigratedFlag() @@ -240,6 +250,8 @@ public void SetsStatusAndHasMigratedFlag() Assert.Same(e, e2); Assert.Equal(MigrationManifestEntryStatus.Migrated, e.Status); Assert.True(e.HasMigrated); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Pending), Times.Once); } } @@ -247,7 +259,7 @@ public void SetsStatusAndHasMigratedFlag() #region - SetCanceled - - public class SetCanceled : MigrationManifestEntryTest + public sealed class SetCanceled : MigrationManifestEntryTest { [Fact] public void SetsStatus() @@ -258,6 +270,8 @@ public void SetsStatus() Assert.Same(e, e2); Assert.Equal(MigrationManifestEntryStatus.Canceled, e.Status); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Pending), Times.Once); } } @@ -265,7 +279,7 @@ public void SetsStatus() #region - SetFailed - - public class SetFailed : MigrationManifestEntryTest + public sealed class SetFailed : MigrationManifestEntryTest { [Fact] public void SetsStatusAndErrors() @@ -278,6 +292,8 @@ public void SetsStatusAndErrors() Assert.Same(e, e2); Assert.Equal(MigrationManifestEntryStatus.Error, e.Status); Assert.Equal(errors, e.Errors); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Pending), Times.Once); } [Fact] @@ -290,7 +306,9 @@ public void SingleErrorParams() Assert.Same(e, e2); Assert.Equal(MigrationManifestEntryStatus.Error, e.Status); - Assert.Equal(new[] { error }, e.Errors); + Assert.Equal([error], e.Errors); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Pending), Times.Once); } [Fact] @@ -302,6 +320,8 @@ public void EmptyErrorParams() Assert.Same(e, e2); Assert.Equal(MigrationManifestEntryStatus.Error, e.Status); Assert.Empty(e.Errors); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Pending), Times.Once); } } @@ -309,7 +329,7 @@ public void EmptyErrorParams() #region - Equality - - public class Equality : MigrationManifestEntryTest + public sealed class Equality : MigrationManifestEntryTest { private readonly ContentReferenceStub BaseSource; @@ -462,5 +482,28 @@ public void ErrorsNull() } #endregion + + #region - ResetStatus - + + public sealed class ResetStatus : MigrationManifestEntryTest + { + [Fact] + public void ResetsStatus() + { + var sourceRef = Create(); + var e = new MigrationManifestEntry(MockEntryBuilder.Object, sourceRef); + + e.SetSkipped(); + + var result = e.ResetStatus(); + + Assert.Same(e, result); + Assert.Equal(MigrationManifestEntryStatus.Pending, e.Status); + + MockEntryBuilder.Verify(x => x.StatusUpdated(e, MigrationManifestEntryStatus.Skipped), Times.Once); + } + } + + #endregion } } \ No newline at end of file diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs index 9674ac4..2787802 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/MigrationPlanBuilderTests.cs @@ -88,8 +88,6 @@ protected void AssertDefaultExtensions() MockTransformerBuilder.Verify(x => x.Add(typeof(OwnershipTransformer<>), It.IsAny>()), Times.Once); MockTransformerBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); MockTransformerBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); - MockTransformerBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); - MockTransformerBuilder.Verify(x => x.Add(typeof(CloudScheduleCompatibilityTransformer<>), It.IsAny>()), Times.Once); MockTransformerBuilder.Verify(x => x.Add(typeof(WorkbookReferenceTransformer<>), It.IsAny>()), Times.Once); MockTransformerBuilder.Verify(x => x.Add(It.IsAny>()), Times.Once); diff --git a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs index 03a5e7f..ad31e75 100644 --- a/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Engine/Migrators/ContentMigratorTests.cs @@ -37,11 +37,11 @@ namespace Tableau.Migration.Tests.Unit.Engine.Migrators { - public class ContentMigratorTests + public sealed class ContentMigratorTests { #region - Test Classes - - public class TestContentMigrator : ContentMigrator + public sealed class TestContentMigrator : ContentMigrator { public TestContentMigrator( IMigrationPipeline pipeline, @@ -56,7 +56,7 @@ public TestContentMigrator( new public int BatchSize => base.BatchSize; } - public class ContentMigratorTest : AutoFixtureTestBase + public abstract class ContentMigratorTest : AutoFixtureTestBase { protected readonly Mock MockConfigReader; protected readonly Mock MockSourceEndpoint; @@ -100,8 +100,9 @@ public ContentMigratorTest() MockManifestEntryBuilder = Freeze>(); MockManifestEntryBuilder.Setup(x => x.CreateEntries( It.IsAny>(), - It.IsAny>>())) - .Returns((IReadOnlyCollection c, Func> f) + It.IsAny>>(), + It.IsAny())) + .Returns((IReadOnlyCollection c, Func> f, int i) => { return c.Select(i => @@ -153,7 +154,7 @@ public ContentMigratorTest() #region - Ctor - - public class Ctor : ContentMigratorTest + public sealed class Ctor : ContentMigratorTest { [Fact] public void GetBatchMigratorByContentType() @@ -166,7 +167,7 @@ public void GetBatchMigratorByContentType() #region - BatchSize - - public class BatchSize : ContentMigratorTest + public sealed class BatchSize : ContentMigratorTest { [Fact] public void GetsConfigBatchSize() @@ -182,7 +183,7 @@ public void GetsConfigBatchSize() #region - MigrateAsync - - public class MigrateAsync : ContentMigratorTest + public sealed class MigrateAsync : ContentMigratorTest { [Fact] public async Task MigratesInBatchesAsync() @@ -197,6 +198,10 @@ public async Task MigratesInBatchesAsync() Assert.Equal(2, NumSourcePages); MockBatchMigrator.Verify(x => x.MigrateAsync(It.IsAny>>(), Cancel), Times.Exactly(NumSourcePages)); + + MockManifestEntryBuilder.Verify(x => x.CreateEntries(It.IsAny>(), + It.IsAny>>(), + SourceContent.Count), Times.Exactly(NumSourcePages)); } [Fact] @@ -218,6 +223,10 @@ public async Task AppliesFiltersAsync() 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)); } [Fact] @@ -240,6 +249,10 @@ public async Task FilteredOutItemsMarkedAsSkipped() 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); @@ -257,7 +270,7 @@ public async Task ContinueOnBatchFailureAsync() .Cast>() .ToImmutableArray(); - return ContentBatchMigrationResult.Failed(itemResults, new[] { new Exception() }); + return ContentBatchMigrationResult.Failed(itemResults, [new Exception()]); }); var result = await Migrator.MigrateAsync(Cancel); @@ -269,6 +282,10 @@ public async Task ContinueOnBatchFailureAsync() Assert.Equal(2, NumSourcePages); MockBatchMigrator.Verify(x => x.MigrateAsync(It.IsAny>>(), Cancel), Times.Exactly(NumSourcePages)); + + MockManifestEntryBuilder.Verify(x => x.CreateEntries(It.IsAny>(), + It.IsAny>>(), + SourceContent.Count), Times.Exactly(NumSourcePages)); } [Fact] @@ -293,7 +310,11 @@ public async Task HaltBatchesOnResultFlagAsync() MockManifestPartition.Verify(x => x.GetEntryBuilder(SourceContent.Count), Times.Once); Assert.Equal(2, NumSourcePages); - MockBatchMigrator.Verify(x => x.MigrateAsync(It.IsAny>>(), Cancel), Times.Once()); + MockBatchMigrator.Verify(x => x.MigrateAsync(It.IsAny>>(), Cancel), Times.Once); + + MockManifestEntryBuilder.Verify(x => x.CreateEntries(It.IsAny>(), + It.IsAny>>(), + SourceContent.Count), Times.Once); } [Fact] @@ -303,6 +324,10 @@ public async Task MapsItemsAsync() result.AssertSuccess(); + MockManifestEntryBuilder.Verify(x => x.CreateEntries(It.IsAny>(), + It.IsAny>>(), + SourceContent.Count), Times.Exactly(NumSourcePages)); + MockManifestEntryBuilder.Verify(x => x.MapEntriesAsync(It.IsAny>(), MockMappingRunner.Object, Cancel), Times.Exactly(NumSourcePages)); } } diff --git a/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializableObjects/TestSerializableManifestEntry.cs b/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializableObjects/TestSerializableManifestEntry.cs index 83600d8..bf9f9c5 100644 --- a/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializableObjects/TestSerializableManifestEntry.cs +++ b/tests/Tableau.Migration.Tests/Unit/JsonConverter/SerializableObjects/TestSerializableManifestEntry.cs @@ -39,7 +39,7 @@ public void AsMigrationManifestEntryWithDestination() Assert.Equal(input.Source!.AsContentReferenceStub(), output.Source); Assert.Equal(input.MappedLocation!.AsContentLocation(), output.MappedLocation); - Assert.Equal((int)input.Status, (int)output.Status); + Assert.Equal(input.Status, output.Status.ToString()); Assert.Equal(input.HasMigrated, output.HasMigrated); Assert.Equal(input.Destination?.AsContentReferenceStub(), output.Destination); Assert.Equal(input?.Errors?.Select(e => e.Error), output.Errors); @@ -59,7 +59,7 @@ public void AsMigrationManifestEntryWithoutDestination() Assert.Equal(input.Source!.AsContentReferenceStub(), output.Source); Assert.Null(output.Destination); Assert.Equal(input.MappedLocation!.AsContentLocation(), output.MappedLocation); - Assert.Equal((int)input.Status, (int)output.Status); + Assert.Equal(input.Status, output.Status.ToString()); Assert.Equal(input.HasMigrated, output.HasMigrated); Assert.Equal(input?.Errors?.Select(e => e.Error), output.Errors); } @@ -81,5 +81,14 @@ public void BadDeserialization_NullMappedLocation() Assert.Throws(() => input.AsMigrationManifestEntry(mockPartition.Object)); } + + [Fact] + public void BadDeserialization_NullStatus() + { + var input = Create(); + input.Status = null; + + Assert.Throws(() => input.AsMigrationManifestEntry(mockPartition.Object)); + } } } diff --git a/tests/Tableau.Migration.Tests/Unit/LoggingServiceCollectionExtensionsTests.cs b/tests/Tableau.Migration.Tests/Unit/LoggingServiceCollectionExtensionsTests.cs index d660253..d4153ef 100644 --- a/tests/Tableau.Migration.Tests/Unit/LoggingServiceCollectionExtensionsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/LoggingServiceCollectionExtensionsTests.cs @@ -36,7 +36,7 @@ public void ServiceCollectionWithoutLoggerFactoryAndProvider() .AddTableauMigrationSdk(); // Assert - Assert.NotEmpty(serviceCollection.Where(descriptor => descriptor.ServiceType == typeof(ILoggerFactory))); + Assert.Contains(serviceCollection, descriptor => descriptor.ServiceType == typeof(ILoggerFactory)); Assert.Empty(serviceCollection.Where(descriptor => descriptor.ServiceType == typeof(ILoggerProvider)).ToList()); } diff --git a/tests/Tableau.Migration.Tests/Unit/Resources/AllUsersTranslationsTests.cs b/tests/Tableau.Migration.Tests/Unit/Resources/AllUsersTranslationsTests.cs index 5f38183..72cf9a2 100644 --- a/tests/Tableau.Migration.Tests/Unit/Resources/AllUsersTranslationsTests.cs +++ b/tests/Tableau.Migration.Tests/Unit/Resources/AllUsersTranslationsTests.cs @@ -67,7 +67,7 @@ public void Removes_duplicates() var all = AllUsersTranslations.GetAll(extras); - Assert.Single(all.Where(t => t == extra)); + Assert.Single(all, t => t == extra); } [Fact]