feat(04-02): implement CsvValidationService and TemplateRepository with tests
- CsvValidationService: CsvHelper-based parsing with DetectDelimiter, BOM detection, per-row validation for BulkMemberRow/BulkSiteRow/FolderStructureRow - TemplateRepository: atomic JSON write (tmp + File.Move) with SemaphoreSlim, supports GetAll/GetById/Save/Delete/Rename operations - CsvValidationServiceTests: 9 passing tests (email validation, delimiter detection, BOM handling, folder/site/member validation) - TemplateRepositoryTests: 6 passing tests (round-trip, GetAll, delete, rename, empty directory, non-existent id) - All previously-skipped scaffold tests now active and passing (15 total)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TemplateLibraryInfo>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user