feat(04-03): implement FileTransferService with MoveCopyUtil and conflict policies
- FileTransferService.cs: CSOM copy/move via MoveCopyUtil.CopyFileByPath/MoveFileByPath - Conflict policies: Skip (catch ServerException), Overwrite (overwrite=true), Rename (KeepBoth=true) - ResourcePath.FromDecodedUrl for special character support - Recursive folder enumeration with system folder filtering - EnsureFolderAsync creates intermediate destination folders - Best-effort metadata preservation (ResetAuthorAndCreatedOnCopy=false) - FileTransferServiceTests.cs: 4 passing tests, 3 skipped (integration)
This commit is contained in:
56
SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs
Normal file
56
SharepointToolbox.Tests/Services/BulkMemberServiceTests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
public class BulkMemberServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BulkMemberService_Implements_IBulkMemberService()
|
||||||
|
{
|
||||||
|
// GraphClientFactory requires MsalClientFactory which requires real MSAL setup
|
||||||
|
// Verify the type hierarchy at minimum
|
||||||
|
Assert.True(typeof(IBulkMemberService).IsAssignableFrom(typeof(BulkMemberService)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BulkMemberRow_DefaultValues()
|
||||||
|
{
|
||||||
|
var row = new BulkMemberRow();
|
||||||
|
Assert.Equal(string.Empty, row.Email);
|
||||||
|
Assert.Equal(string.Empty, row.GroupName);
|
||||||
|
Assert.Equal(string.Empty, row.GroupUrl);
|
||||||
|
Assert.Equal(string.Empty, row.Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BulkMemberRow_PropertiesSettable()
|
||||||
|
{
|
||||||
|
var row = new BulkMemberRow
|
||||||
|
{
|
||||||
|
Email = "user@test.com",
|
||||||
|
GroupName = "Marketing",
|
||||||
|
GroupUrl = "https://contoso.sharepoint.com/sites/Marketing",
|
||||||
|
Role = "Owner"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal("user@test.com", row.Email);
|
||||||
|
Assert.Equal("Marketing", row.GroupName);
|
||||||
|
Assert.Equal("Owner", row.Role);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint tenant and Graph permissions")]
|
||||||
|
public async Task AddMembersAsync_ValidRows_AddsToGroups()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint tenant")]
|
||||||
|
public async Task AddMembersAsync_InvalidEmail_ReportsPerItemError()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires Graph permissions - Group.ReadWrite.All")]
|
||||||
|
public async Task AddMembersAsync_M365Group_UsesGraphApi()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
57
SharepointToolbox.Tests/Services/FileTransferServiceTests.cs
Normal file
57
SharepointToolbox.Tests/Services/FileTransferServiceTests.cs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
public class FileTransferServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FileTransferService_Implements_IFileTransferService()
|
||||||
|
{
|
||||||
|
var service = new FileTransferService();
|
||||||
|
Assert.IsAssignableFrom<IFileTransferService>(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransferJob_DefaultValues_AreCorrect()
|
||||||
|
{
|
||||||
|
var job = new TransferJob();
|
||||||
|
Assert.Equal(TransferMode.Copy, job.Mode);
|
||||||
|
Assert.Equal(ConflictPolicy.Skip, job.ConflictPolicy);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConflictPolicy_HasAllValues()
|
||||||
|
{
|
||||||
|
Assert.Equal(3, Enum.GetValues<ConflictPolicy>().Length);
|
||||||
|
Assert.Contains(ConflictPolicy.Skip, Enum.GetValues<ConflictPolicy>());
|
||||||
|
Assert.Contains(ConflictPolicy.Overwrite, Enum.GetValues<ConflictPolicy>());
|
||||||
|
Assert.Contains(ConflictPolicy.Rename, Enum.GetValues<ConflictPolicy>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TransferMode_HasAllValues()
|
||||||
|
{
|
||||||
|
Assert.Equal(2, Enum.GetValues<TransferMode>().Length);
|
||||||
|
Assert.Contains(TransferMode.Copy, Enum.GetValues<TransferMode>());
|
||||||
|
Assert.Contains(TransferMode.Move, Enum.GetValues<TransferMode>());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint tenant")]
|
||||||
|
public async Task TransferAsync_CopyMode_CopiesFiles()
|
||||||
|
{
|
||||||
|
// Integration test — needs real ClientContext
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint tenant")]
|
||||||
|
public async Task TransferAsync_MoveMode_DeletesSourceAfterCopy()
|
||||||
|
{
|
||||||
|
// Integration test — needs real ClientContext
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint tenant")]
|
||||||
|
public async Task TransferAsync_SkipConflict_DoesNotOverwrite()
|
||||||
|
{
|
||||||
|
// Integration test — needs real ClientContext
|
||||||
|
}
|
||||||
|
}
|
||||||
71
SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs
Normal file
71
SharepointToolbox/Infrastructure/Auth/GraphClientFactory.cs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.Graph;
|
||||||
|
using Microsoft.Identity.Client;
|
||||||
|
using Microsoft.Kiota.Abstractions.Authentication;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Infrastructure.Auth;
|
||||||
|
|
||||||
|
public class GraphClientFactory
|
||||||
|
{
|
||||||
|
private readonly MsalClientFactory _msalFactory;
|
||||||
|
|
||||||
|
public GraphClientFactory(MsalClientFactory msalFactory)
|
||||||
|
{
|
||||||
|
_msalFactory = msalFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a GraphServiceClient that acquires tokens via the same MSAL PCA
|
||||||
|
/// used for SharePoint auth, but with Graph scopes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<GraphServiceClient> CreateClientAsync(string clientId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var pca = await _msalFactory.GetOrCreateAsync(clientId);
|
||||||
|
var accounts = await pca.GetAccountsAsync();
|
||||||
|
var account = accounts.FirstOrDefault();
|
||||||
|
|
||||||
|
var graphScopes = new[] { "https://graph.microsoft.com/.default" };
|
||||||
|
|
||||||
|
var tokenProvider = new MsalTokenProvider(pca, account, graphScopes);
|
||||||
|
var authProvider = new BaseBearerTokenAuthenticationProvider(tokenProvider);
|
||||||
|
return new GraphServiceClient(authProvider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bridges MSAL PCA token acquisition with Graph SDK's IAccessTokenProvider interface.
|
||||||
|
/// </summary>
|
||||||
|
internal class MsalTokenProvider : IAccessTokenProvider
|
||||||
|
{
|
||||||
|
private readonly IPublicClientApplication _pca;
|
||||||
|
private readonly IAccount? _account;
|
||||||
|
private readonly string[] _scopes;
|
||||||
|
|
||||||
|
public MsalTokenProvider(IPublicClientApplication pca, IAccount? account, string[] scopes)
|
||||||
|
{
|
||||||
|
_pca = pca;
|
||||||
|
_account = account;
|
||||||
|
_scopes = scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AllowedHostsValidator AllowedHostsValidator { get; } = new();
|
||||||
|
|
||||||
|
public async Task<string> GetAuthorizationTokenAsync(
|
||||||
|
Uri uri,
|
||||||
|
Dictionary<string, object>? additionalAuthenticationContext = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await _pca.AcquireTokenSilent(_scopes, _account)
|
||||||
|
.ExecuteAsync(cancellationToken);
|
||||||
|
return result.AccessToken;
|
||||||
|
}
|
||||||
|
catch (MsalUiRequiredException)
|
||||||
|
{
|
||||||
|
// If silent fails, try interactive
|
||||||
|
var result = await _pca.AcquireTokenInteractive(_scopes)
|
||||||
|
.ExecuteAsync(cancellationToken);
|
||||||
|
return result.AccessToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
179
SharepointToolbox/Services/FileTransferService.cs
Normal file
179
SharepointToolbox/Services/FileTransferService.cs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using Serilog;
|
||||||
|
using SharepointToolbox.Core.Helpers;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public class FileTransferService : IFileTransferService
|
||||||
|
{
|
||||||
|
public async Task<BulkOperationSummary<string>> TransferAsync(
|
||||||
|
ClientContext sourceCtx,
|
||||||
|
ClientContext destCtx,
|
||||||
|
TransferJob job,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// 1. Enumerate files from source
|
||||||
|
progress.Report(new OperationProgress(0, 0, "Enumerating source files..."));
|
||||||
|
var files = await EnumerateFilesAsync(sourceCtx, job, progress, ct);
|
||||||
|
|
||||||
|
if (files.Count == 0)
|
||||||
|
{
|
||||||
|
progress.Report(new OperationProgress(0, 0, "No files found to transfer."));
|
||||||
|
return new BulkOperationSummary<string>(new List<BulkItemResult<string>>());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Build source and destination base paths
|
||||||
|
var srcBasePath = BuildServerRelativePath(sourceCtx, job.SourceLibrary, job.SourceFolderPath);
|
||||||
|
var dstBasePath = BuildServerRelativePath(destCtx, job.DestinationLibrary, job.DestinationFolderPath);
|
||||||
|
|
||||||
|
// 3. Transfer each file using BulkOperationRunner
|
||||||
|
return await BulkOperationRunner.RunAsync(
|
||||||
|
files,
|
||||||
|
async (fileRelUrl, idx, token) =>
|
||||||
|
{
|
||||||
|
// Compute destination path by replacing source base with dest base
|
||||||
|
var relativePart = fileRelUrl;
|
||||||
|
if (fileRelUrl.StartsWith(srcBasePath, StringComparison.OrdinalIgnoreCase))
|
||||||
|
relativePart = fileRelUrl.Substring(srcBasePath.Length).TrimStart('/');
|
||||||
|
|
||||||
|
// Ensure destination folder exists
|
||||||
|
var destFolderRelative = dstBasePath;
|
||||||
|
var fileFolder = Path.GetDirectoryName(relativePart)?.Replace('\\', '/');
|
||||||
|
if (!string.IsNullOrEmpty(fileFolder))
|
||||||
|
{
|
||||||
|
destFolderRelative = $"{dstBasePath}/{fileFolder}";
|
||||||
|
await EnsureFolderAsync(destCtx, destFolderRelative, progress, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(relativePart);
|
||||||
|
var destFileUrl = $"{destFolderRelative}/{fileName}";
|
||||||
|
|
||||||
|
await TransferSingleFileAsync(sourceCtx, destCtx, fileRelUrl, destFileUrl, job, progress, token);
|
||||||
|
|
||||||
|
Log.Information("Transferred: {Source} -> {Dest}", fileRelUrl, destFileUrl);
|
||||||
|
},
|
||||||
|
progress,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TransferSingleFileAsync(
|
||||||
|
ClientContext sourceCtx,
|
||||||
|
ClientContext destCtx,
|
||||||
|
string srcFileUrl,
|
||||||
|
string dstFileUrl,
|
||||||
|
TransferJob job,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var srcPath = ResourcePath.FromDecodedUrl(srcFileUrl);
|
||||||
|
var dstPath = ResourcePath.FromDecodedUrl(dstFileUrl);
|
||||||
|
|
||||||
|
bool overwrite = job.ConflictPolicy == ConflictPolicy.Overwrite;
|
||||||
|
var options = new MoveCopyOptions
|
||||||
|
{
|
||||||
|
KeepBoth = job.ConflictPolicy == ConflictPolicy.Rename,
|
||||||
|
ResetAuthorAndCreatedOnCopy = false, // best-effort metadata preservation
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (job.Mode == TransferMode.Copy)
|
||||||
|
{
|
||||||
|
MoveCopyUtil.CopyFileByPath(sourceCtx, srcPath, dstPath, overwrite, options);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
|
||||||
|
}
|
||||||
|
else // Move
|
||||||
|
{
|
||||||
|
MoveCopyUtil.MoveFileByPath(sourceCtx, srcPath, dstPath, overwrite, options);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(sourceCtx, progress, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ServerException ex) when (job.ConflictPolicy == ConflictPolicy.Skip &&
|
||||||
|
ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
Log.Warning("Skipped (already exists): {File}", srcFileUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<IReadOnlyList<string>> EnumerateFilesAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
TransferJob job,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = ctx.Web.Lists.GetByTitle(job.SourceLibrary);
|
||||||
|
var rootFolder = list.RootFolder;
|
||||||
|
ctx.Load(rootFolder, f => f.ServerRelativeUrl);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
var baseFolderUrl = rootFolder.ServerRelativeUrl.TrimEnd('/');
|
||||||
|
if (!string.IsNullOrEmpty(job.SourceFolderPath))
|
||||||
|
baseFolderUrl = $"{baseFolderUrl}/{job.SourceFolderPath.TrimStart('/')}";
|
||||||
|
|
||||||
|
var folder = ctx.Web.GetFolderByServerRelativeUrl(baseFolderUrl);
|
||||||
|
var files = new List<string>();
|
||||||
|
await CollectFilesRecursiveAsync(ctx, folder, files, progress, ct);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CollectFilesRecursiveAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
Folder folder,
|
||||||
|
List<string> files,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
ctx.Load(folder, f => f.Files.Include(fi => fi.ServerRelativeUrl),
|
||||||
|
f => f.Folders);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
foreach (var file in folder.Files)
|
||||||
|
{
|
||||||
|
files.Add(file.ServerRelativeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var subFolder in folder.Folders)
|
||||||
|
{
|
||||||
|
// Skip system folders
|
||||||
|
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
|
||||||
|
continue;
|
||||||
|
await CollectFilesRecursiveAsync(ctx, subFolder, files, progress, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task EnsureFolderAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string folderServerRelativeUrl,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var folder = ctx.Web.GetFolderByServerRelativeUrl(folderServerRelativeUrl);
|
||||||
|
ctx.Load(folder, f => f.Exists);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
if (folder.Exists) return;
|
||||||
|
}
|
||||||
|
catch { /* folder doesn't exist, create it */ }
|
||||||
|
|
||||||
|
// Create folder using Folders.Add which creates intermediate folders
|
||||||
|
ctx.Web.Folders.Add(folderServerRelativeUrl);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildServerRelativePath(ClientContext ctx, string library, string folderPath)
|
||||||
|
{
|
||||||
|
// Extract site-relative URL from context URL
|
||||||
|
var uri = new Uri(ctx.Url);
|
||||||
|
var siteRelative = uri.AbsolutePath.TrimEnd('/');
|
||||||
|
var basePath = $"{siteRelative}/{library}";
|
||||||
|
if (!string.IsNullOrEmpty(folderPath))
|
||||||
|
basePath = $"{basePath}/{folderPath.TrimStart('/')}";
|
||||||
|
return basePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user