Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 96 additions & 21 deletions FirstClassErrors.UnitTests/ErrorDefensiveTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#region Usings declarations

using System.Diagnostics.CodeAnalysis;

using JetBrains.Annotations;

using NFluent;
Expand All @@ -8,8 +10,22 @@

namespace FirstClassErrors.UnitTests;

[Collection("SmartEnumSideEffects")]
[TestSubject(typeof(Error))]
public sealed class ErrorDefensiveTests {
public sealed class ErrorDefensiveTests : IDisposable {

#region Constructors & Destructor

public ErrorDefensiveTests() {
ErrorContextKey.ResetForTests();
}

#endregion

[SuppressMessage("Usage", "CA1816", Justification = "IDisposable is used as an xUnit teardown hook. The class has no finalizer and does not own unmanaged resources.")]
public void Dispose() {
ErrorContextKey.ResetForTests();
}

[Fact(DisplayName = "A null error code is replaced by the unspecified error code.")]
public void ANullErrorCodeIsReplacedByTheUnspecifiedErrorCode() {
Expand All @@ -20,21 +36,25 @@ public void ANullErrorCodeIsReplacedByTheUnspecifiedErrorCode() {
Check.That(error.Code).IsSameReferenceAs(ErrorCode.Unspecified);
}

[Fact(DisplayName = "A null diagnostic message is rejected.")]
public void ANullDiagnosticMessageIsRejected() {
// Exercise & verify
Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, null!))
.Throws<ArgumentNullException>();
[Fact(DisplayName = "A null diagnostic message is replaced by a fallback sentinel.")]
public void ANullDiagnosticMessageIsReplacedByAFallbackSentinel() {
// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, null!).WithPublicMessage("short");

// Verify
Check.That(error.DiagnosticMessage).IsEqualTo(Error.MissingDiagnosticMessage);
}

[Theory(DisplayName = "An empty or whitespace diagnostic message is rejected.")]
[Theory(DisplayName = "An empty or whitespace diagnostic message is replaced by a fallback sentinel.")]
[InlineData("")]
[InlineData(" ")]
[InlineData(" ")]
public void AnEmptyOrWhitespaceDiagnosticMessageIsRejected(string value) {
// Exercise & verify
Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, value))
.Throws<ArgumentException>();
public void AnEmptyOrWhitespaceDiagnosticMessageIsReplacedByAFallbackSentinel(string value) {
// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, value).WithPublicMessage("short");

// Verify
Check.That(error.DiagnosticMessage).IsEqualTo(Error.MissingDiagnosticMessage);
}

[Fact(DisplayName = "The diagnostic message is trimmed.")]
Expand All @@ -46,21 +66,56 @@ public void TheDiagnosticMessageIsTrimmed() {
Check.That(error.DiagnosticMessage).IsEqualTo("hello");
}

[Fact(DisplayName = "A null short message is rejected.")]
public void ANullShortMessageIsRejected() {
// Exercise & verify
Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(null!))
.Throws<ArgumentNullException>();
[Fact(DisplayName = "A null short message is replaced by a fallback sentinel.")]
public void ANullShortMessageIsReplacedByAFallbackSentinel() {
// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(null!);

// Verify
Check.That(error.ShortMessage).IsEqualTo(Error.MissingShortMessage);
}

[Theory(DisplayName = "An empty or whitespace short message is rejected.")]
[Theory(DisplayName = "An empty or whitespace short message is replaced by a fallback sentinel.")]
[InlineData("")]
[InlineData(" ")]
[InlineData(" ")]
public void AnEmptyOrWhitespaceShortMessageIsRejected(string value) {
// Exercise & verify
Check.ThatCode(() => DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(value))
.Throws<ArgumentException>();
public void AnEmptyOrWhitespaceShortMessageIsReplacedByAFallbackSentinel(string value) {
// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage(value);

// Verify
Check.That(error.ShortMessage).IsEqualTo(Error.MissingShortMessage);
}

[Fact(DisplayName = "A missing diagnostic message is recorded in the context under #MISSING_REQUIRED_MESSAGE.")]
public void AMissingDiagnosticMessageIsRecordedInTheContext() {
// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, null!).WithPublicMessage("short");

// Verify
Check.That(error.Context.Values.ContainsKey(ErrorContextKey.MissingRequiredMessages)).IsTrue();

IReadOnlyList<string> missing = (IReadOnlyList<string>)error.Context.Values[ErrorContextKey.MissingRequiredMessages]!;
Check.That(missing).ContainsExactly("diagnosticMessage");
}

[Fact(DisplayName = "Missing diagnostic and short messages are both recorded in the context.")]
public void MissingDiagnosticAndShortMessagesAreBothRecordedInTheContext() {
// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, null!).WithPublicMessage(null!);

// Verify
IReadOnlyList<string> missing = (IReadOnlyList<string>)error.Context.Values[ErrorContextKey.MissingRequiredMessages]!;
Check.That(missing).ContainsExactly("diagnosticMessage", "shortMessage");
}

[Fact(DisplayName = "Present mandatory messages leave no #MISSING_REQUIRED_MESSAGE entry in the context.")]
public void PresentMandatoryMessagesLeaveNoMissingEntryInTheContext() {
// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic").WithPublicMessage("short");

// Verify
Check.That(error.Context.Values.ContainsKey(ErrorContextKey.MissingRequiredMessages)).IsFalse();
}

[Fact(DisplayName = "The short message is trimmed.")]
Expand Down Expand Up @@ -117,6 +172,26 @@ public void AConfigureContextDelegateThatThrowsIsCapturedIntoTheContext() {
Check.That(((Exception)captured!).Message).IsEqualTo("boom");
}

[Fact(DisplayName = "A configure-context delegate that throws preserves the entries added before the failure.")]
public void AConfigureContextDelegateThatThrowsPreservesTheEntriesAddedBeforeTheFailure() {
// Setup
ErrorContextKey<string> beforeKey = ErrorContextKey.Create<string>("BeforeFailure");

// Exercise
DomainError error = DomainError.Create(ErrorCode.Unspecified, "diagnostic", builder => {
builder.Add(beforeKey, "kept");

throw new InvalidOperationException("boom");
})
.WithPublicMessage("short");

// Verify — the entry added before the failure survives alongside the captured exception.
bool found = error.Context.TryGet(beforeKey, out string? kept);
Check.That(found).IsTrue();
Check.That(kept).IsEqualTo("kept");
Check.That(error.Context.Values.ContainsKey(ErrorContextKey.CannotBuildErrorContext)).IsTrue();
}

[Fact(DisplayName = "Inner errors are stored as a defensive copy of the provided collection.")]
public void InnerErrorsAreStoredAsADefensiveCopyOfTheProvidedCollection() {
// Setup
Expand Down
12 changes: 3 additions & 9 deletions FirstClassErrors/DomainError.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,8 @@ internal DomainError(ErrorCode code, string diagnosticMessage, string shortMessa
/// <param name="configureContext">An optional action to configure additional error context.</param>
/// <returns>A <see cref="PublicMessageStage{TError}" /> to supply the public messages and finalize the error.</returns>
public static PublicMessageStage<DomainError> Create(ErrorCode code, string diagnosticMessage, Action<ErrorContextBuilder>? configureContext = null) {
string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage));

return new PublicMessageStage<DomainError>((shortMessage, detailedMessage) =>
new DomainError(code, safeDiagnosticMessage, shortMessage, detailedMessage, configureContext));
new DomainError(code, diagnosticMessage, shortMessage, detailedMessage, configureContext));
}

/// <summary>
Expand All @@ -50,10 +48,8 @@ public static PublicMessageStage<DomainError> Create(ErrorCode code, string diag
/// <param name="configureContext">An optional action to configure additional error context.</param>
/// <returns>A <see cref="PublicMessageStage{TError}" /> to supply the public messages and finalize the error.</returns>
public static PublicMessageStage<DomainError> Create(ErrorCode code, string diagnosticMessage, DomainError innerError, Action<ErrorContextBuilder>? configureContext = null) {
string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage));

return new PublicMessageStage<DomainError>((shortMessage, detailedMessage) =>
new DomainError(code, safeDiagnosticMessage, shortMessage, detailedMessage, innerError, configureContext));
new DomainError(code, diagnosticMessage, shortMessage, detailedMessage, innerError, configureContext));
}

/// <summary>
Expand All @@ -65,10 +61,8 @@ public static PublicMessageStage<DomainError> Create(ErrorCode code, string diag
/// <param name="configureContext">An optional action to configure additional error context.</param>
/// <returns>A <see cref="PublicMessageStage{TError}" /> to supply the public messages and finalize the error.</returns>
public static PublicMessageStage<DomainError> Create(ErrorCode code, string diagnosticMessage, IEnumerable<DomainError> innerErrors, Action<ErrorContextBuilder>? configureContext = null) {
string safeDiagnosticMessage = RequireMessage(diagnosticMessage, nameof(diagnosticMessage));

return new PublicMessageStage<DomainError>((shortMessage, detailedMessage) =>
new DomainError(code, safeDiagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext));
new DomainError(code, diagnosticMessage, shortMessage, detailedMessage, innerErrors, configureContext));
}

#endregion
Expand Down
Loading
Loading