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
This commit is contained in:
@@ -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<FolderStructureRow>
|
||||
{
|
||||
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<FolderStructureRow>
|
||||
{
|
||||
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<FolderStructureRow>
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
49
SharepointToolbox.Tests/Services/TemplateServiceTests.cs
Normal file
49
SharepointToolbox.Tests/Services/TemplateServiceTests.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
69
SharepointToolbox/Services/FolderStructureService.cs
Normal file
69
SharepointToolbox/Services/FolderStructureService.cs
Normal file
@@ -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<BulkOperationSummary<string>> CreateFoldersAsync(
|
||||
ClientContext ctx,
|
||||
string libraryTitle,
|
||||
IReadOnlyList<FolderStructureRow> rows,
|
||||
IProgress<OperationProgress> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds unique folder paths from CSV rows, sorted parent-first to ensure
|
||||
/// parent folders are created before children.
|
||||
/// </summary>
|
||||
internal static IReadOnlyList<string> BuildUniquePaths(IReadOnlyList<FolderStructureRow> rows)
|
||||
{
|
||||
var paths = new HashSet<string>(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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user