feat(04-01): add Phase 4 models, interfaces, BulkOperationRunner, and test scaffolds

- Install CsvHelper 33.1.0 and Microsoft.Graph 5.74.0 (main + test projects)
- Add 14 core model/enum files (BulkOperationResult, BulkMemberRow, BulkSiteRow, TransferJob, FolderStructureRow, SiteTemplate, SiteTemplateOptions, TemplateLibraryInfo, TemplateFolderInfo, TemplatePermissionGroup, ConflictPolicy, TransferMode, CsvValidationRow)
- Add 6 service interfaces (IFileTransferService, IBulkMemberService, IBulkSiteService, ITemplateService, IFolderStructureService, ICsvValidationService)
- Add BulkOperationRunner with continue-on-error and cancellation support
- Add BulkResultCsvExportService stub (compile-ready)
- Add test scaffolds: BulkOperationRunnerTests (5 passing), BulkResultCsvExportServiceTests (2 passing), CsvValidationServiceTests (6 skipped), TemplateRepositoryTests (4 skipped)
This commit is contained in:
Dev
2026-04-03 09:53:05 +02:00
parent d73e50948d
commit 39deed9d8d
27 changed files with 604 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services;
namespace SharepointToolbox.Tests.Services;
public class BulkOperationRunnerTests
{
[Fact]
public async Task RunAsync_AllSucceed_ReturnsAllSuccess()
{
var items = new List<string> { "a", "b", "c" };
var progress = new Progress<OperationProgress>();
var summary = await BulkOperationRunner.RunAsync(
items,
(item, idx, ct) => Task.CompletedTask,
progress,
CancellationToken.None);
Assert.Equal(3, summary.TotalCount);
Assert.Equal(3, summary.SuccessCount);
Assert.Equal(0, summary.FailedCount);
Assert.False(summary.HasFailures);
}
[Fact]
public async Task RunAsync_SomeItemsFail_ContinuesAndReportsPerItem()
{
var items = new List<string> { "ok1", "fail", "ok2" };
var progress = new Progress<OperationProgress>();
var summary = await BulkOperationRunner.RunAsync(
items,
(item, idx, ct) =>
{
if (item == "fail") throw new InvalidOperationException("Test error");
return Task.CompletedTask;
},
progress,
CancellationToken.None);
Assert.Equal(3, summary.TotalCount);
Assert.Equal(2, summary.SuccessCount);
Assert.Equal(1, summary.FailedCount);
Assert.True(summary.HasFailures);
Assert.Contains(summary.FailedItems, r => r.ErrorMessage == "Test error");
}
[Fact]
public async Task RunAsync_Cancelled_ThrowsOperationCanceled()
{
var items = new List<string> { "a", "b", "c" };
var cts = new CancellationTokenSource();
cts.Cancel();
var progress = new Progress<OperationProgress>();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
BulkOperationRunner.RunAsync(
items,
(item, idx, ct) => Task.CompletedTask,
progress,
cts.Token));
}
[Fact]
public async Task RunAsync_CancelledMidOperation_StopsProcessing()
{
var items = new List<string> { "a", "b", "c", "d" };
var cts = new CancellationTokenSource();
var processedCount = 0;
var progress = new Progress<OperationProgress>();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
BulkOperationRunner.RunAsync(
items,
async (item, idx, ct) =>
{
Interlocked.Increment(ref processedCount);
if (idx == 1) cts.Cancel(); // cancel after second item
await Task.CompletedTask;
},
progress,
cts.Token));
Assert.True(processedCount <= 3); // should not process all 4
}
[Fact]
public async Task RunAsync_ReportsProgress()
{
var items = new List<string> { "a", "b" };
var progressReports = new List<OperationProgress>();
var progress = new Progress<OperationProgress>(p => progressReports.Add(p));
await BulkOperationRunner.RunAsync(
items,
(item, idx, ct) => Task.CompletedTask,
progress,
CancellationToken.None);
// Progress is async, give it a moment to flush
await Task.Delay(100);
Assert.True(progressReports.Count >= 2);
}
}

View File

@@ -0,0 +1,45 @@
using SharepointToolbox.Core.Models;
using SharepointToolbox.Services.Export;
namespace SharepointToolbox.Tests.Services;
public class BulkResultCsvExportServiceTests
{
[Fact]
public void BuildFailedItemsCsv_WithFailedItems_IncludesErrorColumn()
{
var service = new BulkResultCsvExportService();
var items = new List<BulkItemResult<BulkMemberRow>>
{
BulkItemResult<BulkMemberRow>.Failed(
new BulkMemberRow { Email = "bad@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" },
"User not found"),
};
var csv = service.BuildFailedItemsCsv(items);
Assert.Contains("Error", csv);
Assert.Contains("Timestamp", csv);
Assert.Contains("bad@test.com", csv);
Assert.Contains("User not found", csv);
}
[Fact]
public void BuildFailedItemsCsv_SuccessItems_Excluded()
{
var service = new BulkResultCsvExportService();
var items = new List<BulkItemResult<BulkMemberRow>>
{
BulkItemResult<BulkMemberRow>.Success(
new BulkMemberRow { Email = "ok@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" }),
BulkItemResult<BulkMemberRow>.Failed(
new BulkMemberRow { Email = "bad@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" },
"Error"),
};
var csv = service.BuildFailedItemsCsv(items);
Assert.DoesNotContain("ok@test.com", csv);
Assert.Contains("bad@test.com", csv);
}
}

View File

@@ -0,0 +1,36 @@
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Tests.Services;
public class CsvValidationServiceTests
{
[Fact(Skip = "Implemented in Plan 04-02")]
public void ParseAndValidateMembers_ValidCsv_ReturnsValidRows()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void ParseAndValidateMembers_InvalidEmail_ReturnsErrors()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void ParseAndValidateSites_TeamWithoutOwner_ReturnsError()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void ParseAndValidateFolders_ValidCsv_ReturnsValidRows()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void ParseAndValidate_BomDetection_WorksWithAndWithoutBom()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void ParseAndValidate_SemicolonDelimiter_DetectedAutomatically()
{
}
}

View File

@@ -0,0 +1,26 @@
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Tests.Services;
public class TemplateRepositoryTests
{
[Fact(Skip = "Implemented in Plan 04-02")]
public void SaveAndLoad_RoundTrips_Correctly()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void GetAll_ReturnsAllSavedTemplates()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void Delete_RemovesTemplate()
{
}
[Fact(Skip = "Implemented in Plan 04-02")]
public void Rename_UpdatesTemplateName()
{
}
}

View File

@@ -10,6 +10,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" /> <PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Moq" Version="4.20.72" /> <PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />

View File

@@ -0,0 +1,18 @@
using CsvHelper.Configuration.Attributes;
namespace SharepointToolbox.Core.Models;
public class BulkMemberRow
{
[Name("GroupName")]
public string GroupName { get; set; } = string.Empty;
[Name("GroupUrl")]
public string GroupUrl { get; set; } = string.Empty;
[Name("Email")]
public string Email { get; set; } = string.Empty;
[Name("Role")]
public string Role { get; set; } = string.Empty; // "Member" or "Owner"
}

View File

@@ -0,0 +1,35 @@
namespace SharepointToolbox.Core.Models;
public class BulkItemResult<T>
{
public T Item { get; }
public bool IsSuccess { get; }
public string? ErrorMessage { get; }
public DateTime Timestamp { get; }
private BulkItemResult(T item, bool success, string? error)
{
Item = item;
IsSuccess = success;
ErrorMessage = error;
Timestamp = DateTime.UtcNow;
}
public static BulkItemResult<T> Success(T item) => new(item, true, null);
public static BulkItemResult<T> Failed(T item, string error) => new(item, false, error);
}
public class BulkOperationSummary<T>
{
public IReadOnlyList<BulkItemResult<T>> Results { get; }
public int TotalCount => Results.Count;
public int SuccessCount => Results.Count(r => r.IsSuccess);
public int FailedCount => Results.Count(r => !r.IsSuccess);
public bool HasFailures => FailedCount > 0;
public IReadOnlyList<BulkItemResult<T>> FailedItems => Results.Where(r => !r.IsSuccess).ToList();
public BulkOperationSummary(IReadOnlyList<BulkItemResult<T>> results)
{
Results = results;
}
}

View File

@@ -0,0 +1,24 @@
using CsvHelper.Configuration.Attributes;
namespace SharepointToolbox.Core.Models;
public class BulkSiteRow
{
[Name("Name")]
public string Name { get; set; } = string.Empty;
[Name("Alias")]
public string Alias { get; set; } = string.Empty;
[Name("Type")]
public string Type { get; set; } = string.Empty; // "Team" or "Communication"
[Name("Template")]
public string Template { get; set; } = string.Empty;
[Name("Owners")]
public string Owners { get; set; } = string.Empty; // comma-separated emails
[Name("Members")]
public string Members { get; set; } = string.Empty; // comma-separated emails
}

View File

@@ -0,0 +1,8 @@
namespace SharepointToolbox.Core.Models;
public enum ConflictPolicy
{
Skip,
Overwrite,
Rename
}

View File

@@ -0,0 +1,25 @@
namespace SharepointToolbox.Core.Models;
public class CsvValidationRow<T>
{
public T? Record { get; }
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; }
public string? RawRecord { get; }
public CsvValidationRow(T record, List<string> errors)
{
Record = record;
Errors = errors;
}
private CsvValidationRow(string rawRecord, string parseError)
{
Record = default;
RawRecord = rawRecord;
Errors = new List<string> { parseError };
}
public static CsvValidationRow<T> ParseError(string? rawRecord, string error)
=> new(rawRecord ?? string.Empty, error);
}

View File

@@ -0,0 +1,28 @@
using CsvHelper.Configuration.Attributes;
namespace SharepointToolbox.Core.Models;
public class FolderStructureRow
{
[Name("Level1")]
public string Level1 { get; set; } = string.Empty;
[Name("Level2")]
public string Level2 { get; set; } = string.Empty;
[Name("Level3")]
public string Level3 { get; set; } = string.Empty;
[Name("Level4")]
public string Level4 { get; set; } = string.Empty;
/// <summary>
/// Builds the folder path from non-empty level values (e.g. "Admin/HR/Contracts").
/// </summary>
public string BuildPath()
{
var parts = new[] { Level1, Level2, Level3, Level4 }
.Where(s => !string.IsNullOrWhiteSpace(s));
return string.Join("/", parts);
}
}

View File

@@ -0,0 +1,27 @@
namespace SharepointToolbox.Core.Models;
public class SiteTemplate
{
public string Id { get; set; } = Guid.NewGuid().ToString();
public string Name { get; set; } = string.Empty;
public string SourceUrl { get; set; } = string.Empty;
public DateTime CapturedAt { get; set; }
public string SiteType { get; set; } = string.Empty; // "Team" or "Communication"
public SiteTemplateOptions Options { get; set; } = new();
public TemplateSettings? Settings { get; set; }
public TemplateLogo? Logo { get; set; }
public List<TemplateLibraryInfo> Libraries { get; set; } = new();
public List<TemplatePermissionGroup> PermissionGroups { get; set; } = new();
}
public class TemplateSettings
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Language { get; set; }
}
public class TemplateLogo
{
public string LogoUrl { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace SharepointToolbox.Core.Models;
public class SiteTemplateOptions
{
public bool CaptureLibraries { get; set; } = true;
public bool CaptureFolders { get; set; } = true;
public bool CapturePermissionGroups { get; set; } = true;
public bool CaptureLogo { get; set; } = true;
public bool CaptureSettings { get; set; } = true;
}

View File

@@ -0,0 +1,8 @@
namespace SharepointToolbox.Core.Models;
public class TemplateFolderInfo
{
public string Name { get; set; } = string.Empty;
public string RelativePath { get; set; } = string.Empty;
public List<TemplateFolderInfo> Children { get; set; } = new();
}

View File

@@ -0,0 +1,9 @@
namespace SharepointToolbox.Core.Models;
public class TemplateLibraryInfo
{
public string Name { get; set; } = string.Empty;
public string BaseType { get; set; } = string.Empty; // "DocumentLibrary", "GenericList"
public int BaseTemplate { get; set; }
public List<TemplateFolderInfo> Folders { get; set; } = new();
}

View File

@@ -0,0 +1,8 @@
namespace SharepointToolbox.Core.Models;
public class TemplatePermissionGroup
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<string> RoleDefinitions { get; set; } = new(); // e.g. "Full Control", "Contribute"
}

View File

@@ -0,0 +1,13 @@
namespace SharepointToolbox.Core.Models;
public class TransferJob
{
public string SourceSiteUrl { get; set; } = string.Empty;
public string SourceLibrary { get; set; } = string.Empty;
public string SourceFolderPath { get; set; } = string.Empty; // relative within library
public string DestinationSiteUrl { get; set; } = string.Empty;
public string DestinationLibrary { get; set; } = string.Empty;
public string DestinationFolderPath { get; set; } = string.Empty;
public TransferMode Mode { get; set; } = TransferMode.Copy;
public ConflictPolicy ConflictPolicy { get; set; } = ConflictPolicy.Skip;
}

View File

@@ -0,0 +1,7 @@
namespace SharepointToolbox.Core.Models;
public enum TransferMode
{
Copy,
Move
}

View File

@@ -0,0 +1,36 @@
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public static class BulkOperationRunner
{
/// <summary>
/// Runs a bulk operation with continue-on-error semantics, per-item result tracking,
/// and cancellation support. OperationCanceledException propagates immediately.
/// </summary>
public static async Task<BulkOperationSummary<TItem>> RunAsync<TItem>(
IReadOnlyList<TItem> items,
Func<TItem, int, CancellationToken, Task> processItem,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
var results = new List<BulkItemResult<TItem>>();
for (int i = 0; i < items.Count; i++)
{
ct.ThrowIfCancellationRequested();
progress.Report(new OperationProgress(i + 1, items.Count, $"Processing {i + 1}/{items.Count}..."));
try
{
await processItem(items[i], i, ct);
results.Add(BulkItemResult<TItem>.Success(items[i]));
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
results.Add(BulkItemResult<TItem>.Failed(items[i], ex.Message));
}
}
progress.Report(new OperationProgress(items.Count, items.Count, "Complete."));
return new BulkOperationSummary<TItem>(results);
}
}

View File

@@ -0,0 +1,40 @@
using System.Globalization;
using System.IO;
using System.Text;
using CsvHelper;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services.Export;
public class BulkResultCsvExportService
{
public string BuildFailedItemsCsv<T>(IReadOnlyList<BulkItemResult<T>> failedItems)
{
using var writer = new StringWriter();
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
csv.WriteHeader<T>();
csv.WriteField("Error");
csv.WriteField("Timestamp");
csv.NextRecord();
foreach (var item in failedItems.Where(r => !r.IsSuccess))
{
csv.WriteRecord(item.Item);
csv.WriteField(item.ErrorMessage);
csv.WriteField(item.Timestamp.ToString("o"));
csv.NextRecord();
}
return writer.ToString();
}
public async Task WriteFailedItemsCsvAsync<T>(
IReadOnlyList<BulkItemResult<T>> failedItems,
string filePath,
CancellationToken ct)
{
var content = BuildFailedItemsCsv(failedItems);
await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct);
}
}

View File

@@ -0,0 +1,14 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IBulkMemberService
{
Task<BulkOperationSummary<BulkMemberRow>> AddMembersAsync(
ClientContext ctx,
string clientId,
IReadOnlyList<BulkMemberRow> rows,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -0,0 +1,13 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IBulkSiteService
{
Task<BulkOperationSummary<BulkSiteRow>> CreateSitesAsync(
ClientContext adminCtx,
IReadOnlyList<BulkSiteRow> rows,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -0,0 +1,12 @@
using System.IO;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface ICsvValidationService
{
List<CsvValidationRow<T>> ParseAndValidate<T>(Stream csvStream) where T : class;
List<CsvValidationRow<BulkMemberRow>> ParseAndValidateMembers(Stream csvStream);
List<CsvValidationRow<BulkSiteRow>> ParseAndValidateSites(Stream csvStream);
List<CsvValidationRow<FolderStructureRow>> ParseAndValidateFolders(Stream csvStream);
}

View File

@@ -0,0 +1,18 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IFileTransferService
{
/// <summary>
/// Transfers files/folders from source to destination.
/// Returns per-item results (file paths as string items).
/// </summary>
Task<BulkOperationSummary<string>> TransferAsync(
ClientContext sourceCtx,
ClientContext destCtx,
TransferJob job,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -0,0 +1,14 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
namespace SharepointToolbox.Services;
public interface IFolderStructureService
{
Task<BulkOperationSummary<string>> CreateFoldersAsync(
ClientContext ctx,
string libraryTitle,
IReadOnlyList<FolderStructureRow> rows,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -0,0 +1,22 @@
using Microsoft.SharePoint.Client;
using SharepointToolbox.Core.Models;
using ModelSiteTemplate = SharepointToolbox.Core.Models.SiteTemplate;
namespace SharepointToolbox.Services;
public interface ITemplateService
{
Task<ModelSiteTemplate> CaptureTemplateAsync(
ClientContext ctx,
SiteTemplateOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct);
Task<string> ApplyTemplateAsync(
ClientContext adminCtx,
ModelSiteTemplate template,
string newSiteTitle,
string newSiteAlias,
IProgress<OperationProgress> progress,
CancellationToken ct);
}

View File

@@ -27,7 +27,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
<PackageReference Include="Microsoft.Graph" Version="5.74.0" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.83.3" /> <PackageReference Include="Microsoft.Identity.Client" Version="4.83.3" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" /> <PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.82.1" />
<PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" /> <PackageReference Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.3" />