Skip to content

Commit 6b83fdf

Browse files
committed
more extentions
1 parent d65d8d6 commit 6b83fdf

22 files changed

+1211
-58
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ If I tell you to remember something, you do the same, update
1212
always check all test are passed.
1313
- Prefer static interface members for result/command factories to centralize shared overloads and avoid duplication across result-like types.
1414
- Use `DateTime.UtcNow` (never `DateTimeOffset`) for all timestamps; we assume every stored time is in UTC.
15+
- For `MergeAll`/`CombineAll` scenarios with mixed failures, keep aggregated behavior and preserve original errors in `Problem.Extensions` (do not flatten everything into validation-only output).
16+
- In display-message APIs, use the parameter name `defaultMessage` (avoid the word `fallback` in public API naming).
17+
- For user-facing helper APIs, prefer multiple ergonomic overloads (delegate + dictionary + tuple mappings) so callers can choose the most convenient style.
18+
- Do not add redundant `result.Problem is not null` checks after `result.IsFailed`; rely on result nullability contract/attributes and only use null-forgiving where needed.
19+
- Keep documentation aligned with the current major version (for this repository now: version 10); do not add cross-major migration sections unless explicitly requested.
20+
- When behavior changes in Result/Problem flows, include a clear README update with concrete usage examples.
1521

1622
# Repository Guidelines
1723

Directory.Build.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
<RepositoryUrl>https://github.com/managedcode/Communication</RepositoryUrl>
2727
<PackageProjectUrl>https://github.com/managedcode/Communication</PackageProjectUrl>
2828
<Product>Managed Code - Communication</Product>
29-
<Version>10.0.0</Version>
30-
<PackageVersion>10.0.0</PackageVersion>
29+
<Version>10.0.1</Version>
30+
<PackageVersion>10.0.1</PackageVersion>
3131

3232
</PropertyGroup>
3333
<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true'">

ManagedCode.Communication.Tests/Extensions/AdvancedRailwayExtensionsTests.cs

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
using System;
22
using System.Linq;
3+
using System.Net;
34
using System.Threading.Tasks;
45
using Shouldly;
6+
using ManagedCode.Communication.Constants;
57
using ManagedCode.Communication.Extensions;
68
using ManagedCode.Communication.Results.Extensions;
79
using Xunit;
@@ -213,6 +215,50 @@ public void MergeAll_WithMultipleFailures_CollectsAllErrors()
213215
errors["field3"].ShouldContain("Error 3");
214216
}
215217

218+
[Fact]
219+
public void MergeAll_WithMixedFailures_ReturnsAggregateProblemWithOriginalErrors()
220+
{
221+
// Arrange
222+
var result1 = Result.Fail("Unauthorized", "Authentication required", HttpStatusCode.Unauthorized);
223+
var result2 = Result.Fail("Forbidden", "Access denied", HttpStatusCode.Forbidden);
224+
var result3 = Result.Fail("Server Error", "Unexpected failure", HttpStatusCode.InternalServerError);
225+
226+
// Act
227+
var merged = AdvancedRailwayExtensions.MergeAll(result1, result2, result3);
228+
229+
// Assert
230+
merged.IsFailed.ShouldBeTrue();
231+
merged.Problem.ShouldNotBeNull();
232+
merged.Problem!.Title.ShouldBe("Multiple errors occurred");
233+
merged.Problem.Detail.ShouldBe("The operation failed with multiple errors.");
234+
merged.Problem.StatusCode.ShouldBe(500);
235+
merged.Problem.TryGetExtension(ProblemConstants.ExtensionKeys.Errors, out Problem[]? aggregatedErrors).ShouldBeTrue();
236+
aggregatedErrors.ShouldNotBeNull();
237+
aggregatedErrors.Length.ShouldBe(3);
238+
aggregatedErrors.Select(problem => problem.StatusCode).ShouldBeEquivalentTo(new[] { 401, 403, 500 });
239+
}
240+
241+
[Fact]
242+
public void MergeAll_WithValidationAndHttpFailures_ReturnsAggregateProblem()
243+
{
244+
// Arrange
245+
var result1 = Result.FailValidation(("email", "Email is invalid"));
246+
var result2 = Result.Fail("Unauthorized", "Authentication required", HttpStatusCode.Unauthorized);
247+
248+
// Act
249+
var merged = AdvancedRailwayExtensions.MergeAll(result1, result2);
250+
251+
// Assert
252+
merged.IsFailed.ShouldBeTrue();
253+
merged.Problem.ShouldNotBeNull();
254+
merged.Problem!.StatusCode.ShouldBe(500);
255+
merged.Problem.TryGetExtension(ProblemConstants.ExtensionKeys.Errors, out Problem[]? aggregatedErrors).ShouldBeTrue();
256+
aggregatedErrors.ShouldNotBeNull();
257+
aggregatedErrors.Length.ShouldBe(2);
258+
aggregatedErrors.Select(problem => problem.StatusCode).ShouldContain(400);
259+
aggregatedErrors.Select(problem => problem.StatusCode).ShouldContain(401);
260+
}
261+
216262
[Fact]
217263
public void Combine_WithAllSuccessful_ReturnsAllValues()
218264
{
@@ -230,7 +276,7 @@ public void Combine_WithAllSuccessful_ReturnsAllValues()
230276
}
231277

232278
[Fact]
233-
public void CombineAll_WithMixedResults_CollectsAllErrors()
279+
public void CombineAll_WithValidationFailures_CollectsAllErrors()
234280
{
235281
// Arrange
236282
var result1 = Result<string>.Succeed("Success");
@@ -247,6 +293,29 @@ public void CombineAll_WithMixedResults_CollectsAllErrors()
247293
errors["error2"].ShouldContain("Second error");
248294
}
249295

296+
[Fact]
297+
public void CombineAll_WithMixedFailures_ReturnsAggregateProblem()
298+
{
299+
// Arrange
300+
var result1 = Result<string>.Succeed("Success");
301+
var result2 = Result<string>.Fail("Unauthorized", "Authentication required", HttpStatusCode.Unauthorized);
302+
var result3 = Result<string>.FailValidation(("email", "Email is invalid"));
303+
304+
// Act
305+
var combined = AdvancedRailwayExtensions.CombineAll(result1, result2, result3);
306+
307+
// Assert
308+
combined.IsFailed.ShouldBeTrue();
309+
combined.Problem.ShouldNotBeNull();
310+
combined.Problem!.Title.ShouldBe("Multiple errors occurred");
311+
combined.Problem.StatusCode.ShouldBe(500);
312+
combined.Problem.TryGetExtension(ProblemConstants.ExtensionKeys.Errors, out Problem[]? aggregatedErrors).ShouldBeTrue();
313+
aggregatedErrors.ShouldNotBeNull();
314+
aggregatedErrors.Length.ShouldBe(2);
315+
aggregatedErrors.Select(problem => problem.StatusCode).ShouldContain(401);
316+
aggregatedErrors.Select(problem => problem.StatusCode).ShouldContain(400);
317+
}
318+
250319
#endregion
251320

252321
#region Switch/Case Tests

ManagedCode.Communication.Tests/Results/CollectionResultTests.cs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,54 @@ public void ThrowIfFail_WithFailedResult_ShouldThrow()
258258
exception.Problem.Title.ShouldBe("Operation failed");
259259
}
260260

261+
[Fact]
262+
public void ToDisplayMessage_WithErrorCodeResolver_ShouldReturnResolvedMessage()
263+
{
264+
// Arrange
265+
var problem = Problem.Create("Validation Failed", "Raw detail", 400);
266+
problem.ErrorCode = "InvalidInput";
267+
var result = CollectionResult<string>.Fail(problem);
268+
269+
// Act
270+
var message = result.ToDisplayMessage(
271+
errorCodeResolver: code => code == "InvalidInput" ? "Friendly invalid input message" : null);
272+
273+
// Assert
274+
message.ShouldBe("Friendly invalid input message");
275+
}
276+
277+
[Fact]
278+
public void ToDisplayMessage_WithSuccessResult_ShouldReturnDefaultMessage()
279+
{
280+
// Arrange
281+
var result = CollectionResult<string>.Succeed(new[] { "item1" });
282+
283+
// Act
284+
var message = result.ToDisplayMessage(defaultMessage: "No problem");
285+
286+
// Assert
287+
message.ShouldBe("No problem");
288+
}
289+
290+
[Fact]
291+
public void ToDisplayMessage_WithTupleOverload_ShouldResolveRegistrationMessages()
292+
{
293+
// Arrange
294+
var problem = Problem.Create("Registration", "Unavailable", 503);
295+
problem.ErrorCode = "RegistrationUnavailable";
296+
var result = CollectionResult<string>.Fail(problem);
297+
298+
// Act
299+
var message = result.ToDisplayMessage(
300+
"Please try again later",
301+
("RegistrationUnavailable", "Registration is currently unavailable."),
302+
("RegistrationBlocked", "Registration is temporarily blocked."),
303+
("RegistrationInviteRequired", "Registration requires an invitation code."));
304+
305+
// Assert
306+
message.ShouldBe("Registration is currently unavailable.");
307+
}
308+
261309
[Fact]
262310
public void ImplicitOperator_ToBool_ShouldReturnIsSuccess()
263311
{

ManagedCode.Communication.Tests/Results/ProblemExceptionTests.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,4 +231,18 @@ public void Constructor_AllDataFieldsShouldUseNameof()
231231
exception.Data.Contains($"{nameof(Problem)}.{nameof(problem.Instance)}").ShouldBeTrue();
232232
exception.Data.Contains($"{nameof(Problem)}.{nameof(problem.ErrorCode)}").ShouldBeTrue();
233233
}
234+
235+
[Fact]
236+
public void Constructor_WithEmptyErrorCode_ShouldNotPopulateErrorCodeDataEntry()
237+
{
238+
// Arrange
239+
var problem = Problem.Create("Server Error", "Failed", 500);
240+
problem.ErrorCode = string.Empty;
241+
242+
// Act
243+
var exception = new ProblemException(problem);
244+
245+
// Assert
246+
exception.Data.Contains($"{nameof(Problem)}.{nameof(problem.ErrorCode)}").ShouldBeFalse();
247+
}
234248
}

ManagedCode.Communication.Tests/Results/ProblemTests.cs

Lines changed: 182 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Net;
4+
using System.Text.Json;
45
using Shouldly;
56
using ManagedCode.Communication.Constants;
67
using Xunit;
@@ -377,4 +378,184 @@ public void ImplicitOperator_ToProblemException_ShouldCreateException()
377378
exception.Title.ShouldBe("Not Found");
378379
exception.Detail.ShouldBe("Resource not found");
379380
}
380-
}
381+
382+
[Fact]
383+
public void ToDisplayMessage_WithResolver_ShouldUseMappedErrorCodeMessage()
384+
{
385+
// Arrange
386+
var problem = Problem.Create("Validation Failed", "Raw detail", 400);
387+
problem.ErrorCode = TestError.InvalidInput.ToString();
388+
389+
// Act
390+
var message = problem.ToDisplayMessage(
391+
errorCode => errorCode == TestError.InvalidInput.ToString() ? "Friendly validation message" : null);
392+
393+
// Assert
394+
message.ShouldBe("Friendly validation message");
395+
}
396+
397+
[Fact]
398+
public void ToDisplayMessage_WithoutResolverMatch_ShouldUseDetailThenTitleThenDefaultMessage()
399+
{
400+
// Arrange
401+
var problem = Problem.Create("Validation Failed", "Raw detail", 400);
402+
problem.ErrorCode = TestError.InvalidInput.ToString();
403+
404+
// Act
405+
var detailMessage = problem.ToDisplayMessage(errorCodeResolver: _ => null, defaultMessage: "Default");
406+
problem.Detail = "";
407+
var titleMessage = problem.ToDisplayMessage(errorCodeResolver: _ => null, defaultMessage: "Default");
408+
problem.Title = "";
409+
var defaultMessage = problem.ToDisplayMessage(errorCodeResolver: _ => null, defaultMessage: "Default");
410+
411+
// Assert
412+
detailMessage.ShouldBe("Raw detail");
413+
titleMessage.ShouldBe("Validation Failed");
414+
defaultMessage.ShouldBe("Default");
415+
}
416+
417+
[Fact]
418+
public void ToDisplayMessage_WhenProblemHasNoMessage_ShouldUseGenericError()
419+
{
420+
// Arrange
421+
var problem = Problem.Create("", "", 500);
422+
423+
// Act
424+
var message = problem.ToDisplayMessage();
425+
426+
// Assert
427+
message.ShouldBe(ProblemConstants.Messages.GenericError);
428+
}
429+
430+
[Fact]
431+
public void ToDisplayMessage_WithDictionaryOverload_ShouldResolveRegistrationMessages()
432+
{
433+
// Arrange
434+
var problem = Problem.Create("Registration", "Unavailable", 503);
435+
problem.ErrorCode = "RegistrationUnavailable";
436+
437+
var messages = new Dictionary<string, string>
438+
{
439+
["RegistrationUnavailable"] = "Registration is currently unavailable.",
440+
["RegistrationBlocked"] = "Registration is temporarily blocked.",
441+
["RegistrationInviteRequired"] = "Registration requires an invitation code."
442+
};
443+
444+
// Act
445+
var displayMessage = problem.ToDisplayMessage(messages, defaultMessage: "Please try again later");
446+
447+
// Assert
448+
displayMessage.ShouldBe("Registration is currently unavailable.");
449+
}
450+
451+
[Fact]
452+
public void ToDisplayMessage_WithEnumerableOverload_ShouldResolveRegistrationMessages()
453+
{
454+
// Arrange
455+
var problem = Problem.Create("Registration", "Unavailable", 503);
456+
problem.ErrorCode = "RegistrationBlocked";
457+
458+
var messages = new List<KeyValuePair<string, string>>
459+
{
460+
new("RegistrationUnavailable", "Registration is currently unavailable."),
461+
new("RegistrationBlocked", "Registration is temporarily blocked."),
462+
new("RegistrationInviteRequired", "Registration requires an invitation code.")
463+
};
464+
465+
// Act
466+
var displayMessage = problem.ToDisplayMessage(messages, defaultMessage: "Please try again later");
467+
468+
// Assert
469+
displayMessage.ShouldBe("Registration is temporarily blocked.");
470+
}
471+
472+
[Fact]
473+
public void ToDisplayMessage_WithTupleOverload_ShouldResolveRegistrationMessages()
474+
{
475+
// Arrange
476+
var problem = Problem.Create("Registration", "Unavailable", 503);
477+
problem.ErrorCode = "RegistrationInviteRequired";
478+
479+
// Act
480+
var displayMessage = problem.ToDisplayMessage(
481+
"Please try again later",
482+
("RegistrationUnavailable", "Registration is currently unavailable."),
483+
("RegistrationBlocked", "Registration is temporarily blocked."),
484+
("RegistrationInviteRequired", "Registration requires an invitation code."));
485+
486+
// Assert
487+
displayMessage.ShouldBe("Registration requires an invitation code.");
488+
}
489+
490+
[Fact]
491+
public void TryGetExtension_WithJsonElementValues_ShouldReturnTypedValues()
492+
{
493+
// Arrange
494+
const string payload = """
495+
{
496+
"type": "https://httpstatuses.io/400",
497+
"title": "Bad Request",
498+
"status": 400,
499+
"detail": "Validation failed",
500+
"errorCode": "InvalidInput",
501+
"retryAfter": 30
502+
}
503+
""";
504+
var problem = JsonSerializer.Deserialize<Problem>(payload)!;
505+
506+
// Act
507+
var hasErrorCode = problem.TryGetExtension(ProblemConstants.ExtensionKeys.ErrorCode, out string? errorCode);
508+
var hasRetryAfter = problem.TryGetExtension("retryAfter", out int retryAfter);
509+
var hasEnumCode = problem.TryGetExtension(ProblemConstants.ExtensionKeys.ErrorCode, out TestError enumCode);
510+
511+
// Assert
512+
hasErrorCode.ShouldBeTrue();
513+
errorCode.ShouldBe("InvalidInput");
514+
hasRetryAfter.ShouldBeTrue();
515+
retryAfter.ShouldBe(30);
516+
hasEnumCode.ShouldBeTrue();
517+
enumCode.ShouldBe(TestError.InvalidInput);
518+
}
519+
520+
[Fact]
521+
public void GetExtensionOrDefault_WhenKeyMissing_ShouldReturnProvidedDefault()
522+
{
523+
// Arrange
524+
var problem = Problem.Create("Error", "Missing extension", 500);
525+
526+
// Act
527+
var retryAfter = problem.GetExtensionOrDefault("retryAfter", 15);
528+
529+
// Assert
530+
retryAfter.ShouldBe(15);
531+
}
532+
533+
[Fact]
534+
public void TryGetExtension_WhenTypeMismatch_ShouldReturnFalse()
535+
{
536+
// Arrange
537+
var problem = Problem.Create("Error", "Type mismatch", 500);
538+
problem.Extensions["retryAfter"] = "not-a-number";
539+
540+
// Act
541+
var hasRetryAfter = problem.TryGetExtension("retryAfter", out int retryAfter);
542+
543+
// Assert
544+
hasRetryAfter.ShouldBeFalse();
545+
retryAfter.ShouldBe(0);
546+
}
547+
548+
[Fact]
549+
public void TryGetExtension_WhenKeyMissing_ShouldReturnFalse()
550+
{
551+
// Arrange
552+
var problem = Problem.Create("Error", "Missing", 500);
553+
554+
// Act
555+
var hasValue = problem.TryGetExtension("unknown", out string? value);
556+
557+
// Assert
558+
hasValue.ShouldBeFalse();
559+
value.ShouldBeNull();
560+
}
561+
}

0 commit comments

Comments
 (0)