diff --git a/SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs b/SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs index 058c095..dd1c823 100644 --- a/SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs +++ b/SharepointToolbox.Tests/Services/CsvValidationServiceTests.cs @@ -1,36 +1,128 @@ +using System.IO; +using System.Text; using SharepointToolbox.Core.Models; +using SharepointToolbox.Services; namespace SharepointToolbox.Tests.Services; public class CsvValidationServiceTests { - [Fact(Skip = "Implemented in Plan 04-02")] + private readonly CsvValidationService _service = new(); + + private static Stream ToStream(string content) + { + return new MemoryStream(Encoding.UTF8.GetBytes(content)); + } + + private static Stream ToStreamWithBom(string content) + { + var preamble = Encoding.UTF8.GetPreamble(); + var bytes = Encoding.UTF8.GetBytes(content); + var combined = new byte[preamble.Length + bytes.Length]; + preamble.CopyTo(combined, 0); + bytes.CopyTo(combined, preamble.Length); + return new MemoryStream(combined); + } + + [Fact] public void ParseAndValidateMembers_ValidCsv_ReturnsValidRows() { + var csv = "GroupName,GroupUrl,Email,Role\nTeam A,https://site,user@test.com,Member\n"; + var rows = _service.ParseAndValidateMembers(ToStream(csv)); + + Assert.Single(rows); + Assert.True(rows[0].IsValid); + Assert.Equal("user@test.com", rows[0].Record!.Email); } - [Fact(Skip = "Implemented in Plan 04-02")] + [Fact] public void ParseAndValidateMembers_InvalidEmail_ReturnsErrors() { + var csv = "GroupName,GroupUrl,Email,Role\nTeam A,https://site,not-an-email,Member\n"; + var rows = _service.ParseAndValidateMembers(ToStream(csv)); + + Assert.Single(rows); + Assert.False(rows[0].IsValid); + Assert.Contains(rows[0].Errors, e => e.Contains("Invalid email")); } - [Fact(Skip = "Implemented in Plan 04-02")] + [Fact] + public void ParseAndValidateMembers_MissingGroup_ReturnsError() + { + var csv = "GroupName,GroupUrl,Email,Role\n,,user@test.com,Member\n"; + var rows = _service.ParseAndValidateMembers(ToStream(csv)); + + Assert.Single(rows); + Assert.False(rows[0].IsValid); + Assert.Contains(rows[0].Errors, e => e.Contains("GroupName or GroupUrl")); + } + + [Fact] public void ParseAndValidateSites_TeamWithoutOwner_ReturnsError() { + var csv = "Name;Alias;Type;Template;Owners;Members\nSite A;site-a;Team;;;\n"; + var rows = _service.ParseAndValidateSites(ToStream(csv)); + + Assert.Single(rows); + Assert.False(rows[0].IsValid); + Assert.Contains(rows[0].Errors, e => e.Contains("owner")); } - [Fact(Skip = "Implemented in Plan 04-02")] + [Fact] + public void ParseAndValidateSites_ValidTeam_ReturnsValid() + { + var csv = "Name;Alias;Type;Template;Owners;Members\nSite A;site-a;Team;;admin@test.com;user@test.com\n"; + var rows = _service.ParseAndValidateSites(ToStream(csv)); + + Assert.Single(rows); + Assert.True(rows[0].IsValid); + Assert.Equal("Site A", rows[0].Record!.Name); + } + + [Fact] public void ParseAndValidateFolders_ValidCsv_ReturnsValidRows() { + var csv = "Level1;Level2;Level3;Level4\nAdmin;HR;;\n"; + var rows = _service.ParseAndValidateFolders(ToStream(csv)); + + Assert.Single(rows); + Assert.True(rows[0].IsValid); + Assert.Equal("Admin", rows[0].Record!.Level1); + Assert.Equal("HR", rows[0].Record!.Level2); } - [Fact(Skip = "Implemented in Plan 04-02")] + [Fact] + public void ParseAndValidateFolders_MissingLevel1_ReturnsError() + { + var csv = "Level1;Level2;Level3;Level4\n;SubFolder;;\n"; + var rows = _service.ParseAndValidateFolders(ToStream(csv)); + + Assert.Single(rows); + Assert.False(rows[0].IsValid); + Assert.Contains(rows[0].Errors, e => e.Contains("Level1")); + } + + [Fact] public void ParseAndValidate_BomDetection_WorksWithAndWithoutBom() { + var csv = "GroupName,GroupUrl,Email,Role\nTeam A,https://site,user@test.com,Member\n"; + var rowsNoBom = _service.ParseAndValidateMembers(ToStream(csv)); + var rowsWithBom = _service.ParseAndValidateMembers(ToStreamWithBom(csv)); + + Assert.Single(rowsNoBom); + Assert.Single(rowsWithBom); + Assert.True(rowsNoBom[0].IsValid); + Assert.True(rowsWithBom[0].IsValid); } - [Fact(Skip = "Implemented in Plan 04-02")] + [Fact] public void ParseAndValidate_SemicolonDelimiter_DetectedAutomatically() { + var csv = "Name;Alias;Type;Template;Owners;Members\nSite A;site-a;Communication;;;;\n"; + var rows = _service.ParseAndValidateSites(ToStream(csv)); + + Assert.Single(rows); + Assert.Equal("Site A", rows[0].Record!.Name); + Assert.Equal("Communication", rows[0].Record!.Type); } } diff --git a/SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs b/SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs index 3150383..14e8c01 100644 --- a/SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs +++ b/SharepointToolbox.Tests/Services/TemplateRepositoryTests.cs @@ -1,26 +1,107 @@ +using System.IO; using SharepointToolbox.Core.Models; +using SharepointToolbox.Infrastructure.Persistence; namespace SharepointToolbox.Tests.Services; -public class TemplateRepositoryTests +public class TemplateRepositoryTests : IDisposable { - [Fact(Skip = "Implemented in Plan 04-02")] - public void SaveAndLoad_RoundTrips_Correctly() + private readonly string _tempDir; + private readonly TemplateRepository _repo; + + public TemplateRepositoryTests() { + _tempDir = Path.Combine(Path.GetTempPath(), $"sptoolbox_test_{Guid.NewGuid():N}"); + _repo = new TemplateRepository(_tempDir); } - [Fact(Skip = "Implemented in Plan 04-02")] - public void GetAll_ReturnsAllSavedTemplates() + public void Dispose() { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); } - [Fact(Skip = "Implemented in Plan 04-02")] - public void Delete_RemovesTemplate() + private static SiteTemplate CreateTestTemplate(string name = "Test Template") { + return new SiteTemplate + { + Id = Guid.NewGuid().ToString(), + Name = name, + SourceUrl = "https://contoso.sharepoint.com/sites/test", + CapturedAt = DateTime.UtcNow, + SiteType = "Team", + Options = new SiteTemplateOptions(), + Settings = new TemplateSettings { Title = "Test", Description = "Desc", Language = 1033 }, + Libraries = new List + { + new() { Name = "Documents", BaseType = "DocumentLibrary", BaseTemplate = 101 } + }, + }; } - [Fact(Skip = "Implemented in Plan 04-02")] - public void Rename_UpdatesTemplateName() + [Fact] + public async Task SaveAndLoad_RoundTrips_Correctly() { + var template = CreateTestTemplate(); + await _repo.SaveAsync(template); + + var loaded = await _repo.GetByIdAsync(template.Id); + + Assert.NotNull(loaded); + Assert.Equal(template.Name, loaded!.Name); + Assert.Equal(template.SiteType, loaded.SiteType); + Assert.Equal(template.SourceUrl, loaded.SourceUrl); + Assert.Single(loaded.Libraries); + Assert.Equal("Documents", loaded.Libraries[0].Name); + } + + [Fact] + public async Task GetAll_ReturnsAllSavedTemplates() + { + await _repo.SaveAsync(CreateTestTemplate("Template A")); + await _repo.SaveAsync(CreateTestTemplate("Template B")); + await _repo.SaveAsync(CreateTestTemplate("Template C")); + + var all = await _repo.GetAllAsync(); + + Assert.Equal(3, all.Count); + } + + [Fact] + public async Task Delete_RemovesTemplate() + { + var template = CreateTestTemplate(); + await _repo.SaveAsync(template); + Assert.NotNull(await _repo.GetByIdAsync(template.Id)); + + await _repo.DeleteAsync(template.Id); + + Assert.Null(await _repo.GetByIdAsync(template.Id)); + } + + [Fact] + public async Task Rename_UpdatesTemplateName() + { + var template = CreateTestTemplate("Old Name"); + await _repo.SaveAsync(template); + + await _repo.RenameAsync(template.Id, "New Name"); + + var loaded = await _repo.GetByIdAsync(template.Id); + Assert.Equal("New Name", loaded!.Name); + } + + [Fact] + public async Task GetAll_EmptyDirectory_ReturnsEmptyList() + { + var all = await _repo.GetAllAsync(); + Assert.Empty(all); + } + + [Fact] + public async Task GetById_NonExistent_ReturnsNull() + { + var result = await _repo.GetByIdAsync("nonexistent-id"); + Assert.Null(result); } } diff --git a/SharepointToolbox/Infrastructure/Persistence/TemplateRepository.cs b/SharepointToolbox/Infrastructure/Persistence/TemplateRepository.cs new file mode 100644 index 0000000..8497652 --- /dev/null +++ b/SharepointToolbox/Infrastructure/Persistence/TemplateRepository.cs @@ -0,0 +1,101 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Infrastructure.Persistence; + +public class TemplateRepository +{ + private readonly string _directoryPath; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public TemplateRepository(string directoryPath) + { + _directoryPath = directoryPath; + } + + public async Task> GetAllAsync() + { + if (!Directory.Exists(_directoryPath)) + return new List(); + + var templates = new List(); + foreach (var file in Directory.GetFiles(_directoryPath, "*.json")) + { + try + { + var json = await File.ReadAllTextAsync(file, Encoding.UTF8); + var template = JsonSerializer.Deserialize(json, JsonOptions); + if (template != null) + templates.Add(template); + } + catch (JsonException) + { + // Skip corrupted template files + } + } + return templates.OrderByDescending(t => t.CapturedAt).ToList(); + } + + public async Task GetByIdAsync(string id) + { + var filePath = GetFilePath(id); + if (!File.Exists(filePath)) + return null; + + var json = await File.ReadAllTextAsync(filePath, Encoding.UTF8); + return JsonSerializer.Deserialize(json, JsonOptions); + } + + public async Task SaveAsync(SiteTemplate template) + { + await _writeLock.WaitAsync(); + try + { + if (!Directory.Exists(_directoryPath)) + Directory.CreateDirectory(_directoryPath); + + var json = JsonSerializer.Serialize(template, JsonOptions); + var filePath = GetFilePath(template.Id); + var tmpPath = filePath + ".tmp"; + + await File.WriteAllTextAsync(tmpPath, json, Encoding.UTF8); + + // Validate round-trip before replacing + JsonDocument.Parse(await File.ReadAllTextAsync(tmpPath, Encoding.UTF8)).Dispose(); + + File.Move(tmpPath, filePath, overwrite: true); + } + finally + { + _writeLock.Release(); + } + } + + public Task DeleteAsync(string id) + { + var filePath = GetFilePath(id); + if (File.Exists(filePath)) + File.Delete(filePath); + return Task.CompletedTask; + } + + public async Task RenameAsync(string id, string newName) + { + var template = await GetByIdAsync(id); + if (template == null) + throw new InvalidOperationException($"Template not found: {id}"); + + template.Name = newName; + await SaveAsync(template); + } + + private string GetFilePath(string id) => Path.Combine(_directoryPath, $"{id}.json"); +} diff --git a/SharepointToolbox/Services/CsvValidationService.cs b/SharepointToolbox/Services/CsvValidationService.cs new file mode 100644 index 0000000..3422736 --- /dev/null +++ b/SharepointToolbox/Services/CsvValidationService.cs @@ -0,0 +1,135 @@ +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; +using CsvHelper; +using CsvHelper.Configuration; +using SharepointToolbox.Core.Models; + +namespace SharepointToolbox.Services; + +public class CsvValidationService : ICsvValidationService +{ + private static readonly Regex EmailRegex = new( + @"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled); + + public List> ParseAndValidate(Stream csvStream) where T : class + { + using var reader = new StreamReader(csvStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture) + { + HasHeaderRecord = true, + MissingFieldFound = null, + HeaderValidated = null, + DetectDelimiter = true, + TrimOptions = TrimOptions.Trim, + }); + + var rows = new List>(); + csv.Read(); + csv.ReadHeader(); + while (csv.Read()) + { + try + { + var record = csv.GetRecord(); + if (record == null) + { + rows.Add(CsvValidationRow.ParseError(csv.Context.Parser.RawRecord, "Failed to parse row")); + continue; + } + rows.Add(new CsvValidationRow(record, new List())); + } + catch (Exception ex) + { + rows.Add(CsvValidationRow.ParseError(csv.Context.Parser.RawRecord, ex.Message)); + } + } + return rows; + } + + public List> ParseAndValidateMembers(Stream csvStream) + { + var rows = ParseAndValidate(csvStream); + foreach (var row in rows.Where(r => r.IsValid && r.Record != null)) + { + var errors = ValidateMemberRow(row.Record!); + row.Errors.AddRange(errors); + } + return rows; + } + + public List> ParseAndValidateSites(Stream csvStream) + { + var rows = ParseAndValidate(csvStream); + foreach (var row in rows.Where(r => r.IsValid && r.Record != null)) + { + var errors = ValidateSiteRow(row.Record!); + row.Errors.AddRange(errors); + } + return rows; + } + + public List> ParseAndValidateFolders(Stream csvStream) + { + var rows = ParseAndValidate(csvStream); + foreach (var row in rows.Where(r => r.IsValid && r.Record != null)) + { + var errors = ValidateFolderRow(row.Record!); + row.Errors.AddRange(errors); + } + return rows; + } + + private static List ValidateMemberRow(BulkMemberRow row) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(row.Email)) + errors.Add("Email is required"); + else if (!EmailRegex.IsMatch(row.Email.Trim())) + errors.Add($"Invalid email format: {row.Email}"); + + if (string.IsNullOrWhiteSpace(row.GroupName) && string.IsNullOrWhiteSpace(row.GroupUrl)) + errors.Add("GroupName or GroupUrl is required"); + + if (!string.IsNullOrWhiteSpace(row.Role) && + !row.Role.Equals("Member", StringComparison.OrdinalIgnoreCase) && + !row.Role.Equals("Owner", StringComparison.OrdinalIgnoreCase)) + errors.Add($"Role must be 'Member' or 'Owner', got: {row.Role}"); + + return errors; + } + + private static List ValidateSiteRow(BulkSiteRow row) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(row.Name)) + errors.Add("Name is required"); + + if (string.IsNullOrWhiteSpace(row.Type)) + errors.Add("Type is required"); + else if (!row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase) && + !row.Type.Equals("Communication", StringComparison.OrdinalIgnoreCase)) + errors.Add($"Type must be 'Team' or 'Communication', got: {row.Type}"); + + // Team sites require at least one owner (Pitfall 6 from research) + if (row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(row.Owners)) + errors.Add("Team sites require at least one owner"); + + // Team sites need an alias + if (row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(row.Alias)) + errors.Add("Team sites require an alias"); + + return errors; + } + + private static List ValidateFolderRow(FolderStructureRow row) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(row.Level1)) + errors.Add("Level1 is required (root folder)"); + return errors; + } +}