diff --git a/src/dotnet/QuantEngine.Application/Services/ApprovalService.cs b/src/dotnet/QuantEngine.Application/Services/ApprovalService.cs index fff0f383..88483003 100644 --- a/src/dotnet/QuantEngine.Application/Services/ApprovalService.cs +++ b/src/dotnet/QuantEngine.Application/Services/ApprovalService.cs @@ -10,18 +10,39 @@ namespace QuantEngine.Application.Services { private readonly IWorkspaceRepository _repository; - public ApprovalService(IWorkspaceRepository repository) + public ApprovalService(IWorkspaceRepository repository) + { + _repository = repository; + } + + public Task> GetApprovalsAsync() => _repository.GetApprovalsAsync(); + public Task GetApprovalAsync(string domain, string targetRef) + => _repository.GetApprovalAsync(RequireValue(domain, nameof(domain)), RequireValue(targetRef, nameof(targetRef))); + public Task UpsertApprovalAsync(WorkspaceApproval approval) + { + ArgumentNullException.ThrowIfNull(approval); + return _repository.UpsertApprovalAsync(approval); + } + + public Task> GetLocksAsync() => _repository.GetLocksAsync(); + public Task GetLockAsync(string domain, string targetRef) + => _repository.GetLockAsync(RequireValue(domain, nameof(domain)), RequireValue(targetRef, nameof(targetRef))); + public Task AcquireLockAsync(WorkspaceLock @lock) + { + ArgumentNullException.ThrowIfNull(@lock); + return _repository.AcquireLockAsync(@lock); + } + public Task ReleaseLockAsync(string domain, string targetRef) + => _repository.ReleaseLockAsync(RequireValue(domain, nameof(domain)), RequireValue(targetRef, nameof(targetRef))); + + private static string RequireValue(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) { - _repository = repository; + throw new ArgumentException("Value is required.", parameterName); } - public Task> GetApprovalsAsync() => _repository.GetApprovalsAsync(); - public Task GetApprovalAsync(string domain, string targetRef) => _repository.GetApprovalAsync(domain, targetRef); - public Task UpsertApprovalAsync(WorkspaceApproval approval) => _repository.UpsertApprovalAsync(approval); - - public Task> GetLocksAsync() => _repository.GetLocksAsync(); - public Task GetLockAsync(string domain, string targetRef) => _repository.GetLockAsync(domain, targetRef); - public Task AcquireLockAsync(WorkspaceLock @lock) => _repository.AcquireLockAsync(@lock); - public Task ReleaseLockAsync(string domain, string targetRef) => _repository.ReleaseLockAsync(domain, targetRef); + return value.Trim(); } } +} diff --git a/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs b/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs index 6113c940..0472ed39 100644 --- a/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs +++ b/src/dotnet/QuantEngine.Application/Services/WorkspaceService.cs @@ -11,22 +11,39 @@ namespace QuantEngine.Application.Services private readonly IWorkspaceRepository _repository; private readonly IPostgresqlHistoryStore _historyStore; - public WorkspaceService(IWorkspaceRepository repository, IPostgresqlHistoryStore historyStore) + public WorkspaceService(IWorkspaceRepository repository, IPostgresqlHistoryStore historyStore) + { + _repository = repository; + _historyStore = historyStore; + } + + public Task> GetSettingsAsync() => _repository.GetSettingsAsync(); + public Task GetSettingByKeyAsync(string key) => _repository.GetSettingByKeyAsync(RequireValue(key, nameof(key))); + public Task UpsertSettingAsync(Setting setting) + { + ArgumentNullException.ThrowIfNull(setting); + return _repository.UpsertSettingAsync(setting); + } + public Task DeleteSettingAsync(string key) => _repository.DeleteSettingAsync(RequireValue(key, nameof(key))); + + public Task> GetAccountSnapshotsAsync() => _repository.GetAccountSnapshotsAsync(); + public Task InsertAccountSnapshotsAsync(IEnumerable snapshots) => _repository.InsertAccountSnapshotsAsync(snapshots); + public Task ClearAccountSnapshotsAsync() => _repository.ClearAccountSnapshotsAsync(); + + public Task AppendHistoryAsync(string domain, IDictionary payload) + => _historyStore.AppendAsync(RequireValue(domain, nameof(domain)), payload); + + public Task>> ReadHistorySnapshotAsync(string domain, int limit = 500) + => _historyStore.SnapshotAsync(RequireValue(domain, nameof(domain)), Math.Clamp(limit, 1, 2000)); + + private static string RequireValue(string value, string parameterName) + { + if (string.IsNullOrWhiteSpace(value)) { - _repository = repository; - _historyStore = historyStore; + throw new ArgumentException("Value is required.", parameterName); } - public Task> GetSettingsAsync() => _repository.GetSettingsAsync(); - public Task GetSettingByKeyAsync(string key) => _repository.GetSettingByKeyAsync(key); - public Task UpsertSettingAsync(Setting setting) => _repository.UpsertSettingAsync(setting); - public Task DeleteSettingAsync(string key) => _repository.DeleteSettingAsync(key); - - public Task> GetAccountSnapshotsAsync() => _repository.GetAccountSnapshotsAsync(); - public Task InsertAccountSnapshotsAsync(IEnumerable snapshots) => _repository.InsertAccountSnapshotsAsync(snapshots); - public Task ClearAccountSnapshotsAsync() => _repository.ClearAccountSnapshotsAsync(); - - public Task AppendHistoryAsync(string domain, IDictionary payload) => _historyStore.AppendAsync(domain, payload); - public Task>> ReadHistorySnapshotAsync(string domain, int limit = 500) => _historyStore.SnapshotAsync(domain, limit); + return value.Trim(); } } +} diff --git a/src/dotnet/QuantEngine.Core.Tests/WorkspaceApprovalServiceTests.cs b/src/dotnet/QuantEngine.Core.Tests/WorkspaceApprovalServiceTests.cs new file mode 100644 index 00000000..2d40ff2c --- /dev/null +++ b/src/dotnet/QuantEngine.Core.Tests/WorkspaceApprovalServiceTests.cs @@ -0,0 +1,41 @@ +using Moq; +using QuantEngine.Application.Services; +using QuantEngine.Core.Interfaces; +using QuantEngine.Core.Models; + +namespace QuantEngine.Core.Tests; + +public class WorkspaceApprovalServiceTests +{ + [Fact] + public async Task WorkspaceService_RejectsBlankHistoryDomain() + { + var service = new WorkspaceService(new Mock().Object, new Mock().Object); + + await Assert.ThrowsAsync(() => + service.AppendHistoryAsync(" ", new Dictionary())); + } + + [Fact] + public async Task ApprovalService_NormalizesLookupKeys() + { + var repo = new Mock(MockBehavior.Strict); + repo.Setup(r => r.GetApprovalAsync("workflow", "target-1")).ReturnsAsync(new WorkspaceApproval()); + + var service = new ApprovalService(repo.Object); + + var approval = await service.GetApprovalAsync(" workflow ", " target-1 "); + + Assert.NotNull(approval); + repo.VerifyAll(); + } + + [Fact] + public async Task ApprovalService_RejectsBlankLockTarget() + { + var service = new ApprovalService(new Mock().Object); + + await Assert.ThrowsAsync(() => + service.ReleaseLockAsync("workflow", " ")); + } +}