--- phase: 04 plan: 01 title: Dependencies + Core Models + Interfaces + BulkOperationRunner + Test Scaffolds status: pending wave: 0 depends_on: [] files_modified: - SharepointToolbox/SharepointToolbox.csproj - SharepointToolbox.Tests/SharepointToolbox.Tests.csproj - SharepointToolbox/Core/Models/BulkOperationResult.cs - SharepointToolbox/Core/Models/BulkMemberRow.cs - SharepointToolbox/Core/Models/BulkSiteRow.cs - SharepointToolbox/Core/Models/TransferJob.cs - SharepointToolbox/Core/Models/FolderStructureRow.cs - SharepointToolbox/Core/Models/SiteTemplate.cs - SharepointToolbox/Core/Models/SiteTemplateOptions.cs - SharepointToolbox/Core/Models/TemplateLibraryInfo.cs - SharepointToolbox/Core/Models/TemplateFolderInfo.cs - SharepointToolbox/Core/Models/TemplatePermissionGroup.cs - SharepointToolbox/Core/Models/ConflictPolicy.cs - SharepointToolbox/Core/Models/TransferMode.cs - SharepointToolbox/Core/Models/CsvValidationRow.cs - SharepointToolbox/Services/BulkOperationRunner.cs - SharepointToolbox/Services/IFileTransferService.cs - SharepointToolbox/Services/IBulkMemberService.cs - SharepointToolbox/Services/IBulkSiteService.cs - SharepointToolbox/Services/ITemplateService.cs - SharepointToolbox/Services/IFolderStructureService.cs - SharepointToolbox/Services/ICsvValidationService.cs - SharepointToolbox/Services/Export/BulkResultCsvExportService.cs - SharepointToolbox.Tests/Services/BulkOperationRunnerTests.cs - SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs - SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs - SharepointToolbox.Tests/Services/BulkResultCsvExportServiceTests.cs autonomous: true requirements: - BULK-04 - BULK-05 must_haves: truths: - "CsvHelper 33.1.0 and Microsoft.Graph 5.74.0 are installed and dotnet build succeeds" - "BulkOperationRunner.RunAsync continues on error and collects per-item results" - "BulkOperationRunner.RunAsync propagates OperationCanceledException on cancellation" - "All service interfaces compile and define the expected method signatures" - "All model classes compile with correct properties" - "Test scaffolds compile and failing tests are marked with Skip" artifacts: - path: "SharepointToolbox/Services/BulkOperationRunner.cs" provides: "Shared bulk operation helper with continue-on-error" exports: ["BulkOperationRunner", "BulkOperationRunner.RunAsync"] - path: "SharepointToolbox/Core/Models/BulkOperationResult.cs" provides: "Per-item result tracking models" exports: ["BulkItemResult", "BulkOperationSummary"] - path: "SharepointToolbox/Core/Models/SiteTemplate.cs" provides: "Template JSON model" exports: ["SiteTemplate"] key_links: - from: "BulkOperationRunner.cs" to: "BulkOperationResult.cs" via: "returns BulkOperationSummary" pattern: "BulkOperationSummary" - from: "BulkOperationRunnerTests.cs" to: "BulkOperationRunner.cs" via: "unit tests for continue-on-error and cancellation" pattern: "BulkOperationRunner.RunAsync" --- # Plan 04-01: Dependencies + Core Models + Interfaces + BulkOperationRunner + Test Scaffolds ## Goal Install new NuGet packages (CsvHelper 33.1.0, Microsoft.Graph 5.74.0), create all core models for Phase 4, define all service interfaces, implement the shared BulkOperationRunner, create a BulkResultCsvExportService stub, and scaffold test files for Wave 0 coverage. ## Context This is the foundation plan for Phase 4. Every subsequent plan depends on the models, interfaces, and BulkOperationRunner created here. The project uses .NET 10, PnP.Framework 1.18.0, CommunityToolkit.Mvvm 8.4.2. Solution file is `SharepointToolbox.slnx`. Existing patterns: - Models are plain classes in `Core/Models/` with public get/set properties (not records — System.Text.Json requirement) - Service interfaces in `Services/` with `I` prefix - OperationProgress(int Current, int Total, string Message) already exists - SettingsRepository pattern (atomic JSON write with .tmp + File.Move) for persistence ## Tasks ### Task 1: Install NuGet packages and create all core models + enums **Files:** - `SharepointToolbox/SharepointToolbox.csproj` - `SharepointToolbox.Tests/SharepointToolbox.Tests.csproj` - `SharepointToolbox/Core/Models/BulkOperationResult.cs` - `SharepointToolbox/Core/Models/BulkMemberRow.cs` - `SharepointToolbox/Core/Models/BulkSiteRow.cs` - `SharepointToolbox/Core/Models/TransferJob.cs` - `SharepointToolbox/Core/Models/FolderStructureRow.cs` - `SharepointToolbox/Core/Models/SiteTemplate.cs` - `SharepointToolbox/Core/Models/SiteTemplateOptions.cs` - `SharepointToolbox/Core/Models/TemplateLibraryInfo.cs` - `SharepointToolbox/Core/Models/TemplateFolderInfo.cs` - `SharepointToolbox/Core/Models/TemplatePermissionGroup.cs` - `SharepointToolbox/Core/Models/ConflictPolicy.cs` - `SharepointToolbox/Core/Models/TransferMode.cs` - `SharepointToolbox/Core/Models/CsvValidationRow.cs` **Action:** 1. Install NuGet packages: ```bash dotnet add SharepointToolbox/SharepointToolbox.csproj package CsvHelper --version 33.1.0 dotnet add SharepointToolbox/SharepointToolbox.csproj package Microsoft.Graph --version 5.74.0 ``` Also add CsvHelper to the test project (needed for generating test CSVs): ```bash dotnet add SharepointToolbox.Tests/SharepointToolbox.Tests.csproj package CsvHelper --version 33.1.0 ``` 2. Create `ConflictPolicy.cs`: ```csharp namespace SharepointToolbox.Core.Models; public enum ConflictPolicy { Skip, Overwrite, Rename } ``` 3. Create `TransferMode.cs`: ```csharp namespace SharepointToolbox.Core.Models; public enum TransferMode { Copy, Move } ``` 4. Create `BulkOperationResult.cs` with three types: ```csharp namespace SharepointToolbox.Core.Models; public class BulkItemResult { 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 Success(T item) => new(item, true, null); public static BulkItemResult Failed(T item, string error) => new(item, false, error); } public class BulkOperationSummary { public IReadOnlyList> 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> FailedItems => Results.Where(r => !r.IsSuccess).ToList(); public BulkOperationSummary(IReadOnlyList> results) { Results = results; } } ``` 5. Create `BulkMemberRow.cs` — CSV row for bulk member addition: ```csharp 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" } ``` 6. Create `BulkSiteRow.cs` — CSV row for bulk site creation (matches existing example CSV with semicolon delimiter): ```csharp 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 } ``` 7. Create `TransferJob.cs`: ```csharp 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; } ``` 8. Create `FolderStructureRow.cs`: ```csharp 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; /// /// Builds the folder path from non-empty level values (e.g. "Admin/HR/Contracts"). /// public string BuildPath() { var parts = new[] { Level1, Level2, Level3, Level4 } .Where(s => !string.IsNullOrWhiteSpace(s)); return string.Join("/", parts); } } ``` 9. Create `SiteTemplateOptions.cs`: ```csharp 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; } ``` 10. Create `TemplateLibraryInfo.cs`: ```csharp 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 Folders { get; set; } = new(); } ``` 11. Create `TemplateFolderInfo.cs`: ```csharp namespace SharepointToolbox.Core.Models; public class TemplateFolderInfo { public string Name { get; set; } = string.Empty; public string RelativePath { get; set; } = string.Empty; public List Children { get; set; } = new(); } ``` 12. Create `TemplatePermissionGroup.cs`: ```csharp namespace SharepointToolbox.Core.Models; public class TemplatePermissionGroup { public string Name { get; set; } = string.Empty; public string Description { get; set; } = string.Empty; public List RoleDefinitions { get; set; } = new(); // e.g. "Full Control", "Contribute" } ``` 13. Create `SiteTemplate.cs`: ```csharp 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 Libraries { get; set; } = new(); public List 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; } ``` 14. Create `CsvValidationRow.cs`: ```csharp namespace SharepointToolbox.Core.Models; public class CsvValidationRow { public T? Record { get; } public bool IsValid => Errors.Count == 0; public List Errors { get; } public string? RawRecord { get; } public CsvValidationRow(T record, List errors) { Record = record; Errors = errors; } private CsvValidationRow(string rawRecord, string parseError) { Record = default; RawRecord = rawRecord; Errors = new List { parseError }; } public static CsvValidationRow ParseError(string? rawRecord, string error) => new(rawRecord ?? string.Empty, error); } ``` **Verify:** ```bash dotnet build SharepointToolbox/SharepointToolbox.csproj --no-restore -q ``` **Done:** All 14 model files compile. CsvHelper and Microsoft.Graph packages installed. ### Task 2: Create all service interfaces + BulkOperationRunner + export stub + test scaffolds **Files:** - `SharepointToolbox/Services/BulkOperationRunner.cs` - `SharepointToolbox/Services/IFileTransferService.cs` - `SharepointToolbox/Services/IBulkMemberService.cs` - `SharepointToolbox/Services/IBulkSiteService.cs` - `SharepointToolbox/Services/ITemplateService.cs` - `SharepointToolbox/Services/IFolderStructureService.cs` - `SharepointToolbox/Services/ICsvValidationService.cs` - `SharepointToolbox/Services/Export/BulkResultCsvExportService.cs` - `SharepointToolbox.Tests/Services/BulkOperationRunnerTests.cs` - `SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs` - `SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs` - `SharepointToolbox.Tests/Services/BulkResultCsvExportServiceTests.cs` **Action:** 1. Create `BulkOperationRunner.cs` — the shared bulk operation helper: ```csharp using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public static class BulkOperationRunner { /// /// Runs a bulk operation with continue-on-error semantics, per-item result tracking, /// and cancellation support. OperationCanceledException propagates immediately. /// public static async Task> RunAsync( IReadOnlyList items, Func processItem, IProgress progress, CancellationToken ct) { var results = new List>(); 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.Success(items[i])); } catch (OperationCanceledException) { throw; } catch (Exception ex) { results.Add(BulkItemResult.Failed(items[i], ex.Message)); } } progress.Report(new OperationProgress(items.Count, items.Count, "Complete.")); return new BulkOperationSummary(results); } } ``` 2. Create `IFileTransferService.cs`: ```csharp using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface IFileTransferService { /// /// Transfers files/folders from source to destination. /// Returns per-item results (file paths as string items). /// Task> TransferAsync( ClientContext sourceCtx, ClientContext destCtx, TransferJob job, IProgress progress, CancellationToken ct); } ``` 3. Create `IBulkMemberService.cs`: ```csharp using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface IBulkMemberService { Task> AddMembersAsync( ClientContext ctx, IReadOnlyList rows, IProgress progress, CancellationToken ct); } ``` 4. Create `IBulkSiteService.cs`: ```csharp using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface IBulkSiteService { Task> CreateSitesAsync( ClientContext adminCtx, IReadOnlyList rows, IProgress progress, CancellationToken ct); } ``` 5. Create `ITemplateService.cs`: ```csharp using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface ITemplateService { Task CaptureTemplateAsync( ClientContext ctx, SiteTemplateOptions options, IProgress progress, CancellationToken ct); Task ApplyTemplateAsync( ClientContext adminCtx, SiteTemplate template, string newSiteTitle, string newSiteAlias, IProgress progress, CancellationToken ct); } ``` 6. Create `IFolderStructureService.cs`: ```csharp using Microsoft.SharePoint.Client; using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface IFolderStructureService { Task> CreateFoldersAsync( ClientContext ctx, string libraryTitle, IReadOnlyList rows, IProgress progress, CancellationToken ct); } ``` 7. Create `ICsvValidationService.cs`: ```csharp using SharepointToolbox.Core.Models; namespace SharepointToolbox.Services; public interface ICsvValidationService { List> ParseAndValidate(Stream csvStream) where T : class; List> ParseAndValidateMembers(Stream csvStream); List> ParseAndValidateSites(Stream csvStream); List> ParseAndValidateFolders(Stream csvStream); } ``` 8. Create `Export/BulkResultCsvExportService.cs` (stub — implemented in full later, but must compile for test scaffolds): ```csharp 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(IReadOnlyList> failedItems) { using var writer = new StringWriter(); using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); csv.WriteHeader(); 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( IReadOnlyList> failedItems, string filePath, CancellationToken ct) { var content = BuildFailedItemsCsv(failedItems); await System.IO.File.WriteAllTextAsync(filePath, content, new UTF8Encoding(true), ct); } } ``` 9. Create `BulkOperationRunnerTests.cs`: ```csharp 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 { "a", "b", "c" }; var progress = new Progress(); 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 { "ok1", "fail", "ok2" }; var progress = new Progress(); 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 { "a", "b", "c" }; var cts = new CancellationTokenSource(); cts.Cancel(); var progress = new Progress(); await Assert.ThrowsAsync(() => BulkOperationRunner.RunAsync( items, (item, idx, ct) => Task.CompletedTask, progress, cts.Token)); } [Fact] public async Task RunAsync_CancelledMidOperation_StopsProcessing() { var items = new List { "a", "b", "c", "d" }; var cts = new CancellationTokenSource(); var processedCount = 0; var progress = new Progress(); await Assert.ThrowsAsync(() => 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 { "a", "b" }; var progressReports = new List(); var progress = new Progress(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); } } ``` 10. Create `CsvValidationServiceTests.cs` (scaffold — tests skip until service is implemented in Plan 04-02): ```csharp 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() { } } ``` 11. Create `TemplateRepositoryTests.cs` (scaffold): ```csharp 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() { } } ``` 12. Create `BulkResultCsvExportServiceTests.cs`: ```csharp 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.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.Success( new BulkMemberRow { Email = "ok@test.com", GroupName = "G1", GroupUrl = "http://test", Role = "Member" }), BulkItemResult.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); } } ``` **Verify:** ```bash dotnet build SharepointToolbox.slnx --no-restore -q && dotnet test SharepointToolbox.Tests --no-build --filter "FullyQualifiedName~BulkOperationRunner|FullyQualifiedName~BulkResultCsvExport" -q ``` **Done:** All models, enums, interfaces, BulkOperationRunner, and export stub compile. BulkOperationRunner tests pass (5 tests). BulkResultCsvExportService tests pass (2 tests). Skipped test scaffolds compile. `dotnet build` succeeds for entire solution. **Commit:** `feat(04-01): add Phase 4 models, interfaces, BulkOperationRunner, and test scaffolds`