From 84cd569fb769c994f66e6cf0248a4d13c53b53e6 Mon Sep 17 00:00:00 2001 From: Dev Date: Fri, 3 Apr 2026 10:07:49 +0200 Subject: [PATCH] feat(04-06): implement TemplateService and FolderStructureService - FolderStructureService.CreateFoldersAsync creates folder hierarchy from CSV rows using BulkOperationRunner - FolderStructureService.BuildUniquePaths deduplicates and sorts paths parent-first by slash depth - TemplateService already committed; verified compilation and interface compliance - FolderStructureServiceTests: 4 unit tests pass (BuildUniquePaths edge cases, deduplication, empty levels, BuildPath) + 1 skip - TemplateServiceTests: 3 unit tests pass (interface impl, SiteTemplate defaults, SiteTemplateOptions defaults) + 2 skip --- .../Services/FolderStructureServiceTests.cs | 89 +++++++++++++++++++ .../Services/TemplateServiceTests.cs | 49 ++++++++++ .../Services/FolderStructureService.cs | 69 ++++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 SharepointToolbox.Tests/Services/FolderStructureServiceTests.cs create mode 100644 SharepointToolbox.Tests/Services/TemplateServiceTests.cs create mode 100644 SharepointToolbox/Services/FolderStructureService.cs diff --git a/SharepointToolbox.Tests/Services/FolderStructureServiceTests.cs b/SharepointToolbox.Tests/Services/FolderStructureServiceTests.cs new file mode 100644 index 0000000..6959333 --- /dev/null +++ b/SharepointToolbox.Tests/Services/FolderStructureServiceTests.cs @@ -0,0 +1,89 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +public class FolderStructureServiceTests +{ + [Fact] + public void FolderStructureService_Implements_IFolderStructureService() + { + Assert.True(typeof(IFolderStructureService).IsAssignableFrom(typeof(FolderStructureService))); + } + + [Fact] + public void BuildUniquePaths_FromExampleCsv_ReturnsParentFirst() + { + var rows = new List + { + new() { Level1 = "Administration", Level2 = "Comptabilite", Level3 = "Factures" }, + new() { Level1 = "Administration", Level2 = "Comptabilite", Level3 = "Bilans" }, + new() { Level1 = "Administration", Level2 = "Ressources Humaines" }, + new() { Level1 = "Projets", Level2 = "Projet Alpha", Level3 = "Documents" }, + }; + + var paths = FolderStructureService.BuildUniquePaths(rows); + + // Should contain unique paths, parent-first + Assert.Contains("Administration", paths); + Assert.Contains("Administration/Comptabilite", paths); + Assert.Contains("Administration/Comptabilite/Factures", paths); + Assert.Contains("Administration/Comptabilite/Bilans", paths); + Assert.Contains("Projets", paths); + Assert.Contains("Projets/Projet Alpha", paths); + + // Parent-first: "Administration" before "Administration/Comptabilite" + var adminIdx = paths.ToList().IndexOf("Administration"); + var compIdx = paths.ToList().IndexOf("Administration/Comptabilite"); + Assert.True(adminIdx < compIdx); + } + + [Fact] + public void BuildUniquePaths_DuplicateRows_Deduplicated() + { + var rows = new List + { + new() { Level1 = "A", Level2 = "B" }, + new() { Level1 = "A", Level2 = "B" }, + new() { Level1 = "A", Level2 = "C" }, + }; + + var paths = FolderStructureService.BuildUniquePaths(rows); + + Assert.Equal(3, paths.Count); // A, A/B, A/C (deduplicated) + } + + [Fact] + public void BuildUniquePaths_EmptyLevels_StopsAtLastNonEmpty() + { + var rows = new List + { + new() { Level1 = "Root", Level2 = "", Level3 = "", Level4 = "" }, + }; + + var paths = FolderStructureService.BuildUniquePaths(rows); + + Assert.Single(paths); + Assert.Equal("Root", paths[0]); + } + + [Fact] + public void FolderStructureRow_BuildPath_ReturnsCorrectPath() + { + var row = new FolderStructureRow + { + Level1 = "Admin", + Level2 = "HR", + Level3 = "Contracts", + Level4 = "" + }; + + Assert.Equal("Admin/HR/Contracts", row.BuildPath()); + } + + [Fact(Skip = "Requires live SharePoint tenant")] + public async Task CreateFoldersAsync_ValidRows_CreatesFolders() + { + await Task.CompletedTask; + } +} diff --git a/SharepointToolbox.Tests/Services/TemplateServiceTests.cs b/SharepointToolbox.Tests/Services/TemplateServiceTests.cs new file mode 100644 index 0000000..cde81e3 --- /dev/null +++ b/SharepointToolbox.Tests/Services/TemplateServiceTests.cs @@ -0,0 +1,49 @@ +using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; + +namespace SharepointToolbox.Tests.Services; + +public class TemplateServiceTests +{ + [Fact] + public void TemplateService_Implements_ITemplateService() + { + Assert.True(typeof(ITemplateService).IsAssignableFrom(typeof(TemplateService))); + } + + [Fact] + public void SiteTemplate_DefaultValues_AreCorrect() + { + var template = new SiteTemplate(); + Assert.NotNull(template.Id); + Assert.NotEmpty(template.Id); + Assert.NotNull(template.Libraries); + Assert.Empty(template.Libraries); + Assert.NotNull(template.PermissionGroups); + Assert.Empty(template.PermissionGroups); + Assert.NotNull(template.Options); + } + + [Fact] + public void SiteTemplateOptions_AllDefaultTrue() + { + var opts = new SiteTemplateOptions(); + Assert.True(opts.CaptureLibraries); + Assert.True(opts.CaptureFolders); + Assert.True(opts.CapturePermissionGroups); + Assert.True(opts.CaptureLogo); + Assert.True(opts.CaptureSettings); + } + + [Fact(Skip = "Requires live SharePoint tenant")] + public async Task CaptureTemplateAsync_CapturesLibrariesAndFolders() + { + await Task.CompletedTask; + } + + [Fact(Skip = "Requires live SharePoint admin context")] + public async Task ApplyTemplateAsync_CreatesTeamSiteWithStructure() + { + await Task.CompletedTask; + } +} diff --git a/SharepointToolbox/Services/FolderStructureService.cs b/SharepointToolbox/Services/FolderStructureService.cs new file mode 100644 index 0000000..b596cee --- /dev/null +++ b/SharepointToolbox/Services/FolderStructureService.cs @@ -0,0 +1,69 @@ +using Microsoft.SharePoint.Client; +using Serilog; +using SharepointToolbox.Core.Helpers; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +public class FolderStructureService : IFolderStructureService +{ + public async Task> CreateFoldersAsync( + ClientContext ctx, + string libraryTitle, + IReadOnlyList rows, + IProgress progress, + CancellationToken ct) + { + // Get library root folder URL + var list = ctx.Web.Lists.GetByTitle(libraryTitle); + ctx.Load(list, l => l.RootFolder.ServerRelativeUrl); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct); + + var baseUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/'); + + // Build unique folder paths from CSV rows, sorted parent-first + var folderPaths = BuildUniquePaths(rows); + + return await BulkOperationRunner.RunAsync( + folderPaths, + async (path, idx, token) => + { + var fullPath = $"{baseUrl}/{path}"; + ctx.Web.Folders.Add(fullPath); + await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, token); + Log.Information("Created folder: {Path}", fullPath); + }, + progress, + ct); + } + + /// + /// Builds unique folder paths from CSV rows, sorted parent-first to ensure + /// parent folders are created before children. + /// + internal static IReadOnlyList BuildUniquePaths(IReadOnlyList rows) + { + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + var parts = new[] { row.Level1, row.Level2, row.Level3, row.Level4 } + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); + + // Add each level as a path (e.g., "Admin", "Admin/HR", "Admin/HR/Contracts") + var current = string.Empty; + foreach (var part in parts) + { + current = string.IsNullOrEmpty(current) ? part.Trim() : $"{current}/{part.Trim()}"; + paths.Add(current); + } + } + + // Sort by depth (fewer slashes first) to ensure parent-first ordering + return paths + .OrderBy(p => p.Count(c => c == '/')) + .ThenBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToList(); + } +}