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:
@@ -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<List<SiteTemplate>> GetAllAsync()
|
||||
{
|
||||
if (!Directory.Exists(_directoryPath))
|
||||
return new List<SiteTemplate>();
|
||||
|
||||
var templates = new List<SiteTemplate>();
|
||||
foreach (var file in Directory.GetFiles(_directoryPath, "*.json"))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file, Encoding.UTF8);
|
||||
var template = JsonSerializer.Deserialize<SiteTemplate>(json, JsonOptions);
|
||||
if (template != null)
|
||||
templates.Add(template);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Skip corrupted template files
|
||||
}
|
||||
}
|
||||
return templates.OrderByDescending(t => t.CapturedAt).ToList();
|
||||
}
|
||||
|
||||
public async Task<SiteTemplate?> GetByIdAsync(string id)
|
||||
{
|
||||
var filePath = GetFilePath(id);
|
||||
if (!File.Exists(filePath))
|
||||
return null;
|
||||
|
||||
var json = await File.ReadAllTextAsync(filePath, Encoding.UTF8);
|
||||
return JsonSerializer.Deserialize<SiteTemplate>(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");
|
||||
}
|
||||
135
SharepointToolbox/Services/CsvValidationService.cs
Normal file
135
SharepointToolbox/Services/CsvValidationService.cs
Normal file
@@ -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<CsvValidationRow<T>> ParseAndValidate<T>(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<CsvValidationRow<T>>();
|
||||
csv.Read();
|
||||
csv.ReadHeader();
|
||||
while (csv.Read())
|
||||
{
|
||||
try
|
||||
{
|
||||
var record = csv.GetRecord<T>();
|
||||
if (record == null)
|
||||
{
|
||||
rows.Add(CsvValidationRow<T>.ParseError(csv.Context.Parser.RawRecord, "Failed to parse row"));
|
||||
continue;
|
||||
}
|
||||
rows.Add(new CsvValidationRow<T>(record, new List<string>()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
rows.Add(CsvValidationRow<T>.ParseError(csv.Context.Parser.RawRecord, ex.Message));
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
public List<CsvValidationRow<BulkMemberRow>> ParseAndValidateMembers(Stream csvStream)
|
||||
{
|
||||
var rows = ParseAndValidate<BulkMemberRow>(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<CsvValidationRow<BulkSiteRow>> ParseAndValidateSites(Stream csvStream)
|
||||
{
|
||||
var rows = ParseAndValidate<BulkSiteRow>(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<CsvValidationRow<FolderStructureRow>> ParseAndValidateFolders(Stream csvStream)
|
||||
{
|
||||
var rows = ParseAndValidate<FolderStructureRow>(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<string> ValidateMemberRow(BulkMemberRow row)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
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<string> ValidateSiteRow(BulkSiteRow row)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
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<string> ValidateFolderRow(FolderStructureRow row)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(row.Level1))
|
||||
errors.Add("Level1 is required (root folder)");
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user