From 488a9847ea15d977d28582bc44da110758e46ad1 Mon Sep 17 00:00:00 2001 From: Jared McCannon Date: Fri, 14 Mar 2025 12:00:58 -0500 Subject: [PATCH] Partial for CommandResult (#5482) * Example of how a partial success/failure command result would look. * Fixed code. * Added Validator and ValidationResult * Moved errors into their own files. * Fixing tests * fixed import. * Forgot mock error. --- src/Api/Utilities/CommandResultExtensions.cs | 2 +- src/Core/AdminConsole/Errors/Error.cs | 3 + .../Errors/InsufficientPermissionsError.cs | 11 ++++ .../Errors/RecordNotFoundError.cs | 11 ++++ .../Shared/Validation/IValidator.cs | 6 ++ .../Shared/Validation/ValidationResult.cs | 15 +++++ src/Core/Models/Commands/CommandResult.cs | 27 ++++++--- .../AdminConsole/Shared/IValidatorTests.cs | 58 +++++++++++++++++++ .../Models/Commands/CommandResultTests.cs | 53 +++++++++++++++++ 9 files changed, 176 insertions(+), 10 deletions(-) create mode 100644 src/Core/AdminConsole/Errors/Error.cs create mode 100644 src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs create mode 100644 src/Core/AdminConsole/Errors/RecordNotFoundError.cs create mode 100644 src/Core/AdminConsole/Shared/Validation/IValidator.cs create mode 100644 src/Core/AdminConsole/Shared/Validation/ValidationResult.cs create mode 100644 test/Core.Test/AdminConsole/Shared/IValidatorTests.cs create mode 100644 test/Core.Test/Models/Commands/CommandResultTests.cs diff --git a/src/Api/Utilities/CommandResultExtensions.cs b/src/Api/Utilities/CommandResultExtensions.cs index 39104db7ff..c7315a0fa0 100644 --- a/src/Api/Utilities/CommandResultExtensions.cs +++ b/src/Api/Utilities/CommandResultExtensions.cs @@ -12,7 +12,7 @@ public static class CommandResultExtensions NoRecordFoundFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status404NotFound }, BadRequestFailure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, Failure failure => new ObjectResult(failure.ErrorMessages) { StatusCode = StatusCodes.Status400BadRequest }, - Success success => new ObjectResult(success.Data) { StatusCode = StatusCodes.Status200OK }, + Success success => new ObjectResult(success.Value) { StatusCode = StatusCodes.Status200OK }, _ => throw new InvalidOperationException($"Unhandled commandResult type: {commandResult.GetType().Name}") }; } diff --git a/src/Core/AdminConsole/Errors/Error.cs b/src/Core/AdminConsole/Errors/Error.cs new file mode 100644 index 0000000000..6c8eed41a4 --- /dev/null +++ b/src/Core/AdminConsole/Errors/Error.cs @@ -0,0 +1,3 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record Error(string Message, T ErroredValue); diff --git a/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs b/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs new file mode 100644 index 0000000000..d04ceba7c9 --- /dev/null +++ b/src/Core/AdminConsole/Errors/InsufficientPermissionsError.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record InsufficientPermissionsError(string Message, T ErroredValue) : Error(Message, ErroredValue) +{ + public const string Code = "Insufficient Permissions"; + + public InsufficientPermissionsError(T ErroredValue) : this(Code, ErroredValue) + { + + } +} diff --git a/src/Core/AdminConsole/Errors/RecordNotFoundError.cs b/src/Core/AdminConsole/Errors/RecordNotFoundError.cs new file mode 100644 index 0000000000..25a169efe1 --- /dev/null +++ b/src/Core/AdminConsole/Errors/RecordNotFoundError.cs @@ -0,0 +1,11 @@ +namespace Bit.Core.AdminConsole.Errors; + +public record RecordNotFoundError(string Message, T ErroredValue) : Error(Message, ErroredValue) +{ + public const string Code = "Record Not Found"; + + public RecordNotFoundError(T ErroredValue) : this(Code, ErroredValue) + { + + } +} diff --git a/src/Core/AdminConsole/Shared/Validation/IValidator.cs b/src/Core/AdminConsole/Shared/Validation/IValidator.cs new file mode 100644 index 0000000000..d90386f00e --- /dev/null +++ b/src/Core/AdminConsole/Shared/Validation/IValidator.cs @@ -0,0 +1,6 @@ +namespace Bit.Core.AdminConsole.Shared.Validation; + +public interface IValidator +{ + public Task> ValidateAsync(T value); +} diff --git a/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs new file mode 100644 index 0000000000..e25103e701 --- /dev/null +++ b/src/Core/AdminConsole/Shared/Validation/ValidationResult.cs @@ -0,0 +1,15 @@ +using Bit.Core.AdminConsole.Errors; + +namespace Bit.Core.AdminConsole.Shared.Validation; + +public abstract record ValidationResult; + +public record Valid : ValidationResult +{ + public T Value { get; init; } +} + +public record Invalid : ValidationResult +{ + public IEnumerable> Errors { get; init; } +} diff --git a/src/Core/Models/Commands/CommandResult.cs b/src/Core/Models/Commands/CommandResult.cs index ae14b7d2f9..a8ec772fc1 100644 --- a/src/Core/Models/Commands/CommandResult.cs +++ b/src/Core/Models/Commands/CommandResult.cs @@ -1,5 +1,7 @@ #nullable enable +using Bit.Core.AdminConsole.Errors; + namespace Bit.Core.Models.Commands; public class CommandResult(IEnumerable errors) @@ -9,7 +11,6 @@ public class CommandResult(IEnumerable errors) public bool Success => ErrorMessages.Count == 0; public bool HasErrors => ErrorMessages.Count > 0; public List ErrorMessages { get; } = errors.ToList(); - public CommandResult() : this(Array.Empty()) { } } @@ -29,22 +30,30 @@ public class Success : CommandResult { } -public abstract class CommandResult -{ +public abstract class CommandResult; +public class Success(T value) : CommandResult +{ + public T Value { get; } = value; } -public class Success(T data) : CommandResult +public class Failure(IEnumerable errorMessages) : CommandResult { - public T? Data { get; init; } = data; + public List ErrorMessages { get; } = errorMessages.ToList(); + + public string ErrorMessage => string.Join(" ", ErrorMessages); + + public Failure(string error) : this([error]) { } } -public class Failure(IEnumerable errorMessage) : CommandResult +public class Partial : CommandResult { - public IEnumerable ErrorMessages { get; init; } = errorMessage; + public T[] Successes { get; set; } = []; + public Error[] Failures { get; set; } = []; - public Failure(string errorMessage) : this(new[] { errorMessage }) + public Partial(IEnumerable successfulItems, IEnumerable> failedItems) { + Successes = successfulItems.ToArray(); + Failures = failedItems.ToArray(); } } - diff --git a/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs new file mode 100644 index 0000000000..abb49c25c6 --- /dev/null +++ b/test/Core.Test/AdminConsole/Shared/IValidatorTests.cs @@ -0,0 +1,58 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.AdminConsole.Shared.Validation; +using Xunit; + +namespace Bit.Core.Test.AdminConsole.Shared; + +public class IValidatorTests +{ + public class TestClass + { + public string Name { get; set; } = string.Empty; + } + + public record InvalidRequestError(T ErroredValue) : Error(Code, ErroredValue) + { + public const string Code = "InvalidRequest"; + } + + public class TestClassValidator : IValidator + { + public Task> ValidateAsync(TestClass value) + { + if (string.IsNullOrWhiteSpace(value.Name)) + { + return Task.FromResult>(new Invalid + { + Errors = [new InvalidRequestError(value)] + }); + } + + return Task.FromResult>(new Valid { Value = value }); + } + } + + [Fact] + public async Task ValidateAsync_WhenSomethingIsInvalid_ReturnsInvalidWithError() + { + var example = new TestClass(); + + var result = await new TestClassValidator().ValidateAsync(example); + + Assert.IsType>(result); + var invalidResult = result as Invalid; + Assert.Equal(InvalidRequestError.Code, invalidResult.Errors.First().Message); + } + + [Fact] + public async Task ValidateAsync_WhenIsValid_ReturnsValid() + { + var example = new TestClass { Name = "Valid" }; + + var result = await new TestClassValidator().ValidateAsync(example); + + Assert.IsType>(result); + var validResult = result as Valid; + Assert.Equal(example.Name, validResult.Value.Name); + } +} diff --git a/test/Core.Test/Models/Commands/CommandResultTests.cs b/test/Core.Test/Models/Commands/CommandResultTests.cs new file mode 100644 index 0000000000..c500fef4f5 --- /dev/null +++ b/test/Core.Test/Models/Commands/CommandResultTests.cs @@ -0,0 +1,53 @@ +using Bit.Core.AdminConsole.Errors; +using Bit.Core.Models.Commands; +using Bit.Test.Common.AutoFixture.Attributes; +using Xunit; + +namespace Bit.Core.Test.Models.Commands; + +public class CommandResultTests +{ + public class TestItem + { + public Guid Id { get; set; } + public string Value { get; set; } + } + + public CommandResult BulkAction(IEnumerable items) + { + var itemList = items.ToList(); + var successfulItems = items.Where(x => x.Value == "SuccessfulRequest").ToArray(); + + var failedItems = itemList.Except(successfulItems).ToArray(); + + var notFound = failedItems.First(x => x.Value == "Failed due to not found"); + var invalidPermissions = failedItems.First(x => x.Value == "Failed due to invalid permissions"); + + var notFoundError = new RecordNotFoundError(notFound); + var insufficientPermissionsError = new InsufficientPermissionsError(invalidPermissions); + + return new Partial(successfulItems.ToArray(), [notFoundError, insufficientPermissionsError]); + } + + [Theory] + [BitAutoData] + public void Partial_CommandResult_BulkRequestWithSuccessAndFailures(Guid successId1, Guid failureId1, Guid failureId2) + { + var listOfRecords = new List + { + new TestItem() { Id = successId1, Value = "SuccessfulRequest" }, + new TestItem() { Id = failureId1, Value = "Failed due to not found" }, + new TestItem() { Id = failureId2, Value = "Failed due to invalid permissions" } + }; + + var result = BulkAction(listOfRecords); + + Assert.IsType>(result); + + var failures = (result as Partial).Failures.ToArray(); + var success = (result as Partial).Successes.First(); + + Assert.Equal(listOfRecords.First(), success); + Assert.Equal(2, failures.Length); + } +}