diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..11f2826
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,37 @@
+name: Generate and Deploy DocFX Documentation
+
+on:
+ push:
+ branches:
+ - main
+
+jobs:
+ build-and-deploy:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Set up .NET
+ uses: actions/setup-dotnet@v3
+ with:
+ dotnet-version: '8.0.x'
+
+ - name: Install DocFX
+ run: dotnet tool install -g docfx
+
+ - name: Add .dotnet/tools to PATH
+ run: echo "::add-path::${HOME}/.dotnet/tools"
+
+ - name: Build DocFX site
+ working-directory: ./docs
+ run: docfx
+
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v4
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./docs/_site
+ publish_branch: gh-pages
+ keep_files: false
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 3ccb93d..1d2bac3 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -24,8 +24,13 @@ jobs:
- name: Build
run: dotnet build --configuration Release --no-restore
+ - name: Install ReportGenerator
+ run: dotnet tool install -g dotnet-reportgenerator-globaltool
+
- name: Test
- run: dotnet test --no-build --configuration Release --verbosity normal
+ run: |
+ dotnet test --configuration Release tests/Tableau.Migration.App.Core.Tests/ /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../CoreCoverage.xml
+ dotnet test --configuration Release tests/Tableau.Migration.App.GUI.Tests /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../GUICoverage.xml
env:
TABLEAU_CLOUD_URL: ${{ secrets.TABLEAU_CLOUD_URL }}
TABLEAU_CLOUD_SITE: ${{ secrets.TABLEAU_CLOUD_SITE }}
@@ -36,9 +41,44 @@ jobs:
TABLEAU_SERVER_TOKEN_NAME: ${{ secrets.TABLEAU_SERVER_TOKEN_NAME }}
TABLEAU_SERVER_TOKEN: ${{ secrets.TABLEAU_SERVER_TOKEN }}
- - name: Run dotnet format
- run: dotnet format --verbosity diagnostic
+ - name: Generate coverage report
+ env:
+ PATH: $PATH:/home/runner/.dotnet/tools # Adds dotnet tools to PATH
+ run: reportgenerator -reports:"./*Coverage.xml" -targetdir:./coverage-report -reporttypes:"Html;TextSummary"
+
+ - name: Comment coverage summary on PR
+ uses: actions/github-script@v6
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const fs = require('fs');
+ const summary = fs.readFileSync('./coverage-report/Summary.txt', 'utf8');
+ // Split the summary by lines and get the first 18
+ const topSummary = summary.split('\n').slice(0, 18).join('\n');
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: `## Code Coverage Summary\n\`\`\`\n${topSummary}\n\`\`\``
+ });
+
+# Skipping for now due to line ending mismatches
+ # - name: Run dotnet format
+ # id: format
+ # run: |
+ # dotnet format --verbosity diagnostic > format.log
+ # grep -q 'Formatted code file' format.log || exit 0
+ # exit 1
- - name: Check format results
- if: failure()
- run: echo "Formatting issues found. Please run 'dotnet format' locally and fix the issues."
\ No newline at end of file
+ # - name: Check format results
+ # if: failure()
+ # uses: actions/github-script@v6
+ # with:
+ # github-token: ${{ secrets.GITHUB_TOKEN }}
+ # script: |
+ # github.rest.issues.createComment({
+ # issue_number: context.issue.number,
+ # owner: context.repo.owner,
+ # repo: context.repo.repo,
+ # body: "Formatting issues were found and fixed. Please run 'dotnet format' locally to ensure your code is properly formatted."
+ # })
diff --git a/BUILD.md b/BUILD.md
index 3705d88..66e7358 100644
--- a/BUILD.md
+++ b/BUILD.md
@@ -1,4 +1,4 @@
-# Tableau ESMB Migration App
+# Tableau Migration App
Tableau express migration application to migrate data sources from Tableau Server to Tableau Cloud.
Requirements doc can be found [here](https://docs.google.com/document/d/1DXrYdTbS5aGcZeicNVAdD1tvGRwtH1Yj/edit#heading=h.gjdgxs).
@@ -12,10 +12,6 @@ Requirements doc can be found [here](https://docs.google.com/document/d/1DXrYdTb
* Tableau.Migration.App.GUI - Gui implementation using Avalonia framework.
# Building
-## From Container
-The dockerfile is defined in the `Dockerfile` from the project root.
-```
-docker build .
```
## From Source Root
```
diff --git a/docs/docs/gui.md b/docs/docs/gui.md
index 210eff3..c0302db 100644
--- a/docs/docs/gui.md
+++ b/docs/docs/gui.md
@@ -26,7 +26,8 @@ Currently we have the following defined service interfaces:
The top level view used to hold all visible elements is located in the [MainWindow](/api/Tableau.Migration.App.GUI.Views.MainWindow.html) view.
## View Models
-All ViewModels are named based on their appropriate View name. i.e. the [MainWindow](/api/Tableau.Migration.App.GUI.Views.MainWindow.html) view has an associatedv [MainWindowViewModel](api/Tableau.Migration.App.GUI.ViewModels.MainWindowViewModel.html).
+All ViewModels are named based on their appropriate View name. i.e. the [MainWindow](/api/Tableau.Migration.App.GUI.Views.MainWindow.html) view has an associatedv [MainWindowViewModel](/api/Tableau.Migration.App.GUI.ViewModels.MainWindowViewModel.html)
+
There exist 2 abstract classes defined in the ViewModel folder:
- [**ViewModelBase**](/api/Tableau.Migration.App.GUI.ViewModels.ViewModelBase.html) - The base class of a ViewModel.
diff --git a/docs/index.md b/docs/index.md
new file mode 120000
index 0000000..32d46ee
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1 @@
+../README.md
\ No newline at end of file
diff --git a/docs/toc.yml b/docs/toc.yml
new file mode 100644
index 0000000..6da36ae
--- /dev/null
+++ b/docs/toc.yml
@@ -0,0 +1,4 @@
+- name: Docs
+ href: docs/
+- name: API
+ href: api/
\ No newline at end of file
diff --git a/scripts/gen_report.sh b/scripts/gen_report.sh
new file mode 100755
index 0000000..90c4626
--- /dev/null
+++ b/scripts/gen_report.sh
@@ -0,0 +1,4 @@
+#!/usr/bin/bash
+dotnet test tests/Tableau.Migration.App.Core.Tests/ /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../CoreCoverage.xml
+dotnet test tests/Tableau.Migration.App.GUI.Tests /p:CollectCoverage=true /p:CoverletOutputFormat=opencover /p:CoverletOutput=../../GUICoverage.xml
+reportgenerator -reports:"./*Coverage.xml" -targetdir:./coverage-report -reporttypes:"Html;TextSummary"
diff --git a/src/Tableau.Migration.App.Core/Entities/AppSettings.cs b/src/Tableau.Migration.App.Core/Entities/AppSettings.cs
index 03184f5..93ba1ab 100644
--- a/src/Tableau.Migration.App.Core/Entities/AppSettings.cs
+++ b/src/Tableau.Migration.App.Core/Entities/AppSettings.cs
@@ -15,6 +15,8 @@
// limitations under the License.
//
+namespace Tableau.Migration.App.Core.Entities;
+
///
/// App settings to be loaded.
///
diff --git a/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs b/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs
index 3916d8c..7033db1 100644
--- a/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs
+++ b/src/Tableau.Migration.App.Core/Entities/DetailedMigrationResult.cs
@@ -1,4 +1,4 @@
-//
+//
// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2
//
@@ -26,4 +26,36 @@ namespace Tableau.Migration.App.Core.Entities;
///
public record struct DetailedMigrationResult(
ITableauMigrationService.MigrationStatus status,
- IReadOnlyList errors);
\ No newline at end of file
+ IReadOnlyList errors);
+
+///
+/// Builder to build a .
+///
+public static class DetailedMigrationResultBuilder
+{
+ ///
+ /// Build a Detailed Migration Result.
+ ///
+ /// The Tableau Migration SDK Status.
+ /// The errors from migration.
+ /// The .
+ public static DetailedMigrationResult Build(
+ MigrationCompletionStatus status, IReadOnlyList errors)
+ {
+ ITableauMigrationService.MigrationStatus newStatus;
+ switch (status)
+ {
+ case MigrationCompletionStatus.Completed:
+ newStatus = ITableauMigrationService.MigrationStatus.SUCCESS;
+ break;
+ case MigrationCompletionStatus.Canceled:
+ newStatus = ITableauMigrationService.MigrationStatus.CANCELED;
+ break;
+ default: // ITableauMigrationService.MigrationStatus.FAILURE
+ newStatus = ITableauMigrationService.MigrationStatus.FAILURE;
+ break;
+ }
+
+ return new DetailedMigrationResult(newStatus, errors);
+ }
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs b/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs
index 7221aee..fe05aa1 100644
--- a/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs
+++ b/src/Tableau.Migration.App.Core/Entities/MigrationActions.cs
@@ -29,6 +29,8 @@ namespace Tableau.Migration.App.Core.Entities;
///
public class MigrationActions
{
+ private static List actionsCache = new List();
+
///
/// Gets the list of actions available from the Tableau Migration SDK and the order in which they are migrated.
///
@@ -36,14 +38,39 @@ public static List Actions
{
get
{
- var result = ServerToCloudMigrationPipeline
+ if (actionsCache.Count > 0)
+ {
+ return actionsCache;
+ }
+
+ actionsCache = ServerToCloudMigrationPipeline
.ContentTypes
.Select(
contentType => GetActionTypeName(contentType.ContentType)).ToList();
- return result;
+ actionsCache.Insert(0, "Setup");
+
+ return actionsCache;
}
}
+ ///
+ /// Get the index of a migration action.
+ ///
+ /// The migration action name.
+ /// The migration index for the provided action.
+ public static int GetActionIndex(string action)
+ {
+ for (int i = 0; i < Actions.Count; i++)
+ {
+ if (action == Actions[i])
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
///
/// Returns the action type names from the SDK.
///
diff --git a/src/Tableau.Migration.App.Core/Entities/MigrationTimerEvent.cs b/src/Tableau.Migration.App.Core/Entities/MigrationTimerEvent.cs
new file mode 100644
index 0000000..2542aaa
--- /dev/null
+++ b/src/Tableau.Migration.App.Core/Entities/MigrationTimerEvent.cs
@@ -0,0 +1,67 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.Core.Entities;
+using System;
+
+///
+/// Possible value types for Migration Timer Events.
+///
+public enum MigrationTimerEventType
+{
+ ///
+ /// Event fired when Migration has started.
+ ///
+ MigrationStarted,
+
+ ///
+ /// Event fired when Migration has either completed or failed.
+ ///
+ Migrationfinished,
+
+ ///
+ /// Event fired when a Migration Action has completed.
+ ///
+ MigrationActionCompleted,
+}
+
+///
+/// Migration Time events to be triggered.
+///
+public class MigrationTimerEvent : EventArgs
+{
+ private DateTime migrationStartTime;
+ private Dictionary actionStartTimes;
+ private Dictionary actionStopTimes;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The event type.
+ public MigrationTimerEvent(MigrationTimerEventType eventType)
+ {
+ this.EventType = eventType;
+ this.migrationStartTime = DateTime.Now;
+ this.actionStartTimes = new Dictionary();
+ this.actionStopTimes = new Dictionary();
+ }
+
+ ///
+ /// Gets the Migration event type.
+ ///
+ public MigrationTimerEventType EventType { get; }
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs b/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs
index 7c62996..bb63aec 100644
--- a/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs
+++ b/src/Tableau.Migration.App.Core/Entities/ProgressEventArgs.cs
@@ -26,21 +26,14 @@ public class ProgressEventArgs : EventArgs
///
/// Initializes a new instance of the class.
///
- /// The action.
/// The message.
- public ProgressEventArgs(string action, string message)
+ public ProgressEventArgs(string message)
{
this.Message = message;
- this.Action = action;
}
///
/// Gets something.
///
public string Message { get; }
-
- ///
- /// Gets something.
- ///
- public string Action { get; }
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs b/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs
index aedfa92..1bad754 100644
--- a/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs
+++ b/src/Tableau.Migration.App.Core/Hooks/Mappings/EmailDomainMapping.cs
@@ -33,6 +33,7 @@ public class EmailDomainMapping :
ITableauCloudUsernameMapping
{
private readonly string domain;
+ private ILogger? logger;
///
/// Initializes a new instance of the class.
@@ -44,10 +45,11 @@ public class EmailDomainMapping :
public EmailDomainMapping(
IOptions options,
ISharedResourcesLocalizer localizer,
- ILogger logger)
+ ILogger? logger)
: base(localizer, logger)
{
this.domain = options.Value.EmailDomain;
+ this.logger = logger;
}
///
@@ -71,6 +73,12 @@ public EmailDomainMapping(
return userMappingContext.MapTo(domain.Append(userMappingContext.ContentItem.Name)).ToTask();
}
+ if (string.IsNullOrEmpty(this.domain))
+ {
+ this.logger?.LogInformation("No domain mapping provided and no email found.");
+ return userMappingContext.ToTask();
+ }
+
// Takes the existing username and appends the domain to build the email
var email = $"{userMappingContext.ContentItem.Name}@{this.domain}";
diff --git a/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs b/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs
index d4d5aaa..bdeb107 100644
--- a/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs
+++ b/src/Tableau.Migration.App.Core/Hooks/Progression/BatchMigrationCompletedProgressHook.cs
@@ -35,7 +35,6 @@ public class BatchMigrationCompletedProgressHook :
IContentBatchMigrationCompletedHook
where T : IContentReference
{
- private const string Separator = "-------------------";
private readonly ILogger> logger;
private readonly IProgressMessagePublisher? publisher;
@@ -59,9 +58,10 @@ public BatchMigrationCompletedProgressHook(
List messageList = new ();
if (ctx.ItemResults.Count == 0)
{
+ var type = MigrationActions.GetActionTypeName(typeof(T));
this.logger.LogInformation(
"No resources found for {type} to be migrated.",
- MigrationActions.GetActionTypeName(typeof(T)));
+ type);
return Task.FromResult?>(ctx);
}
@@ -70,15 +70,11 @@ public BatchMigrationCompletedProgressHook(
this.ProcessManifestEntry(result, messageList);
}
- messageList.Add(BatchMigrationCompletedProgressHook.Separator);
-
// Join the messages together to be broadcasted
string progressMessage = string.Join("\n", messageList);
// Publish and log the message
- this.publisher?.PublishProgressMessage(
- MigrationActions.GetActionTypeName(typeof(T)),
- progressMessage);
+ this.publisher?.PublishProgressMessage(progressMessage);
this.logger.LogInformation(
"Published progress message for {type}:\n {message}",
diff --git a/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs b/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs
index e02a0cf..9c75246 100644
--- a/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs
+++ b/src/Tableau.Migration.App.Core/Hooks/Progression/MigrationActionProgressHook.cs
@@ -33,7 +33,7 @@ public class MigrationActionProgressHook : IMigrationActionCompletedHook
/// Action hook set to trigger migration progress visualizations.
///
/// The object to track the migration progress.
- public MigrationActionProgressHook(IProgressUpdater? progressUpdater)
+ public MigrationActionProgressHook(IProgressUpdater? progressUpdater = null)
{
this.progressUpdater = progressUpdater;
}
@@ -42,6 +42,7 @@ public MigrationActionProgressHook(IProgressUpdater? progressUpdater)
public Task ExecuteAsync(IMigrationActionResult ctx, CancellationToken cancel)
{
this.progressUpdater?.Update();
+
return Task.FromResult(ctx);
}
}
diff --git a/src/Tableau.Migration.App.Core/Interfaces/IMigrationTimer.cs b/src/Tableau.Migration.App.Core/Interfaces/IMigrationTimer.cs
new file mode 100644
index 0000000..3f86524
--- /dev/null
+++ b/src/Tableau.Migration.App.Core/Interfaces/IMigrationTimer.cs
@@ -0,0 +1,56 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.Core.Interfaces;
+using Tableau.Migration.App.Core.Entities;
+
+///
+/// Migration Timer Interface.
+///
+public interface IMigrationTimer
+{
+ ///
+ /// Gets the total time spent on the Migration. If migration is ongoing, it will give the total elapsed time so far.
+ ///
+ /// A string representing the total time in `hh:mm:ss` format.
+ string GetTotalMigrationTime { get; }
+
+ ///
+ /// Gets the total time spent for migration of a specific action. Actions that were skipped or have not happened yet will be 0.
+ ///
+ /// The Action to query.
+ /// The string representing teh total time in `hh:mm:ss` format.
+ string GetMigrationActionTime(string actionName);
+
+ ///
+ /// Updates the migration timings based on the new event.
+ ///
+ /// The timer event.
+ /// The migration action to update.
+ void UpdateMigrationTimes(MigrationTimerEventType timerEvent, string migrationAction);
+
+ ///
+ /// Updates the migration timings based on the new event.
+ ///
+ /// The timer event.
+ void UpdateMigrationTimes(MigrationTimerEventType timerEvent);
+
+ ///
+ /// Resets the migration timers.
+ ///
+ void Reset();
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs b/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs
index 9bf08e2..0383823 100644
--- a/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs
+++ b/src/Tableau.Migration.App.Core/Interfaces/IProgressMessagePublisher.cs
@@ -53,6 +53,11 @@ public enum MessageStatus
/// Error message status.
///
Error,
+
+ ///
+ /// Unknown message status.
+ ///
+ Unknown,
}
///
@@ -95,7 +100,6 @@ public static string GetStatusIcon(MessageStatus status)
///
/// Publish progress message of current Migration Action with its corresponding status messages.
///
- /// The action.
/// The message.
- void PublishProgressMessage(string action, string message);
+ void PublishProgressMessage(string message);
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs b/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs
index 4b462be..db5f393 100644
--- a/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs
+++ b/src/Tableau.Migration.App.Core/Interfaces/IProgressUpdater.cs
@@ -42,11 +42,6 @@ public interface IProgressUpdater
///
string CurrentMigrationMessage { get; }
- ///
- /// Gets the total number of migration states available.
- ///
- static int NumMigrationStates { get; }
-
///
/// Updates the visual representation of the migration progress.
///
diff --git a/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs b/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs
index b9b3245..71cfd6e 100644
--- a/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs
+++ b/src/Tableau.Migration.App.Core/Interfaces/ITableauMigrationService.cs
@@ -42,7 +42,7 @@ public enum MigrationStatus
///
/// The migration was stopped while running.
///
- CANCELLED,
+ CANCELED,
///
/// The migration was resumed after previously being stopped.
diff --git a/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs b/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs
index ed7f770..7bd37bb 100644
--- a/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs
+++ b/src/Tableau.Migration.App.Core/ServiceCollectionExtensions.cs
@@ -19,6 +19,7 @@ namespace Tableau.Migration.App.Core;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Tableau.Migration;
+using Tableau.Migration.App.Core.Entities;
using Tableau.Migration.App.Core.Hooks.Mappings;
using Tableau.Migration.App.Core.Hooks.Progression;
using Tableau.Migration.App.Core.Interfaces;
diff --git a/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs b/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs
index 4a73e78..3113572 100644
--- a/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs
+++ b/src/Tableau.Migration.App.Core/Services/TableauMigrationService.cs
@@ -34,9 +34,8 @@ public class TableauMigrationService : ITableauMigrationService
private readonly AppSettings appSettings;
private readonly IMigrationPlanBuilder planBuilder;
private readonly IMigrator migrator;
- private readonly ILogger logger;
+ private readonly ILogger? logger;
private readonly IProgressUpdater? progressUpdater;
- private readonly IProgressMessagePublisher? publisher;
private readonly MigrationManifestSerializer manifestSerializer;
private IMigrationPlan? plan;
private IMigrationManifest? manifest;
@@ -49,23 +48,20 @@ public class TableauMigrationService : ITableauMigrationService
/// The logger to be used.
/// The application settings to apply to the application.
/// The object to handle the visual migration progress indicator.
- /// The message publisher to broadcast progress updates.
/// Serializaer class to save and load manifest file.
public TableauMigrationService(
IMigrationPlanBuilder planBuilder,
IMigrator migrator,
- ILogger logger,
+ ILogger? logger,
AppSettings appSettings,
MigrationManifestSerializer manifestSerializer,
- IProgressUpdater? progressUpdater = null,
- IProgressMessagePublisher? publisher = null)
+ IProgressUpdater? progressUpdater = null)
{
this.appSettings = appSettings;
this.planBuilder = planBuilder;
this.migrator = migrator;
this.logger = logger;
this.progressUpdater = progressUpdater;
- this.publisher = publisher;
this.manifestSerializer = manifestSerializer;
}
@@ -95,7 +91,7 @@ public bool BuildMigrationPlan(EndpointOptions serverEndpoints, EndpointOptions
if (!validationResult.Success)
{
- this.logger.LogError("Migration plan validation failed. {Errors}", validationResult.Errors);
+ this.logger?.LogError("Migration plan validation failed. {Errors}", validationResult.Errors);
return false;
}
@@ -119,7 +115,7 @@ public async Task StartMigrationTaskAsync(CancellationT
{
if (this.plan == null)
{
- this.logger.LogError("Migration plan is not built.");
+ this.logger?.LogError("Migration plan is not built.");
return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, new List());
}
@@ -131,13 +127,13 @@ public async Task ResumeMigrationTaskAsync(string manif
{
if (this.plan == null)
{
- this.logger.LogError("Migration plan is not built.");
+ this.logger?.LogError("Migration plan is not built.");
return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, new List());
}
if (!(await this.LoadManifestAsync(manifestFilepath, cancel)))
{
- this.logger.LogError("Migration manifest is null. Unable to resume the migration");
+ this.logger?.LogError("Migration manifest is null. Unable to resume the migration");
return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, new List());
}
@@ -230,41 +226,8 @@ private async Task ExecuteMigrationAsync(IMigrationPlan
List messageList = new ();
this.manifest = result.Manifest;
IReadOnlyList errors = this.manifest.Errors;
- var statusIcon = IProgressMessagePublisher.GetStatusIcon(IProgressMessagePublisher.MessageStatus.Error);
- foreach (var error in errors)
- {
- try
- {
- ErrorMessage parsedError = new ErrorMessage(error.Message);
- messageList.Add($"\t {statusIcon} {parsedError.Detail}");
- messageList.Add($"\t\t {parsedError.Summary}: {parsedError.URL}");
- }
- catch (Exception)
- {
- messageList.Add($"\t {statusIcon} Could not parse error message: \n{error.Message}");
- }
- }
-
- string resultErrorMessage = string.Join("\n", messageList);
-
- if (result.Status == MigrationCompletionStatus.Completed)
- {
- this.logger.LogInformation("Migration completed.");
- this.publisher?.PublishProgressMessage("Migration completed", resultErrorMessage);
- return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.SUCCESS, errors);
- }
- else if (result.Status == MigrationCompletionStatus.Canceled)
- {
- this.logger.LogInformation("Migration cancelled.");
- this.publisher?.PublishProgressMessage("Migration cancelled", resultErrorMessage);
- return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.CANCELLED, errors);
- }
- else
- {
- this.logger.LogError("Migration failed with status: {Status}", result.Status);
- this.publisher?.PublishProgressMessage("Migration failed", resultErrorMessage);
- return new DetailedMigrationResult(ITableauMigrationService.MigrationStatus.FAILURE, errors);
- }
+ this.logger?.LogInformation($"Migration Result: {result.Status}");
+ return DetailedMigrationResultBuilder.Build(result.Status, this.manifest.Errors);
}
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj b/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj
index 173e3f4..fc1d98b 100644
--- a/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj
+++ b/src/Tableau.Migration.App.Core/Tableau.Migration.App.Core.csproj
@@ -27,6 +27,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
@@ -34,7 +38,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitiveall
-
+
diff --git a/src/Tableau.Migration.App.GUI/App.axaml b/src/Tableau.Migration.App.GUI/App.axaml
index c0ca2d2..90ebab3 100644
--- a/src/Tableau.Migration.App.GUI/App.axaml
+++ b/src/Tableau.Migration.App.GUI/App.axaml
@@ -1,15 +1,20 @@
-
-
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ x:Class="Tableau.Migration.App.GUI.App"
+ xmlns:local="using:Tableau.Migration.App.GUI"
+ x:DataType="local:App"
+ RequestedThemeVariant="Light">
+
+
+
+
+
+
-
+
-
+
-
+
-
\ No newline at end of file
+
diff --git a/src/Tableau.Migration.App.GUI/App.axaml.cs b/src/Tableau.Migration.App.GUI/App.axaml.cs
index 0b4e1ec..ab93f1d 100644
--- a/src/Tableau.Migration.App.GUI/App.axaml.cs
+++ b/src/Tableau.Migration.App.GUI/App.axaml.cs
@@ -27,9 +27,11 @@ namespace Tableau.Migration.App.GUI;
using Serilog;
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using Tableau.Migration.App.Core;
using Tableau.Migration.App.Core.Hooks.Mappings;
using Tableau.Migration.App.Core.Interfaces;
+using Tableau.Migration.App.GUI;
using Tableau.Migration.App.GUI.Models;
using Tableau.Migration.App.GUI.Services.Implementations;
using Tableau.Migration.App.GUI.Services.Interfaces;
@@ -39,19 +41,35 @@ namespace Tableau.Migration.App.GUI;
///
/// Main application definition.
///
+[ExcludeFromCodeCoverage]
public partial class App : Application
{
+ ///
+ /// Initializes a new instance of the class.
+ /// Constructor.
+ ///
+ public App()
+ {
+ this.Name = Constants.AppName;
+ }
+
///
/// Gets the service provider.
///
public static IServiceProvider? ServiceProvider { get; private set; }
+ ///
+ /// Gets the application name and version number.
+ ///
+ public string AppNameVersion { get => Constants.AppNameVersion; }
+
///
/// Initializes the app.
///
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
+ this.DataContext = this;
}
///
@@ -124,14 +142,18 @@ public virtual void OnApplicationExit()
///
private void ConfigureServices(IServiceCollection services)
{
+ string logTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}";
Log.Logger = new LoggerConfiguration()
- .WriteTo.Console()
+ .Enrich.FromLogContext()
+ .WriteTo.Console(
+ outputTemplate: logTemplate)
.WriteTo.File(
"Logs/migration-app.log",
fileSizeLimitBytes: 20 * 1024 * 1024, // 20 MB file size limit
rollOnFileSizeLimit: true,
retainedFileCountLimit: 10,
- shared: true)
+ shared: true,
+ outputTemplate: logTemplate)
.CreateLogger();
services.AddLogging(loggingBuilder =>
@@ -154,6 +176,12 @@ private void ConfigureServices(IServiceCollection services)
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
services.AddTransient();
services.AddTransient(provider =>
{
diff --git a/src/Tableau.Migration.App.GUI/Constants.cs b/src/Tableau.Migration.App.GUI/Constants.cs
new file mode 100644
index 0000000..3069a24
--- /dev/null
+++ b/src/Tableau.Migration.App.GUI/Constants.cs
@@ -0,0 +1,39 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.GUI;
+
+///
+/// Constants to be used in the code.
+///
+public static class Constants
+{
+ ///
+ /// The App name.
+ ///
+ public const string AppName = "Tableau Migration App";
+
+ ///
+ /// The App version number.
+ ///
+ public const string Version = "v1.0.2";
+
+ ///
+ /// The App name with version.
+ ///
+ public const string AppNameVersion = $"{AppName} {Version}";
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/Models/MigrationTimer.cs b/src/Tableau.Migration.App.GUI/Models/MigrationTimer.cs
new file mode 100644
index 0000000..b6c3fa8
--- /dev/null
+++ b/src/Tableau.Migration.App.GUI/Models/MigrationTimer.cs
@@ -0,0 +1,146 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.GUI.Models;
+
+using Microsoft.Extensions.Logging;
+using System;
+using System.Collections.Generic;
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.Core.Interfaces;
+
+///
+public class MigrationTimer : IMigrationTimer
+{
+ private ILogger? logger;
+ private DateTime? startMigrationTime;
+ private DateTime? stopMigrationTime;
+ private Dictionary<
+ string /* Action Name */,
+ DateTime /* Start Time */> startActionTimes;
+
+ ///
+ /// Initializes a new instance of the class.
+ /// Initializes an new instance of the class.
+ ///
+ /// The logger.
+ public MigrationTimer(ILogger? logger = null)
+ {
+ this.startActionTimes = new Dictionary();
+ this.logger = logger;
+ }
+
+ ///
+ public string GetTotalMigrationTime
+ {
+ get
+ {
+ if (this.startMigrationTime == null)
+ {
+ return string.Empty;
+ }
+
+ DateTime now = DateTime.Now;
+ if (this.stopMigrationTime != null)
+ {
+ now = this.stopMigrationTime.Value;
+ }
+
+ TimeSpan timeSpan = now - this.startMigrationTime.Value;
+ return this.FormatTime(timeSpan);
+ }
+ }
+
+ ///
+ public string GetMigrationActionTime(string currentAction)
+ {
+ if (!this.startActionTimes.ContainsKey(currentAction))
+ {
+ return string.Empty;
+ }
+
+ DateTime start = this.startActionTimes[currentAction];
+ int actionIndex = MigrationActions.GetActionIndex(currentAction);
+ bool lastAction = actionIndex == MigrationActions.Actions.Count - 1;
+ DateTime stop = DateTime.Now;
+
+ if (!lastAction && this.startActionTimes.ContainsKey(MigrationActions.Actions[actionIndex + 1]))
+ {
+ stop = this.startActionTimes[MigrationActions.Actions[actionIndex + 1]];
+ }
+
+ TimeSpan diff = stop - start;
+ return this.FormatTime(diff);
+ }
+
+ ///
+ public void UpdateMigrationTimes(
+ MigrationTimerEventType timerEvent)
+ {
+ this.UpdateMigrationTimes(timerEvent, string.Empty);
+ }
+
+ ///
+ public void UpdateMigrationTimes(
+ MigrationTimerEventType timerEvent,
+ string migrationAction)
+ {
+ switch (timerEvent)
+ {
+ case MigrationTimerEventType.MigrationStarted:
+ this.startMigrationTime = DateTime.Now;
+ this.logger?.LogInformation($"Migration timer started: {this.startMigrationTime:yyyy-MM-dd HH:mm:ss}");
+ break;
+ case MigrationTimerEventType.Migrationfinished:
+ this.stopMigrationTime = DateTime.Now;
+ this.logger?.LogInformation($"Migration timer stopped: {this.stopMigrationTime:yyyy-MM-dd HH:mm:ss}.");
+ break;
+ case MigrationTimerEventType.MigrationActionCompleted:
+ if (string.IsNullOrEmpty(migrationAction))
+ {
+ this.logger?.LogError("Attempted to log a completed migration action time without a migration action name");
+ return;
+ }
+
+ if (this.startActionTimes.ContainsKey(migrationAction))
+ {
+ this.logger?.LogInformation($"Attempted to log a completed migration action time when one already exists: {migrationAction}");
+ return;
+ }
+
+ this.startActionTimes[migrationAction] = DateTime.Now;
+ this.logger?.LogInformation($"Migration Action timer set for {migrationAction}: {this.startActionTimes[migrationAction]:yyyy-MM-dd HH:mm:ss}.");
+ return;
+ }
+ }
+
+ ///
+ public void Reset()
+ {
+ this.startMigrationTime = null;
+ this.stopMigrationTime = null;
+ this.startActionTimes = new Dictionary();
+ }
+
+ private string FormatTime(TimeSpan timeSpan)
+ {
+ string formattedTime = timeSpan.Days > 0
+ ? $"{timeSpan.Days} days, {timeSpan.Hours:00}h:{timeSpan.Minutes:00}m:{timeSpan.Seconds:00}s"
+ : $"{timeSpan.Hours:00}h:{timeSpan.Minutes:00}m:{timeSpan.Seconds:00}s";
+ return formattedTime;
+ }
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs b/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs
index 793cc48..d802489 100644
--- a/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs
+++ b/src/Tableau.Migration.App.GUI/Models/ProgressMessagePublisher.cs
@@ -28,9 +28,9 @@ public class ProgressMessagePublisher : IProgressMessagePublisher
public event Action? OnProgressMessage;
///
- public void PublishProgressMessage(string action, string message)
+ public void PublishProgressMessage(string message)
{
- ProgressEventArgs progressMessage = new ProgressEventArgs(action, message);
+ ProgressEventArgs progressMessage = new ProgressEventArgs(message);
this.OnProgressMessage?.Invoke(progressMessage);
return;
}
diff --git a/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs b/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs
index afbfc94..f8d4519 100644
--- a/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs
+++ b/src/Tableau.Migration.App.GUI/Models/ProgressUpdater.cs
@@ -17,8 +17,11 @@
namespace Tableau.Migration.App.GUI.Models;
+using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Text;
using Tableau.Migration.App.Core.Entities;
using Tableau.Migration.App.Core.Interfaces;
@@ -27,15 +30,26 @@ namespace Tableau.Migration.App.GUI.Models;
///
public class ProgressUpdater : IProgressUpdater
{
+ private readonly string separator = "──────────────────────";
private int currentMigrationStateIndex = -1;
private List actions;
+ private ILogger? logger;
+ private IMigrationTimer? migrationTimer;
+ private IProgressMessagePublisher? publisher;
///
/// Initializes a new instance of the class.
///
- public ProgressUpdater()
+ /// The migration timer.
+ /// The progress message publisher.
+ public ProgressUpdater(IMigrationTimer? migrationTimer = null, IProgressMessagePublisher? publisher = null)
{
this.actions = MigrationActions.Actions;
+ this.logger = App.ServiceProvider?.GetService(typeof(ILogger)) as ILogger;
+ string migrationActions = string.Join(", ", this.actions);
+ this.logger?.LogInformation($"Migration Actions: {migrationActions}");
+ this.migrationTimer = migrationTimer;
+ this.publisher = publisher;
}
///
@@ -103,7 +117,17 @@ public string CurrentMigrationMessage
///
public void Update()
{
+ var oldState = this.CurrentMigrationStateName;
this.CurrentMigrationStateIndex++;
+ this.logger?.LogInformation($"Migration Progress updated! Changing from [{oldState}] → [{this.CurrentMigrationStateName}]");
+
+ // Update migration action timers
+ this.migrationTimer?.UpdateMigrationTimes(
+ MigrationTimerEventType.MigrationActionCompleted,
+ this.CurrentMigrationStateName);
+
+ // Publish messages
+ this.PublishProgressMessage(oldState);
}
///
@@ -113,4 +137,24 @@ public void Reset()
{
this.CurrentMigrationStateIndex = -1;
}
+
+ private void PublishProgressMessage(string completedAction)
+ {
+ StringBuilder sb = new StringBuilder();
+ string actionTime = this.migrationTimer?.GetMigrationActionTime(completedAction) ?? string.Empty;
+
+ if (actionTime != string.Empty)
+ {
+ sb.Append($"{Environment.NewLine}\tCompleted in {actionTime}{Environment.NewLine}");
+ }
+ else
+ {
+ this.logger?.LogInformation($"Message publishing for [{completedAction}] skipped!");
+ }
+
+ sb.Append(this.separator);
+ sb.Append(Environment.NewLine);
+ sb.Append($"{this.CurrentMigrationStateName}");
+ this.publisher?.PublishProgressMessage(sb.ToString());
+ }
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/Program.cs b/src/Tableau.Migration.App.GUI/Program.cs
index 1a7431a..f415f90 100644
--- a/src/Tableau.Migration.App.GUI/Program.cs
+++ b/src/Tableau.Migration.App.GUI/Program.cs
@@ -19,10 +19,12 @@ namespace Tableau.Migration.App.GUI;
using Avalonia;
using System;
+using System.Diagnostics.CodeAnalysis;
///
/// The main program.
///
+[ExcludeFromCodeCoverage]
internal sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
diff --git a/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj b/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj
index f47bd16..a79ad7c 100644
--- a/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj
+++ b/src/Tableau.Migration.App.GUI/Tableau.Migration.App.GUI.csproj
@@ -32,7 +32,7 @@
-
+
diff --git a/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs
index 7c1e326..d69afc4 100644
--- a/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs
+++ b/src/Tableau.Migration.App.GUI/ViewModels/MainWindowViewModel.cs
@@ -30,6 +30,7 @@ namespace Tableau.Migration.App.GUI.ViewModels;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Input;
@@ -45,49 +46,46 @@ namespace Tableau.Migration.App.GUI.ViewModels;
///
public partial class MainWindowViewModel : ViewModelBase
{
- private const string MigrationMessagesSessionSeperator = "--------------------------------------------";
+ private const string MigrationMessagesSessionSeperator = "─────────────────────────────";
private readonly ITableauMigrationService migrationService;
private bool isMigrating = false;
private IProgressUpdater progressUpdater;
+ private IProgressMessagePublisher publisher;
+ private IMigrationTimer migrationTimer;
private string notificationMessage = string.Empty;
- private string notificationDetailsMessage = string.Empty;
- private string? manifestSaveFilePath = null;
private CancellationTokenSource? cancellationTokenSource = null;
private IImmutableSolidColorBrush notificationColor = Brushes.Black;
private ILogger? logger;
+ private TimersViewModel timersVM;
///
/// Initializes a new instance of the class.
///
/// The migration service.
- /// The default domain mapping to apply to users who do not have an existing email, or mapping present.
- /// The user-specific mappings to be used if provided through CSV.
/// The object to track the migration progress from the migration service.
+ /// The object to track the migration times.
/// The progress publisher for progress status messages.
- /// The file picker service to use for CSV loaded user mappings.
- /// The csv parser to load and parser user mappings.
+ /// The Timers view model.
+ /// The User Mappings view model.
public MainWindowViewModel(
ITableauMigrationService migrationService,
- IOptions emailDomainOptions,
- IOptions dictionaryUserMappingOptions,
IProgressUpdater progressUpdater,
IProgressMessagePublisher publisher,
- IFilePicker filePicker,
- ICsvParser csvParser)
+ IMigrationTimer migrationTimer,
+ TimersViewModel timersVM,
+ UserMappingsViewModel userMappingsVM)
{
this.migrationService = migrationService;
+ this.publisher = publisher;
+ this.timersVM = timersVM;
+ this.UserMappingsVM = userMappingsVM;
- // Sub View Models
- this.MessageDisplayVM = new MessageDisplayViewModel(publisher);
+ // Sub View Models for Authentication Credentials
this.ServerCredentialsVM = new AuthCredentialsViewModel(TableauEnv.TableauServer);
this.CloudCredentialsVM = new AuthCredentialsViewModel(TableauEnv.TableauCloud);
- this.UserMappingsVM = new UserMappingsViewModel(
- emailDomainOptions,
- dictionaryUserMappingOptions,
- filePicker,
- csvParser);
this.progressUpdater = progressUpdater;
+ this.migrationTimer = migrationTimer;
this.logger = App.ServiceProvider?.GetRequiredService>();
// Subscribe to the progress updater event and retrigger UI rendering on update
@@ -111,11 +109,6 @@ await Dispatcher.UIThread.InvokeAsync(() =>
///
public static int NumMigrationStates => ProgressUpdater.NumMigrationStates;
- ///
- /// Gets the progress status Message Display View Model.
- ///
- public MessageDisplayViewModel MessageDisplayVM { get; }
-
///
/// Gets the ViewModel for the Tableau Server Credentials.
///
@@ -143,25 +136,30 @@ public bool IsMigrating
{
this.SetProperty(ref this.isMigrating, value);
}
+
+ this.OnPropertyChanged(nameof(this.IsNotificationVisible));
}
}
///
- /// Gets or Sets the notification message at the bottom of the main window.
+ /// Gets or Sets the notification message under the run migration button.
///
public string NotificationMessage
{
get => this.notificationMessage;
- set => this.SetProperty(ref this.notificationMessage, value);
+ set
+ {
+ this.SetProperty(ref this.notificationMessage, value);
+ this.OnPropertyChanged(nameof(this.IsNotificationVisible));
+ }
}
///
- /// Gets or Sets the notification message at the bottom of the main window.
+ /// Gets a value indicating whether notification message should be visible.
///
- public string NotificationDetailsMessage
+ public bool IsNotificationVisible
{
- get => this.notificationDetailsMessage;
- set => this.SetProperty(ref this.notificationDetailsMessage, value);
+ get => !string.IsNullOrEmpty(this.NotificationMessage) && !this.IsMigrating;
}
///
@@ -186,29 +184,35 @@ public IImmutableSolidColorBrush NotificationColor
///
/// Cancels the ongoing migration.
///
- /// The path of manifest file to save.
- public void CancelMigration(string? manifestSaveFilePath)
+ public void CancelMigration()
{
- this.manifestSaveFilePath = manifestSaveFilePath;
this.cancellationTokenSource?.Cancel();
return;
}
///
- /// Callback to update fields when error state is changed.
+ /// Saves the migration manifest if a save file path is provided.
///
- /// The name of the property that changed.
- protected virtual void OnErrorsChanged(string propertyName)
+ /// The file path where the manifest should be saved, or null if saving is not required.
+ /// A task that represents the asynchronous save operation.
+ public async Task SaveManifestIfRequiredAsync(string? manifestSaveFilePath)
{
- this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
+ if (string.IsNullOrEmpty(manifestSaveFilePath))
+ {
+ this.NotificationMessage += " Manifest was not saved.";
+ return;
+ }
+
+ bool isSaved = await this.migrationService.SaveManifestAsync(manifestSaveFilePath);
+ this.NotificationMessage += isSaved ? " Manifest saved." : " Failed to save manifest.";
}
///
/// Validates fields and starts the migration process if valid.
///
- /// A representing the asynchronous operation.
+ /// A representing the migration.
[RelayCommand]
- private async Task RunMigration()
+ public async Task RunMigration()
{
if (!this.AreFieldsValid())
{
@@ -217,35 +221,37 @@ private async Task RunMigration()
}
this.IsMigrating = true;
- this.MessageDisplayVM.AddMessage(MigrationMessagesSessionSeperator);
- this.MessageDisplayVM.AddMessage("Migration Started");
+ this.publisher.PublishProgressMessage("Migration Started");
this.logger?.LogInformation("Migration Started");
+ this.migrationTimer.Reset(); // Setup migration timer to store progress timing states
+ this.migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationStarted);
+ this.timersVM.Start(); // Triggers for the Timer View to start polling for information
await this.RunMigrationTask().ConfigureAwait(false);
}
///
/// Asynchronously resumes migration by selecting a manifest file and calling RunResumeMigration with the file path.
///
+ /// A representing the async migration.
[RelayCommand]
- private async Task ResumeMigration()
+ public async Task ResumeMigration()
{
- if (!this.AreFieldsValid())
- {
- this.logger?.LogInformation("Migration Run failed due to validation errors.");
- return;
- }
-
var filePath = await this.SelectManifestFileAsync();
if (filePath != null)
{
- this.IsMigrating = true;
- this.MessageDisplayVM.AddMessage(MigrationMessagesSessionSeperator);
- this.MessageDisplayVM.AddMessage("Migration Started");
- this.logger?.LogInformation("Migration Started");
- await this.RunMigrationTask().ConfigureAwait(false);
+ await this.RunMigration();
}
}
+ ///
+ /// Callback to update fields when error state is changed.
+ ///
+ /// The name of the property that changed.
+ protected virtual void OnErrorsChanged(string propertyName)
+ {
+ this.ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(propertyName));
+ }
+
///
/// Opens a file picker dialog to select a manifest file and returns the selected file path.
///
@@ -263,6 +269,7 @@ private async Task ResumeMigration()
},
};
+ // Retrieve the active application window to access the file picker
var window = App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop
? desktop.MainWindow
: null;
@@ -273,8 +280,8 @@ private async Task ResumeMigration()
return null;
}
+ // Open file picker and return the selected file path
var result = await window.StorageProvider.OpenFilePickerAsync(options);
-
if (result == null || result.Count == 0)
{
this.logger?.LogInformation("No file selected in file picker dialog.");
@@ -325,51 +332,30 @@ private async Task ExecuteMigrationAsync(Func
- /// Saves the migration manifest if a save file path is provided.
- ///
- private async Task SaveManifestIfRequiredAsync()
- {
- if (!string.IsNullOrEmpty(this.manifestSaveFilePath))
+ if (isManifestSaved)
{
- bool isSaved = await this.migrationService.SaveManifestAsync(this.manifestSaveFilePath);
- this.NotificationMessage += isSaved ? " Manifest saved." : " Failed to save manifest.";
- }
- else
- {
- this.NotificationMessage += " Manifest was not saved.";
+ this.publisher.PublishProgressMessage($"Manifest saved to '{path}manifest.json'");
}
+
+ this.publisher.PublishProgressMessage($"{Environment.NewLine}Total Elapsed Time: {this.migrationTimer.GetTotalMigrationTime}");
+ this.publisher.PublishProgressMessage(MigrationMessagesSessionSeperator);
+
+ this.IsMigrating = false;
+ this.timersVM.Stop();
+ this.progressUpdater.Reset();
}
///
@@ -378,7 +364,6 @@ private async Task SaveManifestIfRequiredAsync()
private void SetNotification(string message, string? detailsMessage = null, IImmutableSolidColorBrush? color = null)
{
this.NotificationMessage = message;
- this.NotificationDetailsMessage = detailsMessage ?? string.Empty;
this.NotificationColor = color ?? Brushes.Black;
}
@@ -417,4 +402,54 @@ private bool AreFieldsValid()
return errorCount == 0;
}
+
+ private void PublishMigrationResult(DetailedMigrationResult migrationResult)
+ {
+ switch (migrationResult.status)
+ {
+ case ITableauMigrationService.MigrationStatus.SUCCESS:
+ this.SetNotification("Migration completed.", color: Brushes.Green);
+ this.publisher.PublishProgressMessage("Migration completed.");
+ break;
+
+ case ITableauMigrationService.MigrationStatus.CANCELED:
+ var details = this.BuildErrorDetails(migrationResult.errors);
+ this.SetNotification("Migration canceled.", details, Brushes.Red);
+ this.publisher.PublishProgressMessage("Migration canceled.");
+ break;
+
+ default:
+ var failureDetails = this.BuildErrorDetails(migrationResult.errors);
+ this.SetNotification("Migration Failed.", failureDetails, Brushes.Red);
+ this.publisher.PublishProgressMessage("Migration failed.");
+ break;
+ }
+
+ this.PublishMigrationErrors(migrationResult.errors);
+ }
+
+ private void PublishMigrationErrors(IReadOnlyList errors)
+ {
+ StringBuilder sb = new StringBuilder();
+ var statusIcon =
+ IProgressMessagePublisher
+ .GetStatusIcon(IProgressMessagePublisher.MessageStatus.Error);
+ foreach (var error in errors)
+ {
+ try
+ {
+ ErrorMessage parsedError = new ErrorMessage(error.Message);
+ sb.Append($"\t {statusIcon} {parsedError.Detail}");
+ sb.Append($"\t\t {parsedError.Summary}: {parsedError.URL}");
+ }
+ catch (Exception)
+ {
+ sb.Append(
+ $"\t {statusIcon} Could not parse error message:{Environment.NewLine}{error.Message}");
+ }
+ }
+
+ string statusMessages = sb.ToString();
+ this.publisher.PublishProgressMessage(statusMessages);
+ }
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs
index 57bcfeb..9624d79 100644
--- a/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs
+++ b/src/Tableau.Migration.App.GUI/ViewModels/MessageDisplayViewModel.cs
@@ -45,13 +45,18 @@ public partial class MessageDisplayViewModel : ViewModelBase
/// Initializes a new instance of the class.
///
/// The message publisher.
- public MessageDisplayViewModel(IProgressMessagePublisher publisher)
+ /// The migration timer.
+ /// The logger.
+ public MessageDisplayViewModel(
+ IMigrationTimer migrationTimer,
+ IProgressMessagePublisher publisher,
+ ILogger? logger)
{
this.Messages = "Migration output will be displayed here.";
this.publisher = publisher;
this.messageQueue = new Queue();
this.publisher.OnProgressMessage += this.HandleProgressMessage;
- this.logger = App.ServiceProvider?.GetRequiredService>();
+ this.logger = logger;
this.ToggleDetailsCommand = new RelayCommand(this.ToggleDetails);
}
@@ -110,14 +115,6 @@ private void ToggleDetails()
private void HandleProgressMessage(ProgressEventArgs progressEvent)
{
- // Skip the message if no associated action is present
- if (progressEvent.Action == string.Empty)
- {
- return;
- }
-
- this.messageQueue.Enqueue($"{progressEvent.Action}");
-
// Enqueue each line individually
var splitMessages = progressEvent.Message.Split("\n");
foreach (var message in splitMessages)
@@ -142,5 +139,6 @@ private void UpdateText()
}
this.Messages = sb.ToString();
+ this.OnPropertyChanged(nameof(this.Messages));
}
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/ViewModels/TimersViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/TimersViewModel.cs
new file mode 100644
index 0000000..4dcf04e
--- /dev/null
+++ b/src/Tableau.Migration.App.GUI/ViewModels/TimersViewModel.cs
@@ -0,0 +1,154 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.GUI.ViewModels;
+
+using Avalonia.Threading;
+using Microsoft.Extensions.Logging;
+using System;
+using System.Timers;
+using Tableau.Migration.App.Core.Interfaces;
+using Tableau.Migration.App.GUI.Models;
+
+///
+/// The Viewmodel for the Timers view.
+///
+public partial class TimersViewModel : ViewModelBase
+{
+ private DispatcherTimer dispatchTimer;
+ private string totalElapsedTime = string.Empty;
+ private string currentActionTime = string.Empty;
+ private string currentActionLabel = string.Empty;
+ private bool showActionTimer = false;
+ private IMigrationTimer migrationTimer;
+ private IProgressUpdater progressUpdater;
+ private ILogger? logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The migration timer.
+ /// The progress Updater.
+ /// The logger.
+ public TimersViewModel(
+ IMigrationTimer migrationTimer,
+ IProgressUpdater progressUpdater,
+ ILogger? logger)
+ {
+ this.migrationTimer = migrationTimer;
+ this.progressUpdater = progressUpdater;
+ this.logger = logger;
+ this.dispatchTimer = new DispatcherTimer
+ {
+ Interval = TimeSpan.FromSeconds(1),
+ };
+ this.dispatchTimer.Tick += (s, e) => this.UpdateTimers();
+ }
+
+ ///
+ /// Gets the Total Elapsed Time value.
+ ///
+ public string TotalElapsedTime
+ {
+ get => this.totalElapsedTime;
+ private set
+ {
+ if (this.TotalElapsedTime != value)
+ {
+ this.SetProperty(ref this.totalElapsedTime, value);
+ }
+ }
+ }
+
+ ///
+ /// Gets the Current Action Time value.
+ ///
+ public string CurrentActionTime
+ {
+ get => this.currentActionTime;
+ private set
+ {
+ if (this.currentActionTime != value)
+ {
+ this.SetProperty(ref this.currentActionTime, value);
+ }
+ }
+ }
+
+ ///
+ /// Gets the current action name.
+ ///
+ public string CurrentActionLabel
+ {
+ get => this.currentActionLabel;
+ private set
+ {
+ if (this.currentActionLabel != value)
+ {
+ this.SetProperty(ref this.currentActionLabel, value);
+ }
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether gets or Sets the value indicating whether or not the timer should be running.
+ ///
+ public bool ShowActionTimer
+ {
+ get => this.showActionTimer;
+ private set
+ {
+ if (this.showActionTimer != value)
+ {
+ this.SetProperty(ref this.showActionTimer, value);
+ }
+ }
+ }
+
+ ///
+ /// Start checking for migration timer information.
+ ///
+ public void Start()
+ {
+ this.logger?.LogInformation("Timer Polling Started");
+ this.TotalElapsedTime = string.Empty;
+ this.dispatchTimer.Start();
+ }
+
+ ///
+ /// Stop checking for migration timer information.
+ ///
+ public void Stop()
+ {
+ this.logger?.LogInformation("Timer Polling Stopped");
+ this.ShowActionTimer = false;
+ this.dispatchTimer.Stop();
+ }
+
+ private void UpdateTimers()
+ {
+ this.TotalElapsedTime = this.migrationTimer.GetTotalMigrationTime;
+ this.CurrentActionTime = this.migrationTimer.GetMigrationActionTime(this.progressUpdater.CurrentMigrationStateName);
+ this.CurrentActionLabel = $"{this.progressUpdater.CurrentMigrationStateName}: ";
+ if (!this.showActionTimer)
+ {
+ this.ShowActionTimer = true;
+ }
+
+ return;
+ }
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs
index a4d997f..e5d60e0 100644
--- a/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs
+++ b/src/Tableau.Migration.App.GUI/ViewModels/UserDomainMappingViewModel.cs
@@ -27,7 +27,6 @@ public partial class UserDomainMappingViewModel : ValidatableViewModelBase
{
private readonly IOptions emailDomainOptions;
private string cloudUserDomain = string.Empty;
- private bool isMappingDisabled = false;
///
/// Initializes a new instance of the class.
@@ -52,19 +51,6 @@ public string CloudUserDomain
}
}
- ///
- /// Gets or sets a value indicating whether or not the mapping is disabled.
- ///
- public bool IsMappingDisabled
- {
- get => this.isMappingDisabled;
- set
- {
- this.SetProperty(ref this.isMappingDisabled, value);
- this.ValidateAll();
- }
- }
-
///
public override void ValidateAll()
{
@@ -73,20 +59,14 @@ public override void ValidateAll()
private void ValidateCloudUserDomain()
{
- const string requiredMessage = "Tableau Server to Cloud User Domain Mapping is required.";
const string validityMessage = "The provided value is not a valid domain.";
- if (this.IsMappingDisabled)
+
+ if (this.cloudUserDomain == string.Empty)
{
- this.RemoveError(nameof(this.CloudUserDomain), requiredMessage);
this.RemoveError(nameof(this.CloudUserDomain), validityMessage);
return;
}
- this.ValidateRequired(
- this.CloudUserDomain,
- nameof(this.CloudUserDomain),
- requiredMessage);
-
// Validate the domain
if (!Validator.IsDomainNameValid(this.CloudUserDomain))
{
diff --git a/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs
index de38876..f83166f 100644
--- a/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs
+++ b/src/Tableau.Migration.App.GUI/ViewModels/UserFileMappingsViewModel.cs
@@ -19,6 +19,8 @@ namespace Tableau.Migration.App.GUI.ViewModels;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.Input;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
@@ -31,7 +33,7 @@ namespace Tableau.Migration.App.GUI.ViewModels;
/// ViewModel for the URI Details component.
///
public partial class UserFileMappingsViewModel
- : ViewModelBase
+ : ValidatableViewModelBase
{
private readonly IOptions dictionaryUserMappingOptions;
private Dictionary userMappings = new Dictionary();
@@ -42,6 +44,7 @@ public partial class UserFileMappingsViewModel
private bool isUserMappingFileLoaded = false;
private IImmutableSolidColorBrush notificationColor = Brushes.Black;
private IImmutableSolidColorBrush csvLoadStatusColor = Brushes.Black;
+ private ILogger? logger;
///
/// Initializes a new instance of the class.
@@ -57,6 +60,10 @@ public UserFileMappingsViewModel(
this.dictionaryUserMappingOptions = dictionaryUserMappingOptions;
this.filePicker = filePicker;
this.csvParser = csvParser;
+ this.logger = App.ServiceProvider?
+ .GetService(
+ typeof(ILogger))
+ as ILogger;
}
///
@@ -128,16 +135,12 @@ public async Task LoadUserFile()
if (file is null)
{
- this.CSVLoadStatusColor = Brushes.Red;
- this.CSVLoadStatus = "Could not find file.";
return;
}
string? localPath = file.TryGetLocalPath();
if (localPath == null)
{
- this.CSVLoadStatusColor = Brushes.Red;
- this.CSVLoadStatus = "Could not find file.";
return;
}
@@ -145,36 +148,39 @@ public async Task LoadUserFile()
{
this.userMappings = await this.csvParser.ParseAsync(localPath);
this.dictionaryUserMappingOptions.Value.UserMappings = this.userMappings;
+ this.RemoveError(nameof(this.LoadedCSVFilename), $"Failed to load file.");
this.LoadedCSVFilename = file.Name;
this.IsUserMappingFileLoaded = true;
this.CSVLoadStatusColor = Brushes.Black;
this.CSVLoadStatus = $"{this.userMappings.Count} user mappings loaded.";
}
- catch (InvalidDataException e)
+ catch (Exception e) when (e is InvalidDataException || e is CsvHelper.MissingFieldException || e is FileNotFoundException)
{
- this.CSVLoadStatusColor = Brushes.Red;
- this.CSVLoadStatus = $"Failed to load {file.Name}.\n{e.Message}";
- this.ClearCSVLoadedValues();
- }
- catch (FileNotFoundException)
- {
- this.CSVLoadStatusColor = Brushes.Red;
- this.CSVLoadStatus = "Could not find file.";
- this.CSVLoadStatusColor = Brushes.Red;
+ this.AddError(nameof(this.LoadedCSVFilename), $"Failed to load file.");
+ this.logger?.LogInformation($"Failed to load CSV Mapping: {file.Name}.\n{e.Message}");
this.ClearCSVLoadedValues();
}
}
catch (Exception e)
{
- this.CSVLoadStatusColor = Brushes.Red;
- this.CSVLoadStatus = $"Encountered an unexpected error with file picker.\n{e.Message}";
- this.CSVLoadStatusColor = Brushes.Red;
+ this.AddError(nameof(this.LoadedCSVFilename), $"Failed to load file.");
+ this.logger?.LogError($"Encountered n unknown error loading a user mapping file.\n{e}");
this.ClearCSVLoadedValues();
}
return;
}
+ ///
+ public override void ValidateAll()
+ {
+ // Validate that a file is available if designaged that mapping has been loaded
+ if (this.IsUserMappingFileLoaded)
+ {
+ this.ValidateRequired(this.LoadedCSVFilename, nameof(this.LoadedCSVFilename), "Failed to load file.");
+ }
+ }
+
private void ClearCSVLoadedValues()
{
this.IsUserMappingFileLoaded = false;
diff --git a/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs b/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs
index 7e5928a..eaf0a58 100644
--- a/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs
+++ b/src/Tableau.Migration.App.GUI/ViewModels/UserMappingsViewModel.cs
@@ -31,21 +31,14 @@ public partial class UserMappingsViewModel
///
/// Initializes a new instance of the class.
///
- /// Email Domain options to be used for setting Default domain hook.
- /// The User Mapping Options.
- /// The filepicker.
- /// The CSV parser.
+ /// The User Domain Mapping ViewModel.
+ /// The User File Mapping View Model.
public UserMappingsViewModel(
- IOptions emailOptions,
- IOptions dictionaryUserMappingOptions,
- IFilePicker filePicker,
- ICsvParser csvParser)
+ UserDomainMappingViewModel userDomainMappingVM,
+ UserFileMappingsViewModel userFileMappingsVM)
{
- this.UserDomainMappingVM = new UserDomainMappingViewModel(emailOptions);
- this.UserFileMappingsVM = new UserFileMappingsViewModel(
- dictionaryUserMappingOptions,
- filePicker,
- csvParser);
+ this.UserDomainMappingVM = userDomainMappingVM;
+ this.UserFileMappingsVM = userFileMappingsVM;
}
///
@@ -62,6 +55,7 @@ public UserMappingsViewModel(
public override void ValidateAll()
{
this.UserDomainMappingVM.ValidateAll();
+ this.UserFileMappingsVM.ValidateAll();
}
///
@@ -69,7 +63,8 @@ public override int GetErrorCount()
{
return
base.GetErrorCount()
- + this.UserDomainMappingVM.GetErrorCount();
+ + this.UserDomainMappingVM.GetErrorCount()
+ + this.UserFileMappingsVM.GetErrorCount();
}
///
@@ -78,6 +73,7 @@ public override IEnumerable GetErrors(string? propertyName)
List errors = new List();
errors.AddRange(base.GetErrors(propertyName).Cast());
errors.AddRange(this.UserDomainMappingVM.GetErrors(propertyName).Cast());
+ errors.AddRange(this.UserFileMappingsVM.GetErrors(propertyName).Cast());
return errors;
}
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs b/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs
index b4988ec..1442f32 100644
--- a/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs
+++ b/src/Tableau.Migration.App.GUI/Views/Converters/StringIsNullOrEmptyToBooleanConverter.cs
@@ -1,4 +1,4 @@
-//
+//
// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2
//
diff --git a/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml b/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml
index a0abe46..cd5561c 100644
--- a/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml
+++ b/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml
@@ -1,143 +1,156 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
+ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:vm="clr-namespace:Tableau.Migration.App.GUI.ViewModels"
+ xmlns:enums="clr-namespace:Tableau.Migration.App.GUI.Models"
+ xmlns:local="clr-namespace:Tableau.Migration.App.GUI.Views"
+ xmlns:converters="clr-namespace:Tableau.Migration.App.GUI.Views.Converters"
+ mc:Ignorable="d"
+ x:Class="Tableau.Migration.App.GUI.Views.MainWindow"
+ x:DataType="vm:MainWindowViewModel"
+ Icon="avares://TableauMigrationApp/Assets/tableau-migration-app.ico"
+ Title="{x:Static local:ViewConstants.AppNameVersion}"
+ Width="774"
+ MinWidth="774"
+ Height="780"
+ Background="#F0F9F9"
+ CanResize="True">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml.cs b/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml.cs
index 017bce0..5f89003 100644
--- a/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml.cs
+++ b/src/Tableau.Migration.App.GUI/Views/MainWindow.axaml.cs
@@ -28,8 +28,6 @@ namespace Tableau.Migration.App.GUI.Views;
///
public partial class MainWindow : Window
{
- private bool isDialogOpen = false;
-
///
/// Initializes a new instance of the class.
///
@@ -39,6 +37,11 @@ public MainWindow()
this.Closing += this.OnWindowClosing;
}
+ ///
+ /// Gets a value indicating whether a stop migration confirmation dialog has been opened.
+ ///
+ public bool IsDialogOpen { get; private set; } = false;
+
private async void StopMigrationOnClick(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await this.HandleMigrationCancellation("Stop Migration", "Are you sure you want to stop the migration?");
@@ -46,7 +49,7 @@ private async void StopMigrationOnClick(object sender, Avalonia.Interactivity.Ro
private async void OnWindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
- if (this.isDialogOpen)
+ if (this.IsDialogOpen)
{
e.Cancel = true;
return;
@@ -57,11 +60,11 @@ private async void OnWindowClosing(object? sender, System.ComponentModel.CancelE
if (viewModel.IsMigrating)
{
e.Cancel = true;
- this.isDialogOpen = true;
+ this.IsDialogOpen = true;
await this.HandleMigrationCancellation("Quit", "A migration is running! Are you sure you want to stop the migration and exit?");
- this.isDialogOpen = false;
+ this.IsDialogOpen = false;
if (!viewModel.IsMigrating)
{
@@ -87,6 +90,11 @@ private async Task HandleMigrationCancellation(string title, string message)
if (result)
{
+ // Stop migration first
+ MainWindowViewModel? myViewModel = this.DataContext as MainWindowViewModel;
+ myViewModel?.CancelMigration();
+
+ // Ask if the user wants to save the manifest file to resume migration later
var saveManifestDialog = new ConfirmationDialog(
"Save Manifest File",
"Do you want to save the manifest to resume migration later?",
@@ -94,7 +102,7 @@ private async Task HandleMigrationCancellation(string title, string message)
"No");
var resultSaveManifest = await saveManifestDialog.ShowDialog(this);
- MainWindowViewModel? myViewModel = this.DataContext as MainWindowViewModel;
+ string? filePath = null;
if (resultSaveManifest)
{
var window = this;
@@ -102,10 +110,10 @@ private async Task HandleMigrationCancellation(string title, string message)
{
Title = "Save Manifest",
FileTypeChoices = new List
- {
- new FilePickerFileType("JSON files") { Patterns = new[] { "*.json" } },
- new FilePickerFileType("All files") { Patterns = new[] { "*.*" } },
- },
+ {
+ new FilePickerFileType("JSON files") { Patterns = new[] { "*.json" } },
+ new FilePickerFileType("All files") { Patterns = new[] { "*.*" } },
+ },
DefaultExtension = "json",
};
@@ -113,18 +121,11 @@ private async Task HandleMigrationCancellation(string title, string message)
if (resultFile != null)
{
- var filePath = resultFile.TryGetLocalPath();
- myViewModel?.CancelMigration(filePath ?? string.Empty);
- }
- else
- {
- myViewModel?.CancelMigration(string.Empty);
+ filePath = resultFile.TryGetLocalPath();
}
}
- else
- {
- myViewModel?.CancelMigration(string.Empty);
- }
+
+ myViewModel?.SaveManifestIfRequiredAsync(filePath);
}
}
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml
index b0b0f1b..9289848 100644
--- a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml
+++ b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml
@@ -2,6 +2,7 @@
xmlns:AvalonEdit="using:AvaloniaEdit"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:Tableau.Migration.App.GUI.ViewModels"
+ xmlns:local="clr-namespace:Tableau.Migration.App.GUI.Views"
x:Class="Tableau.Migration.App.GUI.Views.MessageDisplay"
x:DataType="vm:MessageDisplayViewModel">
@@ -20,6 +21,7 @@
-
- Keep scrolled to bottom
-
+
+
+
+
+
+
+
+ Keep scrolled to bottom
+
+
+
-
diff --git a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs
index b4daba0..cfdd783 100644
--- a/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs
+++ b/src/Tableau.Migration.App.GUI/Views/MessageDisplay.axaml.cs
@@ -24,6 +24,7 @@ namespace Tableau.Migration.App.GUI.Views;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Linq;
+using Tableau.Migration.App.GUI.ViewModels;
///
/// View control for Message Display.
@@ -39,6 +40,7 @@ public partial class MessageDisplay : UserControl
public MessageDisplay()
{
this.InitializeComponent();
+ this.DataContext = App.ServiceProvider?.GetRequiredService();
this.logger = App.ServiceProvider?.GetRequiredService>();
// Setup for "Scroll to bottom" behaviour
diff --git a/src/Tableau.Migration.App.GUI/Views/Timers.axaml b/src/Tableau.Migration.App.GUI/Views/Timers.axaml
new file mode 100644
index 0000000..af8e0d8
--- /dev/null
+++ b/src/Tableau.Migration.App.GUI/Views/Timers.axaml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Tableau.Migration.App.GUI/Views/Timers.axaml.cs b/src/Tableau.Migration.App.GUI/Views/Timers.axaml.cs
new file mode 100644
index 0000000..2efe6ee
--- /dev/null
+++ b/src/Tableau.Migration.App.GUI/Views/Timers.axaml.cs
@@ -0,0 +1,38 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.GUI.Views;
+using Avalonia.Controls;
+using Microsoft.Extensions.DependencyInjection;
+
+// using Avalonia;
+using Tableau.Migration.App.GUI.ViewModels;
+
+///
+/// View for Tableau Server to Cloud User Mappings.
+///
+public partial class Timers : UserControl
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public Timers()
+ {
+ this.InitializeComponent();
+ this.DataContext = App.ServiceProvider?.GetRequiredService();
+ }
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml b/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml
index a02c356..76f468a 100644
--- a/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml
+++ b/src/Tableau.Migration.App.GUI/Views/TokenDetails.axaml
@@ -16,6 +16,7 @@
diff --git a/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs b/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs
index 5abeab8..b741a40 100644
--- a/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs
+++ b/src/Tableau.Migration.App.GUI/Views/UriDetails.axaml.cs
@@ -63,14 +63,7 @@ private void SetLabels()
// Full URI Input
this.UriLabel.Text = $"Tableau {env} URI";
- this.InfoHelp.HelpText =
- $"Enter the Tableau {env} URL in one of the following formats:"
- + Environment.NewLine +
- $"- For a single-site: http://<{lowerEnv}_address>"
- + Environment.NewLine +
- $"- For a multi-site: http://<{lowerEnv}_address>/#/site/"
- + Environment.NewLine +
- "The site name is parsed from the URL if one is provided.";
+ this.InfoHelp.HelpText = string.Format(ViewConstants.URIDetailsHelpTextTemplate, env, lowerEnv);
this.InfoHelp.DetailsUrl = "https://help.tableau.com/current/pro/desktop/en-us/embed_structure.htm";
this.UriFull.Watermark = $"Ex: http://<{lowerEnv}_address>/#/site/";
diff --git a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml
index c27a2ac..5858a62 100644
--- a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml
+++ b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml
@@ -10,25 +10,20 @@
-
-
+
+
+ HelpText="{x:Static local:ViewConstants.UserDomainMappingHelpText}" />
-
-
-
-
diff --git a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs
index 2f1ce50..dc11034 100644
--- a/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs
+++ b/src/Tableau.Migration.App.GUI/Views/UserDomainMapping.axaml.cs
@@ -29,16 +29,5 @@ public partial class UserDomainMapping : UserControl
public UserDomainMapping()
{
this.InitializeComponent();
-
- // Attach an event callback when to Checkbox evetns to disable the Textbox
- this.DisableMapping.PropertyChanged += this.CheckBox_PropertyChanged;
- }
-
- private void CheckBox_PropertyChanged(object? sender, Avalonia.AvaloniaPropertyChangedEventArgs e)
- {
- if (e.Property == CheckBox.IsCheckedProperty)
- {
- this.UserCloudDomain.IsEnabled = !this.DisableMapping.IsChecked ?? true;
- }
}
}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml b/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml
index b322376..2b4c939 100644
--- a/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml
+++ b/src/Tableau.Migration.App.GUI/Views/UserFileMappings.axaml
@@ -9,27 +9,37 @@
x:DataType="vm:UserFileMappingsViewModel">
-
-
+
+
+
+ HelpText="{x:Static local:ViewConstants.UserFileMappingHelpText}"/>
-
-
-
+
+
+
+
+
-
+
+
+
+
+
+
+
diff --git a/src/Tableau.Migration.App.GUI/Views/ViewConstants.cs b/src/Tableau.Migration.App.GUI/Views/ViewConstants.cs
new file mode 100644
index 0000000..1874fe6
--- /dev/null
+++ b/src/Tableau.Migration.App.GUI/Views/ViewConstants.cs
@@ -0,0 +1,117 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.GUI.Views;
+using System;
+using Tableau.Migration.App.GUI;
+
+///
+/// Constant values used for the App Views.
+///
+public static class ViewConstants
+{
+ ///
+ /// Gets the App Name.
+ ///
+ public static string AppName => Constants.AppName;
+
+ ///
+ /// Gets the App Version.
+ ///
+ public static string AppVersion => Constants.Version;
+
+ ///
+ /// Gets the App Name And Version.
+ ///
+ public static string AppNameVersion => Constants.AppNameVersion;
+
+ ///
+ /// Gets the maximum width for all Configuration textboxes in the app.
+ ///
+ public static double MaxTextboxWidth { get; } = 345.0;
+
+ ///
+ /// Gets the minimum width for the filename textbox of the UserFileMappings view.
+ ///
+ public static double FileMappingTextboxMinWidth { get; } = 275.0;
+
+ ///
+ /// Gets the maximum width for the filename textbox of the UserFileMappings view.
+ ///
+ public static double FileMappingTextboxMaxWidth { get; } = 323.0;
+
+ ///
+ /// Gets the maximum width for the message display textbox.
+ ///
+ public static double MessageDisplayTextboxMaxWidth { get; } = 730.0;
+
+ // Help Texts
+
+ ///
+ /// Gets the help text for the Migration button.
+ ///
+ public static string MigrationButtonHelpText { get; } =
+ $@"START MIGRATION
+{'\t'}will start a new migration from Tableau Server to Tableau Cloud.
+
+RESUME MIGRATION
+{'\t'}allows you to continue a previously started migration. To resume, you need to select a manifest file that contains the saved migration state. This enables the migration to continue from where it last stopped, using the progress saved in the manifest file.";
+
+ ///
+ /// Gets the templated Help text for the URI Details.
+ ///
+ public static string URIDetailsHelpTextTemplate { get; } =
+ @"Enter the Tableau {0} URL in one of the following formats:
+- For a single-site:
+ http://<{1}_address>
+- For a multi-site:
+ http://<{1}_address>/#/site/
+The site name is parsed from the URL if one is provided.";
+
+ ///
+ /// Gets the templated Help text for the Token Details.
+ ///
+ public static string TokenHelpTextTemplate { get; } =
+ @"Personal Access Tokens (PATs) are used for authentication.
+Enter the Personal Access Token Name.
+Then, provide the Personal Access Token.
+
+Tokens can be managed in your Tableau {0} account's user settings.";
+
+ ///
+ /// Gets the help text for the User Domain Mapping view.
+ ///
+ public static string UserDomainMappingHelpText { get; } =
+ @"Tableau Cloud usernames must be in an email format.
+
+Enter a domain to be appended to usernames when migrating users from Tableau Server to Tableau Cloud if a user does not already have an associated email.
+
+The domain will be used to create one in the format:
+username@domain.
+
+For example:
+- 'user1' with domain `domain.com` will become
+ 'user1@domain.com'.
+- Users with existing emails, like
+ 'user2@existingdomain.com', will not be affected.";
+
+ ///
+ /// Gets the help text for the User File Mapping view.
+ ///
+ public static string UserFileMappingHelpText { get; } =
+ "Upload a CSV file defining Tableau Server to Tableau Cloud username mappings.";
+}
\ No newline at end of file
diff --git a/src/Tableau.Migration.App.GUI/app.manifest b/src/Tableau.Migration.App.GUI/app.manifest
index f09053d..0c45d1d 100644
--- a/src/Tableau.Migration.App.GUI/app.manifest
+++ b/src/Tableau.Migration.App.GUI/app.manifest
@@ -3,7 +3,7 @@
-
+
diff --git a/tests/Tableau.Migration.App.Core.Tests/Entities/AppSettings.cs b/tests/Tableau.Migration.App.Core.Tests/Entities/AppSettings.cs
new file mode 100644
index 0000000..2394484
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Entities/AppSettings.cs
@@ -0,0 +1,91 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 AppSettingsTests;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Moq;
+using System.Threading;
+using Tableau.Migration;
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.Core.Hooks.Mappings;
+using Tableau.Migration.App.Core.Hooks.Progression;
+using Tableau.Migration.App.Core.Interfaces;
+using Tableau.Migration.App.Core.Services;
+using Tableau.Migration.Engine.Manifest;
+using Xunit;
+
+public class AppSettingsTest
+{
+ [Fact]
+ public async void AppSettings_UseSimulator_True()
+ {
+ var services = new ServiceCollection();
+ var appSettings = new AppSettings();
+ appSettings.UseSimulator = true;
+ services.AddSingleton(appSettings);
+ services.AddSingleton();
+ services.AddTableauMigrationSdk();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped(typeof(BatchMigrationCompletedProgressHook<>));
+
+ var provider = services.BuildServiceProvider();
+ var tableauMigrationService = provider.GetRequiredService();
+
+ EndpointOptions serverEndpoint = new EndpointOptions("http://url/", "site", "token name", "token");
+ EndpointOptions cloudEndpoint = new EndpointOptions("http://url/", "site", "token name", "token");
+
+ tableauMigrationService.BuildMigrationPlan(serverEndpoint, cloudEndpoint);
+ var result = await tableauMigrationService.StartMigrationTaskAsync(CancellationToken.None);
+
+ // Migrator service should successfully run with simulator even if endpoint data is incorrect
+ Assert.Equal(ITableauMigrationService.MigrationStatus.SUCCESS, result.status);
+ }
+
+ [Fact(Skip = "Skipped since SDK Retries will make this test take several minutes to complete.")]
+ public async void AppSettings_UseSimulator_False()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging(configure => configure.AddConsole());
+ var appSettings = new AppSettings();
+ appSettings.UseSimulator = false;
+ services.AddSingleton(appSettings);
+ services.AddSingleton();
+ services.AddTableauMigrationSdk();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped(typeof(BatchMigrationCompletedProgressHook<>));
+
+ var provider = services.BuildServiceProvider();
+ var tableauMigrationService = provider.GetRequiredService();
+
+ EndpointOptions serverEndpoint = new EndpointOptions("http://url/", "site", "token name", "token");
+ EndpointOptions cloudEndpoint = new EndpointOptions("http://url/", "site", "token name", "token");
+
+ tableauMigrationService.BuildMigrationPlan(serverEndpoint, cloudEndpoint);
+ var result = await tableauMigrationService.StartMigrationTaskAsync(CancellationToken.None);
+
+ // Migrator service should successfully run with simulator even if endpoint data is incorrect
+ Assert.Equal(ITableauMigrationService.MigrationStatus.FAILURE, result.status);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Entities/DetailedMigrationResults.cs b/tests/Tableau.Migration.App.Core.Tests/Entities/DetailedMigrationResults.cs
new file mode 100644
index 0000000..5640189
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Entities/DetailedMigrationResults.cs
@@ -0,0 +1,65 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 DetailedMigrationResultsTest;
+
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.Core.Interfaces;
+using Tableau.Migration.App.Core.Services;
+using Xunit;
+
+public class DetailedMigrationResultsTests
+{
+ [Fact]
+ public void Constructor_ValidParameters()
+ {
+ var expectedStatus = ITableauMigrationService.MigrationStatus.SUCCESS;
+ var expectedErrors = new List { new Exception("Test error") };
+
+ var result = new DetailedMigrationResult(expectedStatus, expectedErrors);
+
+ Assert.Equal(expectedStatus, result.status);
+ Assert.Equal(expectedErrors, result.errors);
+ }
+
+ [Fact]
+ public void RecordEquality_ShouldBeTrue_WhenPropertiesAreEqual()
+ {
+ var status = ITableauMigrationService.MigrationStatus.SUCCESS;
+ var errors = new List { new Exception("Test error") };
+
+ var result1 = new DetailedMigrationResult(status, errors);
+ var result2 = new DetailedMigrationResult(status, errors);
+
+ Assert.Equal(result1, result2);
+ }
+
+ [Fact]
+ public void RecordEquality_ShouldBeFalse_WhenPropertiesAreDifferent()
+ {
+ var status1 = ITableauMigrationService.MigrationStatus.SUCCESS;
+ var errors1 = new List { new Exception("Test error 1") };
+
+ var status2 = ITableauMigrationService.MigrationStatus.FAILURE;
+ var errors2 = new List { new Exception("Test error 2") };
+
+ var result1 = new DetailedMigrationResult(status1, errors1);
+ var result2 = new DetailedMigrationResult(status2, errors2);
+
+ Assert.NotEqual(result1, result2);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Entities/EndpointOptions.cs b/tests/Tableau.Migration.App.Core.Tests/Entities/EndpointOptions.cs
new file mode 100644
index 0000000..c5525a6
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Entities/EndpointOptions.cs
@@ -0,0 +1,72 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 EndpointOptions;
+
+using Tableau.Migration.App.Core.Entities;
+using Xunit;
+
+public class EndpointOptionsTests
+{
+ [Fact]
+ public void Defualt_constructor()
+ {
+ var options = new EndpointOptions();
+ options.Url = new Uri("http://testurl.com");
+ options.SiteContentUrl = "TestSiteContent";
+ options.AccessTokenName = "TestAccessTokenName";
+ options.AccessToken = "TestAccessToken";
+
+ Assert.Equal("http://testurl.com/", options.Url.ToString());
+ Assert.Equal("TestSiteContent", options.SiteContentUrl);
+ Assert.Equal("TestAccessTokenName", options.AccessTokenName);
+ Assert.Equal("TestAccessToken", options.AccessToken);
+ }
+
+ [Fact]
+ public void Parameter_constructor()
+ {
+ var options = new EndpointOptions(
+ "http://testurl.com",
+ "TestSiteContent",
+ "TestAccessTokenName",
+ "TestAccessToken");
+
+ Assert.Equal("http://testurl.com/", options.Url.ToString());
+ Assert.Equal("TestSiteContent", options.SiteContentUrl);
+ Assert.Equal("TestAccessTokenName", options.AccessTokenName);
+ Assert.Equal("TestAccessToken", options.AccessToken);
+ }
+
+ [Fact]
+ public void IsValid()
+ {
+ var options = new EndpointOptions();
+ Assert.False(options.IsValid());
+
+ options.Url = new Uri("http://testurl.com");
+ Assert.False(options.IsValid());
+
+ options.AccessTokenName = "TestAccessTokenName";
+ Assert.False(options.IsValid());
+
+ options.AccessToken = "TestAccessToken";
+
+ // Not valid until URL, Token Name and Token are present.
+ Assert.True(options.IsValid());
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Entities/ErrorMessageTests.cs b/tests/Tableau.Migration.App.Core.Tests/Entities/ErrorMessage.cs
similarity index 96%
rename from tests/Tableau.Migration.App.Core.Tests/Entities/ErrorMessageTests.cs
rename to tests/Tableau.Migration.App.Core.Tests/Entities/ErrorMessage.cs
index ed6131e..44a7921 100644
--- a/tests/Tableau.Migration.App.Core.Tests/Entities/ErrorMessageTests.cs
+++ b/tests/Tableau.Migration.App.Core.Tests/Entities/ErrorMessage.cs
@@ -1,18 +1,18 @@
-//
-// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
-// 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
-// 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.
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 ErrorMessageTests;
diff --git a/tests/Tableau.Migration.App.Core.Tests/Entities/MigrationActions.cs b/tests/Tableau.Migration.App.Core.Tests/Entities/MigrationActions.cs
index 715906e..58f5001 100644
--- a/tests/Tableau.Migration.App.Core.Tests/Entities/MigrationActions.cs
+++ b/tests/Tableau.Migration.App.Core.Tests/Entities/MigrationActions.cs
@@ -1,18 +1,18 @@
-//
-// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
-// 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
-// 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.
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 MigrationActions;
@@ -30,4 +30,14 @@ public void GetActionTypeNames_MakesNamesReadable()
Assert.Equal("User", MigrationActions.GetActionTypeName(typeof(IUser)));
Assert.Equal("Server Extract Refresh Task", MigrationActions.GetActionTypeName(typeof(IServerExtractRefreshTask)));
}
+
+ [Fact]
+ public void GetActions_Returns_NonNull()
+ {
+ // Actions are retrieved from the Tableau Migration SDK.
+ // As the SDK increases support of resource types, the returned actions will grow.
+ // So we only verify that that we are indeed receiving actions, and not
+ // the specific action names.
+ Assert.NotEmpty(MigrationActions.Actions);
+ }
}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Entities/MigrationTimerEvent.cs b/tests/Tableau.Migration.App.Core.Tests/Entities/MigrationTimerEvent.cs
new file mode 100644
index 0000000..3cc4f93
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Entities/MigrationTimerEvent.cs
@@ -0,0 +1,76 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 MigrationTimerEventTests;
+using System;
+using System.Collections.Generic;
+using Tableau.Migration.App.Core.Entities;
+using Xunit;
+
+public class MigrationTimerEventTests
+{
+ [Fact]
+ public void MigrationTimerEvent_Constructor_SetsEventTypeCorrectly()
+ {
+ var eventType = MigrationTimerEventType.MigrationStarted;
+ var migrationTimerEvent = new MigrationTimerEvent(eventType);
+ Assert.Equal(eventType, migrationTimerEvent.EventType);
+ }
+
+ [Fact]
+ public void MigrationTimerEvent_Constructor_InitializesActionStartTimes()
+ {
+ var migrationTimerEvent = new MigrationTimerEvent(MigrationTimerEventType.MigrationStarted);
+ Assert.NotNull(migrationTimerEvent);
+ var actionStartTimes =
+ migrationTimerEvent
+ .GetType()
+ .GetField(
+ "actionStartTimes",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
+ .GetValue(migrationTimerEvent);
+ Assert.NotNull(actionStartTimes);
+ Assert.IsType>(actionStartTimes);
+ }
+
+ [Fact]
+ public void MigrationTimerEvent_Constructor_InitializesActionStopTimes()
+ {
+ var migrationTimerEvent = new MigrationTimerEvent(MigrationTimerEventType.MigrationStarted);
+ Assert.NotNull(migrationTimerEvent);
+ Assert.IsType>(
+ migrationTimerEvent
+ .GetType()
+ .GetField(
+ "actionStopTimes",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
+ .GetValue(migrationTimerEvent));
+ }
+
+ [Fact]
+ public void MigrationTimerEvent_Constructor_InitializesMigrationStartTime()
+ {
+ var migrationTimerEvent = new MigrationTimerEvent(MigrationTimerEventType.MigrationStarted);
+ var migrationStartTime = migrationTimerEvent
+ .GetType()
+ .GetField(
+ "migrationStartTime",
+ System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?
+ .GetValue(migrationTimerEvent);
+ Assert.IsType(migrationStartTime);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Entities/ProgressEventArgs.cs b/tests/Tableau.Migration.App.Core.Tests/Entities/ProgressEventArgs.cs
new file mode 100644
index 0000000..54ad172
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Entities/ProgressEventArgs.cs
@@ -0,0 +1,31 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 ProgressEventArgsTests;
+
+using Tableau.Migration.App.Core.Entities;
+using Xunit;
+
+public class ProgressEventArgsTests
+{
+ [Fact]
+ public void ProgressEventArgs_constructor_getters()
+ {
+ var progressEventArgs = new ProgressEventArgs("eventMessage");
+ Assert.Equal("eventMessage", progressEventArgs.Message);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/DictionaryUserMappingTests.cs b/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/DictionaryUserMapping.cs
similarity index 69%
rename from tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/DictionaryUserMappingTests.cs
rename to tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/DictionaryUserMapping.cs
index 4a02569..a9b33b6 100644
--- a/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/DictionaryUserMappingTests.cs
+++ b/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/DictionaryUserMapping.cs
@@ -1,4 +1,4 @@
-//
+//
// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2
//
@@ -15,7 +15,7 @@
// limitations under the License.
//
-namespace Tableau.Migration.App.Core.Tests.Hooks.Mappings;
+namespace DictionaryUserMappingTests;
using Microsoft.Extensions.Logging;
using Moq;
@@ -32,7 +32,6 @@ public class DictionaryUserMappingTests
[Fact]
public async Task MapAsync_ShouldMapUserDefinition_WhenDictionaryEntryExists()
{
- // Setup
const string sourceUserName = "testUsername";
const string destinationUserName = "TestUser@destination.com";
Dictionary userMappings =
@@ -55,17 +54,14 @@ public async Task MapAsync_ShouldMapUserDefinition_WhenDictionaryEntryExists()
// Apply the mapping to the user context
var result = await mapping.MapAsync(userMappingContext, default);
- // Assert
Assert.NotNull(result);
- // User should be mapped to new definition from dictionary
Assert.Equal(destinationUserName, result.MappedLocation.ToString());
}
[Fact]
public async Task MapAsync_ShouldNotMapUserDefinition_WhenDictionaryEntryDoesNotExist()
{
- // Setup
const string sourceUserName = "testUsername";
const string destinationUserName = "TestUser@destination.com";
Dictionary userMappings =
@@ -88,10 +84,39 @@ public async Task MapAsync_ShouldNotMapUserDefinition_WhenDictionaryEntryDoesNot
// Apply the mapping to the user context
var result = await mapping.MapAsync(userMappingContext, default);
- // Assert
Assert.NotNull(result);
- // Mapping for user is unaffected
+ Assert.Equal("local", result.MappedLocation.ToString());
+ }
+
+ [Fact]
+ public async Task MapAsync_NoOpOnIncorrectEmailFormatting()
+ {
+ const string sourceUserName = "testUsername";
+ const string destinationUserName = "NonEmailFormatUsername";
+ Dictionary userMappings =
+ new Dictionary { { sourceUserName, destinationUserName } };
+ var optionsMock = new Mock>();
+ optionsMock.Setup(o => o.Get()).Returns(new DictionaryUserMappingOptions { UserMappings = userMappings });
+
+ var mapping = new DictionaryUserMapping(
+ optionsMock.Object,
+ Mock.Of(),
+ Mock.Of>());
+
+ var contentItem = new Mock();
+ contentItem.Setup(c => c.Name).Returns(sourceUserName);
+
+ var userMappingContext = new ContentMappingContext(
+ contentItem.Object,
+ new ContentLocation("local"));
+
+ // Apply the mapping to the user context
+ var result = await mapping.MapAsync(userMappingContext, default);
+
+ Assert.NotNull(result);
+
+ // User mapping will not have changed due to invalid destination formatting
Assert.Equal("local", result.MappedLocation.ToString());
}
}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/EmailDomainMapping.cs b/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/EmailDomainMapping.cs
new file mode 100644
index 0000000..800857b
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/EmailDomainMapping.cs
@@ -0,0 +1,106 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 EmailDomainMappingTests;
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Tableau.Migration;
+using Tableau.Migration.App.Core.Hooks.Mappings;
+using Tableau.Migration.Content;
+using Tableau.Migration.Engine.Hooks.Mappings;
+using Tableau.Migration.Resources;
+using Xunit;
+
+public class EmailDomainMappingTests
+{
+ [Fact]
+ public async Task MapAsync_ShouldAppendEmailDomain_WhenNoEmailExists()
+ {
+ var optionsMock = new Mock>();
+ optionsMock.Setup(o => o.Value).Returns(new EmailDomainMappingOptions { EmailDomain = "test.com" });
+
+ var loggerMock = new Mock>();
+ var localizerMock = new Mock();
+
+ var mapping = new EmailDomainMapping(optionsMock.Object, localizerMock.Object, loggerMock.Object);
+
+ var contentItem = new Mock();
+ contentItem.Setup(c => c.Email).Returns(string.Empty); // No email exists
+ contentItem.Setup(c => c.Name).Returns("johndoe");
+
+ var mappedLocation = new ContentLocation("dummy/project/path");
+
+ var userMappingContext = new ContentMappingContext(contentItem.Object, mappedLocation);
+
+ var result = await mapping.MapAsync(userMappingContext, default);
+
+ Assert.NotNull(result);
+ Assert.Contains("johndoe@test.com", result.MappedLocation.ToString());
+ }
+
+ [Fact]
+ public async Task MapAsync_ShouldUseExistingEmail_WhenEmailAlreadyExists()
+ {
+ var optionsMock = new Mock>();
+ optionsMock.Setup(o => o.Value).Returns(new EmailDomainMappingOptions { EmailDomain = "test.com" });
+
+ var loggerMock = new Mock>();
+ var localizerMock = new Mock();
+
+ var mapping = new EmailDomainMapping(optionsMock.Object, localizerMock.Object, loggerMock.Object);
+
+ var contentItem = new Mock();
+ contentItem.Setup(c => c.Email).Returns("existingemail@existingdomain.com"); // Email already exists
+ contentItem.Setup(c => c.Name).Returns("johndoe");
+
+ var mappedLocation = new ContentLocation("dummy/project/path");
+
+ var userMappingContext = new ContentMappingContext(contentItem.Object, mappedLocation);
+
+ var result = await mapping.MapAsync(userMappingContext, default);
+
+ Assert.NotNull(result);
+ Assert.Contains("existingemail@existingdomain.com", result.MappedLocation.ToString());
+ }
+
+ [Fact]
+ public async Task MapAsync_ShouldUseNameAsEmail_WhenNameIsAlreadyEmailFormat()
+ {
+ var optionsMock = new Mock>();
+ optionsMock.Setup(o => o.Value).Returns(new EmailDomainMappingOptions { EmailDomain = "test.com" });
+
+ var loggerMock = new Mock>();
+ var localizerMock = new Mock();
+
+ var mapping = new EmailDomainMapping(optionsMock.Object, localizerMock.Object, loggerMock.Object);
+
+ var contentItem = new Mock();
+ contentItem.Setup(c => c.Email).Returns(string.Empty);
+ contentItem.Setup(c => c.Name).Returns("johndoe@nottest.com");
+
+ var mappedLocation = new ContentLocation("dummy/project/path");
+
+ var userMappingContext = new ContentMappingContext(contentItem.Object, mappedLocation);
+
+ var result = await mapping.MapAsync(userMappingContext, default);
+
+ Assert.NotNull(result);
+ Assert.Contains("johndoe@nottest.com", result.MappedLocation.ToString());
+ }
+}
diff --git a/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/EmailDomainMappingTests.cs b/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/EmailDomainMappingTests.cs
deleted file mode 100644
index f27b828..0000000
--- a/tests/Tableau.Migration.App.Core.Tests/Hooks/Mappings/EmailDomainMappingTests.cs
+++ /dev/null
@@ -1,107 +0,0 @@
-//
-// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
-// 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
-// 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.App.Core.Tests.Hooks.Mappings
-{
- using Microsoft.Extensions.Logging;
- using Microsoft.Extensions.Options;
- using Moq;
- using Tableau.Migration;
- using Tableau.Migration.App.Core.Hooks.Mappings;
- using Tableau.Migration.Content;
- using Tableau.Migration.Engine.Hooks.Mappings;
- using Tableau.Migration.Resources;
- using Xunit;
-
- public class EmailDomainMappingTests
- {
- [Fact]
- public async Task MapAsync_ShouldAppendEmailDomain_WhenNoEmailExists()
- {
- var optionsMock = new Mock>();
- optionsMock.Setup(o => o.Value).Returns(new EmailDomainMappingOptions { EmailDomain = "test.com" });
-
- var loggerMock = new Mock>();
- var localizerMock = new Mock();
-
- var mapping = new EmailDomainMapping(optionsMock.Object, localizerMock.Object, loggerMock.Object);
-
- var contentItem = new Mock();
- contentItem.Setup(c => c.Email).Returns(string.Empty); // No email exists
- contentItem.Setup(c => c.Name).Returns("johndoe");
-
- var mappedLocation = new ContentLocation("dummy/project/path");
-
- var userMappingContext = new ContentMappingContext(contentItem.Object, mappedLocation);
-
- var result = await mapping.MapAsync(userMappingContext, default);
-
- Assert.NotNull(result);
- Assert.Contains("johndoe@test.com", result.MappedLocation.ToString());
- }
-
- [Fact]
- public async Task MapAsync_ShouldUseExistingEmail_WhenEmailAlreadyExists()
- {
- var optionsMock = new Mock>();
- optionsMock.Setup(o => o.Value).Returns(new EmailDomainMappingOptions { EmailDomain = "test.com" });
-
- var loggerMock = new Mock>();
- var localizerMock = new Mock();
-
- var mapping = new EmailDomainMapping(optionsMock.Object, localizerMock.Object, loggerMock.Object);
-
- var contentItem = new Mock();
- contentItem.Setup(c => c.Email).Returns("existingemail@existingdomain.com"); // Email already exists
- contentItem.Setup(c => c.Name).Returns("johndoe");
-
- var mappedLocation = new ContentLocation("dummy/project/path");
-
- var userMappingContext = new ContentMappingContext(contentItem.Object, mappedLocation);
-
- var result = await mapping.MapAsync(userMappingContext, default);
-
- Assert.NotNull(result);
- Assert.Contains("existingemail@existingdomain.com", result.MappedLocation.ToString());
- }
-
- [Fact]
- public async Task MapAsync_ShouldUseNameAsEmail_WhenNameIsAlreadyEmailFormat()
- {
- var optionsMock = new Mock>();
- optionsMock.Setup(o => o.Value).Returns(new EmailDomainMappingOptions { EmailDomain = "test.com" });
-
- var loggerMock = new Mock>();
- var localizerMock = new Mock();
-
- var mapping = new EmailDomainMapping(optionsMock.Object, localizerMock.Object, loggerMock.Object);
-
- var contentItem = new Mock();
- contentItem.Setup(c => c.Email).Returns(string.Empty);
- contentItem.Setup(c => c.Name).Returns("johndoe@nottest.com");
-
- var mappedLocation = new ContentLocation("dummy/project/path");
-
- var userMappingContext = new ContentMappingContext(contentItem.Object, mappedLocation);
-
- var result = await mapping.MapAsync(userMappingContext, default);
-
- Assert.NotNull(result);
- Assert.Contains("johndoe@nottest.com", result.MappedLocation.ToString());
- }
- }
-}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Hooks/Progress/BatchMigrationCompletedProgressHook.cs b/tests/Tableau.Migration.App.Core.Tests/Hooks/Progress/BatchMigrationCompletedProgressHook.cs
new file mode 100644
index 0000000..52f4aae
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Hooks/Progress/BatchMigrationCompletedProgressHook.cs
@@ -0,0 +1,209 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 BatchMigrationCompletedProgressHookTests;
+
+using Microsoft.Extensions.Logging;
+using Moq;
+using System.Collections.Immutable;
+using System.Threading;
+using Tableau.Migration;
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.Core.Hooks.Progression;
+using Tableau.Migration.App.Core.Interfaces;
+using Tableau.Migration.Content;
+using Tableau.Migration.Engine.Manifest;
+using Tableau.Migration.Engine.Migrators;
+using Tableau.Migration.Engine.Migrators.Batch;
+using Xunit;
+using Xunit.Abstractions;
+
+public class BatchMigrationCompletedProgressHookTests
+{
+ private readonly ITestOutputHelper output;
+
+ public BatchMigrationCompletedProgressHookTests(ITestOutputHelper output)
+ {
+ this.output = output;
+ }
+
+ [Fact]
+ public void BatchMigrationProgressHook_Execute_Messages()
+ {
+ var publisher = new TestPublisher();
+ var logger = new Mock>>();
+ var hook = new BatchMigrationCompletedProgressHook(logger.Object, publisher);
+
+ var sourceLocation = new ContentLocation(["sourceSegmentOne", "sourceSegmentTwo"]);
+ var destLocation = new ContentLocation(["destSegmentOne", "destSegmentTwo"]);
+
+ // Mock the results
+ var item = new Mock>();
+ item.Setup(item => item.ManifestEntry.Source.Location).Returns(sourceLocation);
+ item.Setup(item => item.ManifestEntry.MappedLocation).Returns(destLocation);
+ item.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Migrated);
+ item.Setup(item => item.ManifestEntry.Errors).Returns([]);
+
+ var itemResults = ImmutableList.Create(item.Object);
+
+ var result = new Mock>();
+ result.Setup(ctx => ctx.ItemResults).Returns(itemResults);
+
+ hook.ExecuteAsync(result.Object, CancellationToken.None);
+ Assert.Single(publisher.Messages);
+ Assert.Contains(" \U0001F7E2 [sourceSegmentTwo] to [destSegmentTwo] → Migrated", publisher.Messages[0]);
+ }
+
+ [Fact]
+ public void BatchMigrationPtogressHook_Execute_Errors_UnknownFormat()
+ {
+ var publisher = new TestPublisher();
+ var logger = new Mock>>();
+ var hook = new BatchMigrationCompletedProgressHook(logger.Object, publisher);
+
+ var sourceLocation = new ContentLocation(["sourceSegmentOne", "sourceSegmentTwo"]);
+ var destLocation = new ContentLocation(["destSegmentOne", "destSegmentTwo"]);
+
+ // Mock the results
+ var item = new Mock>();
+ item.Setup(item => item.ManifestEntry.Source.Location).Returns(sourceLocation);
+ item.Setup(item => item.ManifestEntry.MappedLocation).Returns(destLocation);
+ item.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Migrated);
+
+ var error = new Exception("Test Exception Message");
+ var errors = ImmutableList.Create(error);
+ item.Setup(item => item.ManifestEntry.Errors).Returns(errors);
+
+ var itemResults = ImmutableList.Create(item.Object);
+
+ var result = new Mock>();
+ result.Setup(ctx => ctx.ItemResults).Returns(itemResults);
+
+ hook.ExecuteAsync(result.Object, CancellationToken.None);
+ Assert.Single(publisher.Messages);
+ Assert.Contains("Could not parse error message: \nTest Exception Message", publisher.Messages[0]);
+ }
+
+ [Fact]
+ public void BatchMigrationProgressHook_Execute_Errors_Formatted()
+ {
+ var publisher = new TestPublisher();
+ var logger = new Mock>>();
+ var hook = new BatchMigrationCompletedProgressHook(logger.Object, publisher);
+
+ // Mock the results
+ var item = new Mock>();
+ item.Setup(item => item.ManifestEntry.Source.Location).Returns(
+ new ContentLocation(["sourceSegmentOne", "sourceSegmentTwo"]));
+ item.Setup(item => item.ManifestEntry.MappedLocation).Returns(
+ new ContentLocation(["destSegmentOne", "destSegmentTwo"]));
+ item.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Migrated);
+
+ var error = new Exception("URL: testurl\nCode:123\nSummary:Some summary.\nDetail:Test Error Details.");
+ var errors = ImmutableList.Create(error);
+ item.Setup(item => item.ManifestEntry.Errors).Returns(errors);
+
+ var itemResults = ImmutableList.Create(item.Object);
+
+ var result = new Mock>();
+ result.Setup(ctx => ctx.ItemResults).Returns(itemResults);
+
+ hook.ExecuteAsync(result.Object, CancellationToken.None);
+ Assert.Single(publisher.Messages);
+ Assert.DoesNotContain("123", publisher.Messages[0]);
+ Assert.DoesNotContain("testurl", publisher.Messages[0]);
+ Assert.DoesNotContain("Some summary.", publisher.Messages[0]);
+ Assert.Contains("Test Error Details", publisher.Messages[0]);
+ }
+
+ [Fact]
+ public void BathMigraitonProgresHook_Execute_DifferentStatuses_No_Errors()
+ {
+ var publisher = new TestPublisher();
+ var logger = new Mock>>();
+ var hook = new BatchMigrationCompletedProgressHook(logger.Object, publisher);
+
+ // Mock the results with different status items
+ var item1 = new Mock>();
+ item1.Setup(item => item.ManifestEntry.Source.Location).Returns(
+ new ContentLocation(["source1SegmentOne", "source1SegmentTwo"]));
+ item1.Setup(item => item.ManifestEntry.MappedLocation).Returns(
+ new ContentLocation(["dest1SegmentOne", "dest1SegmentTwo"]));
+ item1.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Pending);
+ item1.Setup(item => item.ManifestEntry.Errors).Returns([]);
+
+ var item2 = new Mock>();
+ item2.Setup(item => item.ManifestEntry.Source.Location).Returns(
+ new ContentLocation(["source2SegmentOne", "source2SegmentTwo"]));
+ item2.Setup(item => item.ManifestEntry.MappedLocation).Returns(
+ new ContentLocation(["dest2SegmentOne", "dest2SegmentTwo"]));
+ item2.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Skipped);
+ item2.Setup(item => item.ManifestEntry.Errors).Returns([]);
+
+ var item3 = new Mock>();
+ item3.Setup(item => item.ManifestEntry.Source.Location).Returns(
+ new ContentLocation(["source3SegmentOne", "source3SegmentTwo"]));
+ item3.Setup(item => item.ManifestEntry.MappedLocation).Returns(
+ new ContentLocation(["dest3SegmentOne", "dest3SegmentTwo"]));
+ item3.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Migrated);
+ item3.Setup(item => item.ManifestEntry.Errors).Returns([]);
+
+ var item4 = new Mock>();
+ item4.Setup(item => item.ManifestEntry.Source.Location).Returns(
+ new ContentLocation(["source4SegmentOne", "source4SegmentTwo"]));
+ item4.Setup(item => item.ManifestEntry.MappedLocation).Returns(
+ new ContentLocation(["dest4SegmentOne", "dest4SegmentTwo"]));
+ item4.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Error);
+ item4.Setup(item => item.ManifestEntry.Errors).Returns([]);
+
+ var item5 = new Mock>();
+ item5.Setup(item => item.ManifestEntry.Source.Location).Returns(
+ new ContentLocation(["source5SegmentOne", "source5SegmentTwo"]));
+ item5.Setup(item => item.ManifestEntry.MappedLocation).Returns(
+ new ContentLocation(["dest5SegmentOne", "dest5SegmentTwo"]));
+ item5.Setup(item => item.ManifestEntry.Status).Returns(MigrationManifestEntryStatus.Canceled);
+ item5.Setup(item => item.ManifestEntry.Errors).Returns([]);
+
+ var itemResults = ImmutableList.Create(
+ item1.Object,
+ item2.Object,
+ item3.Object,
+ item4.Object,
+ item5.Object);
+
+ var result = new Mock>();
+ result.Setup(ctx => ctx.ItemResults).Returns(itemResults);
+
+ // Test will fail if execution throws ArgumentOutOfRangeException from statuses
+ hook.ExecuteAsync(result.Object, CancellationToken.None);
+ }
+
+#pragma warning disable CS0067, SA1401, CS1696
+ internal class TestPublisher : IProgressMessagePublisher
+ {
+ public List Messages = new List { };
+
+ public event Action? OnProgressMessage;
+
+ public void PublishProgressMessage(string message)
+ {
+ this.Messages.Add(message);
+ return;
+ }
+ }
+#pragma warning restore CS0067, SA1401, CS1696
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Interfaces/IProgressMessagePublisher.cs b/tests/Tableau.Migration.App.Core.Tests/Interfaces/IProgressMessagePublisher.cs
new file mode 100644
index 0000000..e84c11f
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Interfaces/IProgressMessagePublisher.cs
@@ -0,0 +1,60 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 IProgressMessagePublisherTests;
+
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.Core.Interfaces;
+using Xunit;
+
+public class IProgressMessagePublisherTests
+{
+ [Fact]
+ public void GetStatusIcon_Pending()
+ {
+ Assert.Equal("\U0001F7E1", IProgressMessagePublisher.GetStatusIcon(
+ IProgressMessagePublisher.MessageStatus.Pending));
+ }
+
+ [Fact]
+ public void GetStatusIcon_Skipped()
+ {
+ Assert.Equal("\U0001F535", IProgressMessagePublisher.GetStatusIcon(
+ IProgressMessagePublisher.MessageStatus.Skipped));
+ }
+
+ [Fact]
+ public void GetStatusIcon_Error()
+ {
+ Assert.Equal("\U0001F534", IProgressMessagePublisher.GetStatusIcon(
+ IProgressMessagePublisher.MessageStatus.Error));
+ }
+
+ [Fact]
+ public void GetStatusIcon_Succesful()
+ {
+ Assert.Equal("\U0001F7E2", IProgressMessagePublisher.GetStatusIcon(
+ IProgressMessagePublisher.MessageStatus.Successful));
+ }
+
+ [Fact]
+ public void GetStatusIcon_Unknown()
+ {
+ Assert.Equal("\U0001F7E3", IProgressMessagePublisher.GetStatusIcon(
+ IProgressMessagePublisher.MessageStatus.Unknown));
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Service/TableauMigrationService.cs b/tests/Tableau.Migration.App.Core.Tests/Service/TableauMigrationService.cs
new file mode 100644
index 0000000..7f42573
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/Service/TableauMigrationService.cs
@@ -0,0 +1,255 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 TableauMigrationServiceTests;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Moq;
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.IO.Abstractions;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Threading;
+using System.Threading.Tasks;
+using Tableau.Migration;
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.Core.Interfaces;
+using Tableau.Migration.App.Core.Services;
+using Tableau.Migration.Engine;
+using Tableau.Migration.Engine.Manifest;
+using Tableau.Migration.JsonConverters;
+using Tableau.Migration.JsonConverters.SerializableObjects;
+using Tableau.Migration.Resources;
+using Xunit;
+
+public class TableauMigrationServiceTests
+{
+ private readonly Mock mockPlanBuilder;
+ private readonly Mock mockMigrator;
+ private readonly Mock> mockLogger;
+ private readonly Mock mockProgressUpdater;
+ private readonly Mock mockManifestSerializer;
+ private readonly AppSettings appSettings;
+ private readonly TableauMigrationService service;
+ private readonly Mock mockFileSystem;
+ private readonly Mock mockLocalizer;
+ private readonly Mock mockLoggerFactory;
+ private readonly Mock mockTableauMigrationService;
+
+ public TableauMigrationServiceTests()
+ {
+ var serviceCollection = new ServiceCollection();
+ serviceCollection.AddTableauMigrationSdk();
+ var provider = serviceCollection.BuildServiceProvider();
+
+ var planBuilder = (MigrationPlanBuilder)provider.GetRequiredService();
+ this.mockPlanBuilder = new Mock();
+ this.mockPlanBuilder.Setup(pb => pb.Hooks).Returns(planBuilder.Hooks);
+ this.mockPlanBuilder.Setup(
+ pb => pb.FromSourceTableauServer(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())).Returns(
+ (Uri url, string site, string tokenName, string token, bool useSim)
+ => planBuilder.FromSourceTableauServer(url, site, tokenName, token, true));
+ this.mockPlanBuilder.Setup(
+ pb => pb.ToDestinationTableauCloud(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny())).Returns(
+ (Uri url, string site, string tokenName, string token, bool useSim)
+ => planBuilder.ToDestinationTableauCloud(url, site, tokenName, token, true));
+ this.mockMigrator = new Mock();
+ this.mockLogger = new Mock>();
+ this.mockProgressUpdater = new Mock();
+ this.mockFileSystem = new Mock();
+ this.mockLocalizer = new Mock();
+ this.mockLoggerFactory = new Mock();
+ this.appSettings = new AppSettings { UseSimulator = true };
+
+ this.mockManifestSerializer = new Mock(provider.GetRequiredService());
+ this.mockTableauMigrationService = new Mock(
+ this.mockPlanBuilder.Object,
+ provider.GetRequiredService(),
+ this.mockLogger.Object,
+ this.appSettings,
+ provider.GetRequiredService(),
+ this.mockProgressUpdater.Object)
+ { CallBase = true };
+
+ this.service = this.mockTableauMigrationService.Object;
+ }
+
+ [Fact]
+ public void BuildMigrationPlan_ShouldReturnTrue_WhenValidationSucceeds()
+ {
+ var serverEndpoints = new EndpointOptions();
+ var cloudEndpoints = new EndpointOptions();
+ this.mockPlanBuilder.Setup(pb => pb.Validate()).Returns(
+ new TestResult(true, ImmutableList.Empty));
+
+ var result = this.service.BuildMigrationPlan(serverEndpoints, cloudEndpoints);
+
+ Assert.True(result);
+ this.mockPlanBuilder.Verify(pb => pb.Validate(), Times.Once);
+ }
+
+ [Fact]
+ public void BuildMigrationPlan_ShouldReturnFalse_WhenValidationFails()
+ {
+ var serverEndpoints = new EndpointOptions();
+ var cloudEndpoints = new EndpointOptions();
+ this.mockPlanBuilder.Setup(pb => pb.Validate()).Returns(new TestResult(false, ImmutableList.Empty));
+
+ var result = this.service.BuildMigrationPlan(serverEndpoints, cloudEndpoints);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task StartMigrationTaskAsync_ShouldReturnFailure_WhenPlanIsNotBuilt()
+ {
+ var cancelToken = CancellationToken.None;
+
+ var result = await this.service.StartMigrationTaskAsync(cancelToken);
+
+ Assert.Equal(ITableauMigrationService.MigrationStatus.FAILURE, result.status);
+ }
+
+ [Fact]
+ public async Task SaveManifestAsync_ShouldReturnFalse_WhenManifestIsNull()
+ {
+ string manifestFilePath = "testPath";
+
+ var result = await this.service.SaveManifestAsync(manifestFilePath);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task LoadManifestAsync_ShouldReturnFalse_WhenManifestFilePathIsInvalid()
+ {
+ string manifestFilePath = string.Empty;
+ var cancelToken = CancellationToken.None;
+
+ var result = await this.service.LoadManifestAsync(manifestFilePath, cancelToken);
+
+ Assert.False(result);
+ }
+
+ [Fact]
+ public async Task ResumeMigrationTaskAsync_ShouldReturnFailure_WhenManifestIsNotLoaded()
+ {
+ string manifestFilePath = "testPath";
+ var cancelToken = CancellationToken.None;
+ this.mockPlanBuilder.Setup(pb => pb.Validate()).Returns(new TestResult(true, ImmutableList.Empty));
+ var mockMigrationPlan = new Mock();
+ this.mockPlanBuilder.Setup(pb => pb.Build()).Returns(mockMigrationPlan.Object);
+
+ var serverEndpoints = new EndpointOptions();
+ var cloudEndpoints = new EndpointOptions();
+ this.service.BuildMigrationPlan(serverEndpoints, cloudEndpoints);
+
+ var result = await this.service.ResumeMigrationTaskAsync(manifestFilePath, cancelToken);
+
+ Assert.Equal(ITableauMigrationService.MigrationStatus.FAILURE, result.status);
+ }
+
+ [Fact]
+ public async Task ResumeMigrationTaskAsync_ShouldReturnFailure_WhenPlanIsNotBuilt()
+ {
+ string manifestFilePath = "testPath";
+ var cancelToken = CancellationToken.None;
+
+ var result = await this.service.ResumeMigrationTaskAsync(manifestFilePath, cancelToken);
+
+ Assert.Equal(ITableauMigrationService.MigrationStatus.FAILURE, result.status);
+ }
+
+ [Fact]
+ public async Task ResumeMigrationTaskAsync_ShouldExecuteMigration_WhenNoErrors()
+ {
+ this.mockPlanBuilder.Setup(pb => pb.Validate()).Returns(new TestResult(true, ImmutableList.Empty));
+ var mockMigrationPlan = new Mock();
+ this.mockPlanBuilder.Setup(pb => pb.Build()).Returns(mockMigrationPlan.Object);
+
+ var planId = Guid.NewGuid();
+ var migrationId = Guid.NewGuid();
+ var mockManifest = new Mock(
+ this.mockLocalizer.Object,
+ this.mockLoggerFactory.Object,
+ planId,
+ migrationId,
+ (IMigrationManifest?)null!);
+ var manifestTask = Task.FromResult(mockManifest.Object);
+
+ var cancelToken = CancellationToken.None;
+ string manifestFilePath = "testPath";
+
+ var result = await this.service.ResumeMigrationTaskAsync(manifestFilePath, cancelToken);
+
+ Assert.Equal(ITableauMigrationService.MigrationStatus.FAILURE, result.status);
+ }
+
+ [Fact]
+ public void IsMigrationPlanBuilt()
+ {
+ var isBuilt = this.service.IsMigrationPlanBuilt();
+ Assert.False(isBuilt);
+
+ var serverEndpoints = new EndpointOptions();
+ var cloudEndpoints = new EndpointOptions();
+ this.mockPlanBuilder.Setup(pb => pb.Validate()).Returns(new TestResult(true, ImmutableList.Empty));
+
+ var mockMigrationPlan = new Mock();
+
+ this.mockPlanBuilder.Setup(pb => pb.Build()).Returns(mockMigrationPlan.Object);
+ Assert.True(this.service.BuildMigrationPlan(serverEndpoints, cloudEndpoints));
+
+ isBuilt = this.service.IsMigrationPlanBuilt();
+ Assert.True(isBuilt);
+ }
+
+ internal class TestResult : IResult
+ {
+ private bool success;
+ private IImmutableList errors;
+
+ public TestResult(bool success, IImmutableList errors)
+ {
+ this.success = success;
+ this.errors = errors;
+ }
+
+ public bool Success
+ {
+ get => this.success;
+ }
+
+ public IImmutableList Errors
+ {
+ get => this.errors;
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/ServiceCollectionExtensions.cs b/tests/Tableau.Migration.App.Core.Tests/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..1ba0c35
--- /dev/null
+++ b/tests/Tableau.Migration.App.Core.Tests/ServiceCollectionExtensions.cs
@@ -0,0 +1,50 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 ServiceCollectionExtensionsTests;
+
+using Microsoft.Extensions.DependencyInjection;
+using Tableau.Migration.App.Core;
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.Core.Hooks.Mappings;
+using Tableau.Migration.App.Core.Hooks.Progression;
+using Tableau.Migration.App.Core.Interfaces;
+using Xunit;
+
+public class ServiceCollectionExtensionsTests
+{
+ [Fact]
+ public void AddMigrationAppCore_Services()
+ {
+ var serviceCollection = new ServiceCollection();
+ var configuration = ServiceCollectionExtensions.BuildConfiguration();
+ serviceCollection.AddMigrationAppCore(configuration);
+ var provider = serviceCollection.BuildServiceProvider();
+
+ // Verify that the set of dependencies are injected
+ Assert.NotNull(provider.GetRequiredService());
+ Assert.NotNull(provider.GetRequiredService());
+ Assert.NotNull(provider.GetRequiredService());
+ Assert.NotNull(provider.GetRequiredService());
+
+ // DicitonaryUserMapping needs to be verified through the collection as the
+ // service can't be retrieved before Tableau Migration SDK entities are set up.
+ Assert.Contains(
+ serviceCollection,
+ service => service.ServiceType == typeof(DictionaryUserMapping));
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.Core.Tests/Tableau.Migration.App.Core.Tests.csproj b/tests/Tableau.Migration.App.Core.Tests/Tableau.Migration.App.Core.Tests.csproj
index c493d84..7efba38 100644
--- a/tests/Tableau.Migration.App.Core.Tests/Tableau.Migration.App.Core.Tests.csproj
+++ b/tests/Tableau.Migration.App.Core.Tests/Tableau.Migration.App.Core.Tests.csproj
@@ -9,8 +9,12 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
-
+
diff --git a/tests/Tableau.Migration.App.GUI.Tests/App.axaml.cs b/tests/Tableau.Migration.App.GUI.Tests/App.axaml.cs
new file mode 100644
index 0000000..a95df25
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/App.axaml.cs
@@ -0,0 +1,34 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 AppTests;
+
+using Avalonia.Headless.XUnit;
+using Tableau.Migration.App.GUI;
+using Xunit;
+
+public class AppTests
+{
+ [AvaloniaFact]
+ public void App_NativeMenuItem_AppNameVersion()
+ {
+ // Verify that the proper constant values are being set to the App menu binding values
+ var app = new App();
+ Assert.Equal(Constants.AppName, app.Name);
+ Assert.Equal(Constants.AppNameVersion, app.AppNameVersion);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/AppTests.cs b/tests/Tableau.Migration.App.GUI.Tests/AppTests.cs
deleted file mode 100644
index 9868ce3..0000000
--- a/tests/Tableau.Migration.App.GUI.Tests/AppTests.cs
+++ /dev/null
@@ -1,104 +0,0 @@
-//
-// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
-// 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
-// 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.App.GUI.Tests
-{
- using System;
- using Avalonia.Controls.ApplicationLifetimes;
- using Xunit;
-
- public class AppTests
- {
- [Fact]
- public void Should_Call_OnApplicationExit_When_Desktop_Exit_Is_Triggered()
- {
- var app = new TestableApp();
- var mockDesktopLifetime = new MockClassicDesktopStyleApplicationLifetime(app);
-
- mockDesktopLifetime.TriggerExit();
-
- Assert.True(app.ExitCalled, "OnApplicationExit should be called once during Exit.");
- }
-
- [Fact]
- public void Should_Call_OnApplicationExit_When_UnhandledExceptionOccurs()
- {
- var app = new TestableApp();
- var exception = new Exception("Test exception");
-
- app.HandleUnhandledException(exception);
-
- Assert.True(app.ExitCalled, "OnApplicationExit should be called when an unhandled exception occurs.");
- }
-
- [Fact]
- public void Should_Call_OnApplicationExit_When_CancelKeyPress_Is_Handled()
- {
- var app = new TestableApp();
- var cancelEventArgs = this.CreateConsoleCancelEventArgs(ConsoleSpecialKey.ControlC);
-
- app.HandleCancelKeyPress(cancelEventArgs);
-
- Assert.True(app.ExitCalled, "OnApplicationExit should be called when CancelKeyPress is handled.");
- Assert.True(cancelEventArgs.Cancel, "Cancel should be set to true when handling CancelKeyPress.");
- }
-
- private ConsoleCancelEventArgs CreateConsoleCancelEventArgs(ConsoleSpecialKey key)
- {
- var consoleCancelEventArgsType = typeof(ConsoleCancelEventArgs);
- var ctor = consoleCancelEventArgsType.GetConstructor(
- System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance,
- null,
- new[] { typeof(ConsoleSpecialKey) },
- null);
-
- if (ctor == null)
- {
- throw new InvalidOperationException("Could not retrieve ConsoleCancelEventArgs constructor.");
- }
-
- return (ConsoleCancelEventArgs)ctor.Invoke(new object[] { key });
- }
-
- private class MockClassicDesktopStyleApplicationLifetime
- {
- private readonly IClassicDesktopStyleApplicationLifetime desktopLifetime;
-
- public MockClassicDesktopStyleApplicationLifetime(TestableApp app)
- {
- this.desktopLifetime = new ClassicDesktopStyleApplicationLifetime();
- this.desktopLifetime.Exit += (sender, args) => app.OnApplicationExit();
- }
-
- public void TriggerExit()
- {
- this.desktopLifetime.Shutdown();
- }
- }
-
- private class TestableApp : App
- {
- public bool ExitCalled { get; private set; } = false;
-
- public override void OnApplicationExit()
- {
- this.ExitCalled = true;
- base.OnApplicationExit();
- }
- }
- }
-}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Models/MigrationTimer.cs b/tests/Tableau.Migration.App.GUI.Tests/Models/MigrationTimer.cs
new file mode 100644
index 0000000..9f40c75
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/Models/MigrationTimer.cs
@@ -0,0 +1,110 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 MigrationTimerTests;
+
+using Avalonia.Headless.XUnit;
+using Microsoft.Extensions.Logging;
+using Moq;
+using System;
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.GUI.Models;
+using Xunit;
+
+public class MigrationTimerTests
+{
+ [AvaloniaFact]
+ public void MigrationTimer_WhenInitialized_StartsWithDefaultValues()
+ {
+ var loggerMock = new Mock>();
+ var migrationTimer = new MigrationTimer(loggerMock.Object);
+
+ Assert.Equal(string.Empty, migrationTimer.GetTotalMigrationTime);
+ }
+
+ [AvaloniaFact]
+ public void UpdateMigrationTimes_WhenMigrationStarted_LogsStartTime()
+ {
+ var loggerMock = new Mock>();
+ var migrationTimer = new MigrationTimer(loggerMock.Object);
+
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationStarted);
+
+ Assert.NotEqual(string.Empty, migrationTimer.GetTotalMigrationTime);
+ }
+
+ [AvaloniaFact]
+ public void UpdateMigrationTimes_WhenMigrationFinished_StoreStopTime()
+ {
+ var loggerMock = new Mock>();
+ var migrationTimer = new MigrationTimer(loggerMock.Object);
+
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationStarted);
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.Migrationfinished);
+
+ Assert.NotEqual(string.Empty, migrationTimer.GetTotalMigrationTime);
+ }
+
+ [AvaloniaFact]
+ public void UpdateMigrationTimes_WhenMigrationActionCompleted_storeActionTime()
+ {
+ var loggerMock = new Mock>();
+ var migrationTimer = new MigrationTimer(loggerMock.Object);
+ string actionName = "TestAction";
+
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationActionCompleted, actionName);
+
+ Assert.NotEqual(string.Empty, migrationTimer.GetMigrationActionTime(actionName));
+ }
+
+ [AvaloniaFact]
+ public void UpdateMigrationTimes_WhenActionAlreadyExists_NoErrors()
+ {
+ var loggerMock = new Mock>();
+ var migrationTimer = new MigrationTimer(loggerMock.Object);
+ string actionName = "TestAction";
+
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationActionCompleted, actionName);
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationActionCompleted, actionName);
+ }
+
+ [AvaloniaFact]
+ public void UpdateMigrationTimes_WhenActionEmpty_DoNothing()
+ {
+ var loggerMock = new Mock>();
+ var migrationTimer = new MigrationTimer(loggerMock.Object);
+ string actionName = string.Empty;
+
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationActionCompleted, actionName);
+
+ Assert.Equal(string.Empty, migrationTimer.GetMigrationActionTime(actionName));
+ }
+
+ [AvaloniaFact]
+ public void Reset_ShouldClearAllTimes()
+ {
+ var loggerMock = new Mock>();
+ var migrationTimer = new MigrationTimer(loggerMock.Object);
+
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationStarted);
+ migrationTimer.UpdateMigrationTimes(MigrationTimerEventType.MigrationActionCompleted, "TestAction");
+ migrationTimer.Reset();
+
+ Assert.Equal(string.Empty, migrationTimer.GetTotalMigrationTime);
+ Assert.Equal(string.Empty, migrationTimer.GetMigrationActionTime("TestAction"));
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressMessagePublisher.cs b/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressMessagePublisher.cs
new file mode 100644
index 0000000..53b2874
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressMessagePublisher.cs
@@ -0,0 +1,46 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 ProgressMessagePublisherTests;
+
+using Avalonia.Headless.XUnit;
+using Tableau.Migration.App.Core.Entities;
+using Tableau.Migration.App.GUI.Models;
+using Xunit;
+
+public class ProgressMessagePublisherTests
+{
+ [AvaloniaFact]
+ public void ProgressMessagePublisher()
+ {
+ var publisher = new ProgressMessagePublisher();
+ Assert.NotNull(publisher);
+
+ string expectedMessage = "TestMessage";
+ ProgressEventArgs? receivedEventArgs = null;
+
+ publisher.OnProgressMessage += (args) =>
+ {
+ receivedEventArgs = args;
+ };
+
+ publisher.PublishProgressMessage(expectedMessage);
+
+ Assert.NotNull(receivedEventArgs);
+ Assert.Equal(expectedMessage, receivedEventArgs?.Message);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressMessagePublisherTests.cs b/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressMessagePublisherTests.cs
deleted file mode 100644
index e69de29..0000000
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressUpdaterTests.cs b/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressUpdater.cs
similarity index 59%
rename from tests/Tableau.Migration.App.GUI.Tests/Models/ProgressUpdaterTests.cs
rename to tests/Tableau.Migration.App.GUI.Tests/Models/ProgressUpdater.cs
index e7cca8d..6c706be 100644
--- a/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressUpdaterTests.cs
+++ b/tests/Tableau.Migration.App.GUI.Tests/Models/ProgressUpdater.cs
@@ -1,4 +1,4 @@
-//
+//
// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
// SPDX-License-Identifier: Apache-2
//
@@ -42,7 +42,7 @@ public void ProgressUpdater_Should_Update_State_On_Update_Call()
progressUpdater.Update();
Assert.Equal(0, progressUpdater.CurrentMigrationStateIndex);
- Assert.Equal("User", progressUpdater.CurrentMigrationStateName);
+ Assert.Equal("Setup", progressUpdater.CurrentMigrationStateName);
progressUpdater.CurrentMigrationStateIndex = ProgressUpdater.NumMigrationStates + 1;
Assert.Equal(0, progressUpdater.CurrentMigrationStateIndex); // Don't update if state index is out of bounds
@@ -63,4 +63,43 @@ public void ProgressUpdater_Should_Fire_OnProgressChanged_Event_When_State_Chang
Assert.True(eventFired);
}
+
+ [Fact]
+ public void ProgressUpdater_Reset_ShouldSetStateIndex()
+ {
+ var progressUpdater = new ProgressUpdater();
+ Assert.Equal(-1, progressUpdater.CurrentMigrationStateIndex);
+ progressUpdater.Update();
+ Assert.Equal(0, progressUpdater.CurrentMigrationStateIndex);
+ progressUpdater.Reset();
+ Assert.Equal(-1, progressUpdater.CurrentMigrationStateIndex);
+ }
+
+ [Fact]
+ public void ProgressUpdater_MigrationMessageEmpty_WhenNotMigrating()
+ {
+ var progressUpdater = new ProgressUpdater();
+ Assert.Empty(progressUpdater.CurrentMigrationMessage);
+ }
+
+ [Fact]
+ public void ProgressUpdater_MigrationMessageNotEmpty_WhenMigrating()
+ {
+ var progressUpdater = new ProgressUpdater();
+ progressUpdater.Update();
+ Assert.NotEmpty(progressUpdater.CurrentMigrationMessage);
+ Assert.Equal(progressUpdater.CurrentMigrationStateName, progressUpdater.CurrentMigrationMessage);
+ }
+
+ [Fact]
+ public void ProgressUpdater_MigrationMessageFinished_WhenMigrationCompleted()
+ {
+ var progressUpdater = new ProgressUpdater();
+ for (int i = 0; i <= ProgressUpdater.NumMigrationStates; i++)
+ {
+ progressUpdater.Update();
+ }
+
+ Assert.Equal("Migration Finished.", progressUpdater.CurrentMigrationMessage);
+ }
}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/CsvHelperParser.cs b/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/CsvHelperParser.cs
new file mode 100644
index 0000000..a6a3afc
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/CsvHelperParser.cs
@@ -0,0 +1,209 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 CsvHelperParserTests;
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Tableau.Migration.App.GUI.Services.Implementations;
+using Tableau.Migration.App.GUI.Services.Interfaces;
+using Xunit;
+
+public class CsvHelperParserTests : IDisposable
+{
+ private readonly string tempFilePath;
+
+ public CsvHelperParserTests()
+ {
+ // Create a temporary file to be used in each test
+ this.tempFilePath = Path.GetTempFileName();
+ }
+
+ [Fact]
+ public void Dispose()
+ {
+ // Clean up the temporary file after each test
+ if (File.Exists(this.tempFilePath))
+ {
+ File.Delete(this.tempFilePath);
+ }
+ }
+
+ [Fact]
+ public async Task ParseAsync_ValidCsv_ReturnsCorrectDictionary()
+ {
+ // Setup
+ var csvContent = "serverUser1,cloudUser1@host1.com\nserverUser2,cloudUser2@host2.com\nserverUser3,cloudUser3@host3.com";
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ var expected = new Dictionary
+ {
+ { "serverUser1", "cloudUser1@host1.com" },
+ { "serverUser2", "cloudUser2@host2.com" },
+ { "serverUser3", "cloudUser3@host3.com" },
+ };
+
+ // Parse File
+ var result = await parser.ParseAsync(this.tempFilePath);
+
+ // Assert
+ Assert.Equal(expected.Count, result.Count);
+ foreach (var userEntry in expected)
+ {
+ Assert.True(result.ContainsKey(userEntry.Key), $"Result does not contain key '{userEntry.Key}'.");
+ Assert.Equal(userEntry.Value, result[userEntry.Key]);
+ }
+ }
+
+ [Fact]
+ public async Task ParseAsync_CsvWithExtraColumns_ThrowsInvalidDataException()
+ {
+ // Setup data contianing row with extra column
+ var csvContent = "serverUser1,cloudUser1@host1.com,extraColumn\nserverUser2,cloudUser2@host2.com";
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ // Attempt to Parse File
+ await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
+ }
+
+ [Fact]
+ public async Task ParseAsync_CsvWithInvalidEmail_ThrowsInvalidDataException()
+ {
+ // Setup data contianing row with extra column
+ var csvContent = "serverUser1,cloudUser1@host1.com,nserverUser2,cloudUser2@host2";
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ // Attempt to Parse File
+ await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
+ }
+
+ [Fact]
+ public async Task ParseAsync_EmptyCsv_ReturnsEmptyDictionary()
+ {
+ // Setup
+ var csvContent = string.Empty;
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ // Parse Empty File
+ var result = await parser.ParseAsync(this.tempFilePath);
+
+ // Expect an empty dictionary back
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public async Task ParseAsync_DuplicateServerUsername_OverwritesWithLatestCloudUsername()
+ {
+ // Setup dicitonary with multiple entries for the same user
+ var csvContent = "serverUser1,cloudUser1@host1.com\nserverUser2,cloudUser2@host2.com\nserverUser1,cloudUser3@host3.com";
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ var expected = new Dictionary
+ {
+ { "serverUser1", "cloudUser3@host3.com" }, // Overwritten
+ { "serverUser2", "cloudUser2@host2.com" },
+ };
+
+ // Parse File
+ var result = await parser.ParseAsync(this.tempFilePath);
+
+ // Verify that only the last entry was used
+ Assert.Equal(expected.Count, result.Count);
+ foreach (var userEntry in expected)
+ {
+ Assert.True(result.ContainsKey(userEntry.Key), $"Result does not contain key '{userEntry.Key}'.");
+ Assert.Equal(userEntry.Value, result[userEntry.Key]);
+ }
+ }
+
+ [Fact]
+ public async Task ParseAsync_InvalidCSV_MissingColumn_ThrowsInvalidData()
+ {
+ // Setup invalid CSV with invalid rows containing only one value
+ var csvContent = "serverUser1,cloudUser1@host1.com\n,cloudUser2\nserverUser3@host3.com,\nserverUser4,cloudUser4@host4.com";
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ // Attempt to parse file
+ await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
+ }
+
+ [Fact]
+ public async Task ParseAsync_InvalidCSV_NonEmailCloudUsernames_ThrowsInvalidData()
+ {
+ // Setup invalid CSV with invalid rows containing only one value
+ var csvContent = "serverUser1,cloudUser1@host1.com\n,serverUser2,cloudUser2\nserverUser3@host3.com,\nserverUser4,cloudUser4@host4.com";
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ // Attempt to parse file
+ await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
+ }
+
+ [Fact]
+ public async Task ParseAsync_FileDoesNotExist_ThrowsFileNotFoundException()
+ {
+ // Setup path to non existent file
+ var nonExistentFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".csv");
+ ICsvParser parser = new CsvHelperParser();
+
+ // Attempt to parse file
+ Assert.False(File.Exists(nonExistentFilePath), $"Test setup error: The file '{nonExistentFilePath}' unexpectedly exists.");
+ await Assert.ThrowsAsync(() => parser.ParseAsync(nonExistentFilePath));
+ }
+
+ [Fact]
+ public async Task ParseAsync_CsvWithWhitespace_ParsesCorrectly()
+ {
+ // Setup files with leading and trailing spaces in usernames
+ var csvContent = " serverUser1 , cloudUser1@host1.com \nserverUser2,cloudUser2@host2.com";
+ await File.WriteAllTextAsync(this.tempFilePath, csvContent);
+
+ ICsvParser parser = new CsvHelperParser();
+
+ var expected = new Dictionary
+ {
+ { "serverUser1", "cloudUser1@host1.com" }, // Whitespaces are trimmed
+ { "serverUser2", "cloudUser2@host2.com" },
+ };
+
+ // Parse file
+ var result = await parser.ParseAsync(this.tempFilePath);
+
+ // Verify that parsed usernames are trimmed
+ Assert.Equal(expected.Count, result.Count);
+ foreach (var kvp in expected)
+ {
+ Assert.True(result.ContainsKey(kvp.Key), $"Result does not contain key '{kvp.Key}'.");
+ Assert.Equal(kvp.Value, result[kvp.Key]);
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/CsvHelperParserTests.cs b/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/CsvHelperParserTests.cs
deleted file mode 100644
index 94e0a31..0000000
--- a/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/CsvHelperParserTests.cs
+++ /dev/null
@@ -1,210 +0,0 @@
-//
-// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
-// 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
-// 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 MigrationApp.Tests.Services
-{
- using System;
- using System.Collections.Generic;
- using System.IO;
- using System.Threading.Tasks;
- using Tableau.Migration.App.GUI.Services.Implementations;
- using Tableau.Migration.App.GUI.Services.Interfaces;
- using Xunit;
-
- public class CsvHelperParserTests : IDisposable
- {
- private readonly string tempFilePath;
-
- public CsvHelperParserTests()
- {
- // Create a temporary file to be used in each test
- this.tempFilePath = Path.GetTempFileName();
- }
-
- [Fact]
- public void Dispose()
- {
- // Clean up the temporary file after each test
- if (File.Exists(this.tempFilePath))
- {
- File.Delete(this.tempFilePath);
- }
- }
-
- [Fact]
- public async Task ParseAsync_ValidCsv_ReturnsCorrectDictionary()
- {
- // Setup
- var csvContent = "serverUser1,cloudUser1@host1.com\nserverUser2,cloudUser2@host2.com\nserverUser3,cloudUser3@host3.com";
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- var expected = new Dictionary
- {
- { "serverUser1", "cloudUser1@host1.com" },
- { "serverUser2", "cloudUser2@host2.com" },
- { "serverUser3", "cloudUser3@host3.com" },
- };
-
- // Parse File
- var result = await parser.ParseAsync(this.tempFilePath);
-
- // Assert
- Assert.Equal(expected.Count, result.Count);
- foreach (var userEntry in expected)
- {
- Assert.True(result.ContainsKey(userEntry.Key), $"Result does not contain key '{userEntry.Key}'.");
- Assert.Equal(userEntry.Value, result[userEntry.Key]);
- }
- }
-
- [Fact]
- public async Task ParseAsync_CsvWithExtraColumns_ThrowsInvalidDataException()
- {
- // Setup data contianing row with extra column
- var csvContent = "serverUser1,cloudUser1@host1.com,extraColumn\nserverUser2,cloudUser2@host2.com";
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- // Attempt to Parse File
- await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
- }
-
- [Fact]
- public async Task ParseAsync_CsvWithInvalidEmail_ThrowsInvalidDataException()
- {
- // Setup data contianing row with extra column
- var csvContent = "serverUser1,cloudUser1@host1.com,nserverUser2,cloudUser2@host2";
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- // Attempt to Parse File
- await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
- }
-
- [Fact]
- public async Task ParseAsync_EmptyCsv_ReturnsEmptyDictionary()
- {
- // Setup
- var csvContent = string.Empty;
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- // Parse Empty File
- var result = await parser.ParseAsync(this.tempFilePath);
-
- // Expect an empty dictionary back
- Assert.Empty(result);
- }
-
- [Fact]
- public async Task ParseAsync_DuplicateServerUsername_OverwritesWithLatestCloudUsername()
- {
- // Setup dicitonary with multiple entries for the same user
- var csvContent = "serverUser1,cloudUser1@host1.com\nserverUser2,cloudUser2@host2.com\nserverUser1,cloudUser3@host3.com";
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- var expected = new Dictionary
- {
- { "serverUser1", "cloudUser3@host3.com" }, // Overwritten
- { "serverUser2", "cloudUser2@host2.com" },
- };
-
- // Parse File
- var result = await parser.ParseAsync(this.tempFilePath);
-
- // Verify that only the last entry was used
- Assert.Equal(expected.Count, result.Count);
- foreach (var userEntry in expected)
- {
- Assert.True(result.ContainsKey(userEntry.Key), $"Result does not contain key '{userEntry.Key}'.");
- Assert.Equal(userEntry.Value, result[userEntry.Key]);
- }
- }
-
- [Fact]
- public async Task ParseAsync_InvalidCSV_MissingColumn_ThrowsInvalidData()
- {
- // Setup invalid CSV with invalid rows containing only one value
- var csvContent = "serverUser1,cloudUser1@host1.com\n,cloudUser2\nserverUser3@host3.com,\nserverUser4,cloudUser4@host4.com";
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- // Attempt to parse file
- await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
- }
-
- [Fact]
- public async Task ParseAsync_InvalidCSV_NonEmailCloudUsernames_ThrowsInvalidData()
- {
- // Setup invalid CSV with invalid rows containing only one value
- var csvContent = "serverUser1,cloudUser1@host1.com\n,serverUser2,cloudUser2\nserverUser3@host3.com,\nserverUser4,cloudUser4@host4.com";
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- // Attempt to parse file
- await Assert.ThrowsAsync(() => parser.ParseAsync(this.tempFilePath));
- }
-
- [Fact]
- public async Task ParseAsync_FileDoesNotExist_ThrowsFileNotFoundException()
- {
- // Setup path to non existent file
- var nonExistentFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".csv");
- ICsvParser parser = new CsvHelperParser();
-
- // Attempt to parse file
- Assert.False(File.Exists(nonExistentFilePath), $"Test setup error: The file '{nonExistentFilePath}' unexpectedly exists.");
- await Assert.ThrowsAsync(() => parser.ParseAsync(nonExistentFilePath));
- }
-
- [Fact]
- public async Task ParseAsync_CsvWithWhitespace_ParsesCorrectly()
- {
- // Setup files with leading and trailing spaces in usernames
- var csvContent = " serverUser1 , cloudUser1@host1.com \nserverUser2,cloudUser2@host2.com";
- await File.WriteAllTextAsync(this.tempFilePath, csvContent);
-
- ICsvParser parser = new CsvHelperParser();
-
- var expected = new Dictionary
- {
- { "serverUser1", "cloudUser1@host1.com" }, // Whitespaces are trimmed
- { "serverUser2", "cloudUser2@host2.com" },
- };
-
- // Parse file
- var result = await parser.ParseAsync(this.tempFilePath);
-
- // Verify that parsed usernames are trimmed
- Assert.Equal(expected.Count, result.Count);
- foreach (var kvp in expected)
- {
- Assert.True(result.ContainsKey(kvp.Key), $"Result does not contain key '{kvp.Key}'.");
- Assert.Equal(kvp.Value, result[kvp.Key]);
- }
- }
- }
-}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/FilePicker.cs b/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/FilePicker.cs
new file mode 100644
index 0000000..bbd3d6d
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/FilePicker.cs
@@ -0,0 +1,66 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 FilePickerTests;
+
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using Avalonia.Platform.Storage;
+using Moq;
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Tableau.Migration.App.GUI.Services.Implementations;
+using Tableau.Migration.App.GUI.Services.Interfaces;
+using Xunit;
+
+public class FilePickerTests
+{
+ [AvaloniaFact]
+ public void FilePicker()
+ {
+ var mockWindowProvider = new Mock();
+ var fp = new FilePicker(mockWindowProvider.Object);
+ Assert.NotNull(fp);
+ }
+
+ [AvaloniaFact(Skip = "Skipped until StorageProvider setting logic is abstracted.")]
+ public async Task OpenFilePickerAsync_ReturnsSelectedFile()
+ {
+ var mockWindowProvider = new Mock();
+ var mockWindow = new Mock();
+ var mockStorageProvider = new Mock();
+ var mockStorageFile = new Mock();
+
+ mockWindowProvider.Setup(w => w.GetMainWindow()).Returns(mockWindow.Object);
+ mockWindow.SetupGet(
+ sp => sp.StorageProvider).Returns(mockStorageProvider.Object);
+
+ // Being unable to mock the main window's StorageProvider will make the
+ // function short citcuit out. The function will need to be refactored
+ // to support testing properly.
+ mockStorageProvider.Setup(s => s.OpenFilePickerAsync(It.IsAny())).ReturnsAsync(new List { mockStorageFile.Object });
+
+ var filePicker = new FilePicker(mockWindowProvider.Object);
+
+ var result = await filePicker.OpenFilePickerAsync("Select File");
+
+ Assert.NotNull(result);
+ Assert.Equal(mockStorageFile.Object, result);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/WindowProvider.cs b/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/WindowProvider.cs
new file mode 100644
index 0000000..2a1f429
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/Services/Implementations/WindowProvider.cs
@@ -0,0 +1,56 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 WindowProviderTests;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Headless;
+using Avalonia.Headless.XUnit;
+using System;
+using Tableau.Migration.App.GUI.Services.Implementations;
+using Tableau.Migration.App.GUI.Tests;
+using Xunit;
+
+public class WindowProviderTests
+{
+ [AvaloniaFact]
+ public void GetMainWindow_ShouldReturnMainWindow_WhenApplicationLifetimeIsClassicDesktop()
+ {
+ var appBuilder = AppBuilder.Configure().UseHeadless(new AvaloniaHeadlessPlatformOptions());
+ var lifetime = new ClassicDesktopStyleApplicationLifetime { MainWindow = new Window() };
+ appBuilder.SetupWithLifetime(lifetime);
+
+ var windowProvider = new WindowProvider();
+ var mainWindow = windowProvider.GetMainWindow();
+
+ Assert.NotNull(mainWindow);
+ Assert.Equal(lifetime.MainWindow, mainWindow);
+ }
+
+ [AvaloniaFact(Skip = "AppBuilder singleton conflict between tests. Skipped until that is resolved.")]
+ public void GetMainWindow_ShouldReturnMainWindow_2()
+ {
+ var appBuilder = AppBuilder.Configure().UseHeadless(new AvaloniaHeadlessPlatformOptions());
+ var lifetime = new ClassicDesktopStyleApplicationLifetime();
+ appBuilder.SetupWithLifetime(lifetime);
+
+ var windowProvider = new WindowProvider();
+
+ Assert.Throws(() => windowProvider.GetMainWindow());
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/Tableau.Migration.App.GUI.Tests.csproj b/tests/Tableau.Migration.App.GUI.Tests/Tableau.Migration.App.GUI.Tests.csproj
index 0d93d63..986ea76 100644
--- a/tests/Tableau.Migration.App.GUI.Tests/Tableau.Migration.App.GUI.Tests.csproj
+++ b/tests/Tableau.Migration.App.GUI.Tests/Tableau.Migration.App.GUI.Tests.csproj
@@ -6,11 +6,15 @@
enablefalsetrue
- true
- true
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
@@ -39,7 +43,7 @@
-
+
diff --git a/tests/Tableau.Migration.App.GUI.Tests/TestApp.axaml b/tests/Tableau.Migration.App.GUI.Tests/TestApp.axaml
new file mode 100644
index 0000000..9c17e8c
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/TestApp.axaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/tests/Tableau.Migration.App.GUI.Tests/TestApp.axaml.cs b/tests/Tableau.Migration.App.GUI.Tests/TestApp.axaml.cs
new file mode 100644
index 0000000..7a39bb8
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/TestApp.axaml.cs
@@ -0,0 +1,29 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.GUI.Tests;
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Markup.Xaml;
+
+public class TestApp : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/TestAppBuilder.cs b/tests/Tableau.Migration.App.GUI.Tests/TestAppBuilder.cs
new file mode 100644
index 0000000..aa821a8
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/TestAppBuilder.cs
@@ -0,0 +1,29 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+using Avalonia;
+using Avalonia.Headless;
+using Avalonia.Markup.Xaml;
+using Tableau.Migration.App.GUI.Tests;
+
+[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
+
+public class TestAppBuilder
+{
+ public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure()
+ .UseHeadless(new AvaloniaHeadlessPlatformOptions());
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/ViewLocator.cs b/tests/Tableau.Migration.App.GUI.Tests/ViewLocator.cs
new file mode 100644
index 0000000..f4c8838
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/ViewLocator.cs
@@ -0,0 +1,93 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 ViewLocatoreTests;
+using Avalonia.Controls;
+using Avalonia.Headless.XUnit;
+using System;
+using Tableau.Migration.App.GUI;
+using Tableau.Migration.App.GUI.ViewModels;
+using Xunit;
+
+public class ViewLocatorTests
+{
+ [AvaloniaFact]
+ public void Build_NullData_ReturnsNull()
+ {
+ var viewLocator = new ViewLocator();
+
+ var result = viewLocator.Build(null);
+
+ Assert.Null(result);
+ }
+
+ [AvaloniaFact]
+ public void Build_UnknownViewModel_ReturnsNotFoundTextBlock()
+ {
+ var viewLocator = new ViewLocator();
+ var unknownViewModel = new UnknownViewModel();
+
+ var result = viewLocator.Build(unknownViewModel);
+
+ var textBlock = Assert.IsType(result);
+ Assert.StartsWith("Not Found: ", textBlock.Text);
+ }
+
+ [AvaloniaFact]
+ public void Match_NullData_ReturnsFalse()
+ {
+ var viewLocator = new ViewLocator();
+
+ var result = viewLocator.Match(null);
+
+ Assert.False(result);
+ }
+
+ [AvaloniaFact]
+ public void Match_ValidViewModelBase_ReturnsTrue()
+ {
+ var viewLocator = new ViewLocator();
+ var viewModelBase = new ValidViewModel();
+
+ var result = viewLocator.Match(viewModelBase);
+
+ Assert.True(result);
+ }
+
+ [AvaloniaFact]
+ public void Match_NonViewModelBase_ReturnsFalse()
+ {
+ var viewLocator = new ViewLocator();
+ var nonViewModelBase = new object();
+
+ var result = viewLocator.Match(nonViewModelBase);
+
+ Assert.False(result);
+ }
+}
+
+public class UnknownViewModel : ViewModelBase
+{
+}
+
+public class ValidViewModel : ViewModelBase
+{
+}
+
+public class ValidView : Control
+{
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/ViewModels/AuthCredentialsViewModel.cs b/tests/Tableau.Migration.App.GUI.Tests/ViewModels/AuthCredentialsViewModel.cs
new file mode 100644
index 0000000..e47fb60
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/ViewModels/AuthCredentialsViewModel.cs
@@ -0,0 +1,37 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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 AuthCredentialsViewModelTests;
+
+using Avalonia.Headless.XUnit;
+using Moq;
+using System.Collections.Generic;
+using System.Linq;
+using Tableau.Migration.App.GUI.Models;
+using Tableau.Migration.App.GUI.ViewModels;
+using Xunit;
+
+public class AuthCredentialsViewModelTests
+{
+ [AvaloniaFact]
+ public void Constructor_InitializesUriAndTokenDetailsViewModels()
+ {
+ var viewModel = new AuthCredentialsViewModel();
+ Assert.NotNull(viewModel.UriDetailsVM);
+ Assert.NotNull(viewModel.TokenDetailsVM);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/ViewModels/MainWindowViewModel.cs b/tests/Tableau.Migration.App.GUI.Tests/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..58f42cf
--- /dev/null
+++ b/tests/Tableau.Migration.App.GUI.Tests/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,115 @@
+//
+// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
+// 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
+// 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.App.GUI.Tests.ViewModels;
+
+using Avalonia.Headless.XUnit;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Moq;
+using Tableau.Migration.App.Core.Hooks.Mappings;
+using Tableau.Migration.App.Core.Interfaces;
+using Tableau.Migration.App.GUI.Models;
+using Tableau.Migration.App.GUI.Services.Interfaces;
+using Tableau.Migration.App.GUI.ViewModels;
+
+public class MainWindowViewModelTests
+{
+ private Mock> emailDomainOptionsMock;
+ private EmailDomainMappingOptions optionsValue;
+ private Mock> dictionaryUserMappingOptionsMock;
+ private Mock migrationServiceMock;
+ private Mock progressUpdaterMock;
+ private Mock progressPublisherMock;
+ private Mock migrationTimerMock;
+ private Mock filePickerMock;
+ private Mock csvParserMock;
+ private Mock timersVMMock;
+ private Mock userMappingsVMMock;
+ private Mock userDomainMappingMock;
+ private Mock fileMappingMock;
+ private MainWindowViewModel viewModel;
+
+ public MainWindowViewModelTests()
+ {
+ this.emailDomainOptionsMock = new Mock>();
+ this.optionsValue = new EmailDomainMappingOptions();
+ this.emailDomainOptionsMock.Setup(o => o.Value).Returns(this.optionsValue);
+
+ this.dictionaryUserMappingOptionsMock = new Mock>();
+ this.migrationServiceMock = new Mock();
+ this.progressUpdaterMock = new Mock();
+ this.progressPublisherMock = new Mock();
+ this.migrationTimerMock = new Mock();
+ this.filePickerMock = new Mock();
+ this.csvParserMock = new Mock();
+ var migrationTimerMock = new Mock();
+ var timerLoggerMock = new Mock>();
+ this.timersVMMock = new Mock(
+ migrationTimerMock.Object,
+ this.progressUpdaterMock.Object,
+ timerLoggerMock.Object);
+ this.userDomainMappingMock = new Mock(this.emailDomainOptionsMock.Object);
+ this.fileMappingMock = new Mock(
+ this.dictionaryUserMappingOptionsMock.Object,
+ this.filePickerMock.Object,
+ this.csvParserMock.Object);
+ this.userMappingsVMMock = new Mock(
+ this.userDomainMappingMock.Object,
+ this.fileMappingMock.Object);
+ this.viewModel = new MainWindowViewModel(
+ this.migrationServiceMock.Object,
+ this.progressUpdaterMock.Object,
+ this.progressPublisherMock.Object,
+ this.migrationTimerMock.Object,
+ this.timersVMMock.Object,
+ this.userMappingsVMMock.Object);
+ }
+
+ [AvaloniaFact]
+ public void Constructor_initial_state()
+ {
+ Assert.NotNull(this.viewModel.ServerCredentialsVM);
+ Assert.NotNull(this.viewModel.CloudCredentialsVM);
+ Assert.False(this.viewModel.IsMigrating);
+ Assert.False(this.viewModel.IsNotificationVisible);
+ Assert.Empty(this.viewModel.NotificationMessage);
+ }
+
+ [AvaloniaFact]
+ public async Task SaveManifestIfRequiredAsync_Skip_If_nullAsync()
+ {
+ await this.viewModel.SaveManifestIfRequiredAsync(null);
+ Assert.Equal(" Manifest was not saved.", this.viewModel.NotificationMessage);
+ }
+
+ [AvaloniaFact]
+ public async void SaveManifestIfRequiredAsync_with_path_succeed()
+ {
+ this.migrationServiceMock.Setup(ms => ms.SaveManifestAsync(It.IsAny())).Returns(Task.FromResult(true));
+ await this.viewModel.SaveManifestIfRequiredAsync("somepath");
+ Assert.Equal(" Manifest saved.", this.viewModel.NotificationMessage);
+ }
+
+ [AvaloniaFact]
+ public async void SaveManifestIfRequiredAsync_with_path_failed()
+ {
+ this.migrationServiceMock.Setup(ms => ms.SaveManifestAsync(It.IsAny())).Returns(Task.FromResult(false));
+ await this.viewModel.SaveManifestIfRequiredAsync("somepath");
+ Assert.Equal(" Failed to save manifest.", this.viewModel.NotificationMessage);
+ }
+}
\ No newline at end of file
diff --git a/tests/Tableau.Migration.App.GUI.Tests/ViewModels/MainWindowViewModelTests.cs b/tests/Tableau.Migration.App.GUI.Tests/ViewModels/MainWindowViewModelTests.cs
deleted file mode 100644
index ecfee97..0000000
--- a/tests/Tableau.Migration.App.GUI.Tests/ViewModels/MainWindowViewModelTests.cs
+++ /dev/null
@@ -1,216 +0,0 @@
-//
-// Copyright (c) 2024, Salesforce, Inc. All rights reserved.
-// 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
-// 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.App.GUI.Tests.ViewModels;
-
-using Microsoft.Extensions.Options;
-using Moq;
-using Tableau.Migration.App.Core.Hooks.Mappings;
-using Tableau.Migration.App.Core.Interfaces;
-using Tableau.Migration.App.GUI.Models;
-using Tableau.Migration.App.GUI.Services.Interfaces;
-using Tableau.Migration.App.GUI.ViewModels;
-
-public class MainWindowViewModelTests
-{
- [Fact]
- public void CloudUserDomain_ShouldUpdateEmailDomainOptions()
- {
- var emailDomainOptionsMock = new Mock>();
- var optionsValue = new EmailDomainMappingOptions();
- emailDomainOptionsMock.Setup(o => o.Value).Returns(optionsValue);
-
- var dictionaryUserMappingOptionsMock = new Mock>();
- var migrationServiceMock = new Mock();
- var progressUpdaterMock = new Mock();
- var progressPublisherMock = new Mock();
- var filePickerMock = new Mock();
- var csvParserMock = new Mock();
- var viewModel = new MainWindowViewModel(
- migrationServiceMock.Object,
- emailDomainOptionsMock.Object,
- dictionaryUserMappingOptionsMock.Object,
- progressUpdaterMock.Object,
- progressPublisherMock.Object,
- filePickerMock.Object,
- csvParserMock.Object);
-
- viewModel.UserMappingsVM.UserDomainMappingVM.CloudUserDomain = "testdomain.com";
-
- Assert.Equal("testdomain.com", optionsValue.EmailDomain);
- }
-
- [Fact]
- public void CloudUserDomain_ShouldTriggerValidationErrors_WhenEmpty()
- {
- var emailDomainOptionsMock = new Mock>();
- var optionsValue = new EmailDomainMappingOptions();
- emailDomainOptionsMock.Setup(o => o.Value).Returns(optionsValue);
-
- var dictionaryUserMappingOptionsMock = new Mock