Skip to content

Commit 48a81fe

Browse files
Improve validation and test coverage for orders API
Updated `EmptyStringToNullIntConverter` to throw specific `JsonException` messages for invalid input, ensuring clearer error handling. Enhanced `Program.cs` to return custom 422 responses for JSON binding and semantic validation errors, replacing `ValidationProblemDetails`. Expanded `ApiE2eTest` with new test cases for edge cases and invalid input scenarios, including empty, non-integer, and whitespace values for `quantity` and `country`. Added parameterized tests and improved assertions for existing tests to validate all fields and error messages.
1 parent a5cdbab commit 48a81fe

File tree

3 files changed

+141
-25
lines changed

3 files changed

+141
-25
lines changed

monolith/Core/Converters/EmptyStringToNullIntConverter.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ public class EmptyStringToNullIntConverter : JsonConverter<int?>
2323
return result;
2424
}
2525

26-
// Invalid format - return null to trigger validation
27-
return null;
26+
// Invalid format - throw exception with specific message
27+
throw new JsonException("Quantity must be an integer");
2828
}
2929

3030
if (reader.TokenType == JsonTokenType.Number)
@@ -37,7 +37,7 @@ public class EmptyStringToNullIntConverter : JsonConverter<int?>
3737
return null;
3838
}
3939

40-
throw new JsonException($"Unable to convert to int?");
40+
throw new JsonException("Quantity must be an integer");
4141
}
4242

4343
public override void Write(Utf8JsonWriter writer, int? value, JsonSerializerOptions options)

monolith/Program.cs

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,39 @@
1717
{
1818
options.InvalidModelStateResponseFactory = context =>
1919
{
20-
// Check if this is a JSON binding error by examining ModelState keys
21-
// JSON binding errors have keys that start with "$" (e.g., "$.quantity")
22-
var hasJsonBindingError = context.ModelState.Keys.Any(key => key.StartsWith("$"));
23-
24-
if (hasJsonBindingError)
20+
// Check for JSON binding errors (e.g., from JsonConverter throwing JsonException)
21+
var jsonBindingErrors = context.ModelState
22+
.Where(kvp => kvp.Key.StartsWith("$"))
23+
.SelectMany(kvp => kvp.Value?.Errors ?? new Microsoft.AspNetCore.Mvc.ModelBinding.ModelErrorCollection())
24+
.ToList();
25+
26+
if (jsonBindingErrors.Any())
2527
{
26-
// Return 400 for JSON binding errors (type conversion failures)
27-
var problemDetails = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(context.ModelState)
28-
{
29-
Status = 400
30-
};
31-
return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(problemDetails);
28+
// Extract the error message from the first JSON binding error
29+
var firstError = jsonBindingErrors.First();
30+
var errorMessage = firstError.Exception?.Message ?? firstError.ErrorMessage;
31+
32+
// Return 422 with the custom error message
33+
context.HttpContext.Response.StatusCode = 422;
34+
context.HttpContext.Response.ContentType = "application/json";
35+
var response = new { message = errorMessage };
36+
return new Microsoft.AspNetCore.Mvc.JsonResult(response);
3237
}
3338
else
3439
{
3540
// Return 422 for semantic validation errors (Required, Range, etc.)
36-
var problemDetails = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(context.ModelState)
37-
{
38-
Status = 422
39-
};
40-
return new Microsoft.AspNetCore.Mvc.UnprocessableEntityObjectResult(problemDetails);
41+
var errors = context.ModelState
42+
.Where(x => x.Value?.Errors.Count > 0)
43+
.SelectMany(x => x.Value!.Errors)
44+
.Select(x => x.ErrorMessage)
45+
.ToList();
46+
47+
var errorMessage = string.Join("; ", errors);
48+
49+
context.HttpContext.Response.StatusCode = 422;
50+
context.HttpContext.Response.ContentType = "application/json";
51+
var response = new { message = errorMessage };
52+
return new Microsoft.AspNetCore.Mvc.JsonResult(response);
4153
}
4254
};
4355
});

system-test/E2eTests/ApiE2eTest.cs

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,11 @@ public async Task PlaceOrder_WithValidRequest_ShouldReturnCreated()
4646
[Fact]
4747
public async Task GetOrder_WithExistingOrder_ShouldReturnOrder()
4848
{
49-
// Arrange
49+
// Arrange - Set up product in ERP first
5050
var baseSku = "AUTO-GO-200";
51-
var unitPrice = 125.50m;
52-
var quantity = 2;
53-
var country = "US";
51+
var unitPrice = 299.50m;
52+
var quantity = 3;
53+
var country = "DE";
5454

5555
var sku = await _erpApiClient.Products().CreateProductAsync(baseSku, unitPrice);
5656

@@ -59,14 +59,21 @@ public async Task GetOrder_WithExistingOrder_ShouldReturnOrder()
5959
// Act
6060
var httpResponse = await _shopApiClient.Orders().ViewOrderAsync(orderNumber);
6161

62-
// Assert
62+
// Assert - Assert all fields from GetOrderResponse
6363
var order = await _shopApiClient.Orders().AssertOrderViewedSuccessfullyAsync(httpResponse);
64+
Assert.NotNull(order.OrderNumber);
6465
Assert.Equal(orderNumber, order.OrderNumber);
6566
Assert.Equal(sku, order.Sku);
6667
Assert.Equal(quantity, order.Quantity);
6768
Assert.Equal(country, order.Country);
69+
70+
// Assert with concrete values based on known input
6871
Assert.Equal(unitPrice, order.UnitPrice);
69-
Assert.Equal(251.00m, order.OriginalPrice);
72+
73+
var expectedOriginalPrice = 898.50m;
74+
Assert.Equal(expectedOriginalPrice, order.OriginalPrice);
75+
76+
Assert.NotNull(order.Status);
7077
Assert.Equal(OrderStatus.PLACED, order.Status);
7178
}
7279

@@ -157,6 +164,23 @@ public async Task PlaceOrder_WithInvalidRequest_ShouldReturnBadRequest(
157164
Assert.Contains(expectedError, errorMessage);
158165
}
159166

167+
[Fact]
168+
public async Task PlaceOrder_WithNonExistentSku_ShouldReturnUnprocessableEntity()
169+
{
170+
// Arrange
171+
var sku = "NON-EXISTENT-SKU-12345";
172+
var quantity = "5";
173+
var country = "US";
174+
175+
// Act
176+
var httpResponse = await _shopApiClient.Orders().PlaceOrderAsync(sku, quantity, country);
177+
178+
// Assert
179+
_shopApiClient.Orders().AssertOrderPlacementFailed(httpResponse);
180+
var errorMessage = await _shopApiClient.Orders().GetErrorMessageAsync(httpResponse);
181+
Assert.Contains("Product does not exist for SKU", errorMessage);
182+
}
183+
160184
[Theory]
161185
[MemberData(nameof(GetInvalidQuantityTestData))]
162186
public async Task PlaceOrder_WithInvalidQuantityType_ShouldReturnBadRequest(
@@ -180,6 +204,86 @@ public static IEnumerable<object[]> GetInvalidQuantityTestData()
180204
yield return new object[] { null!, "Quantity must not be empty" };
181205
}
182206

207+
[Theory]
208+
[MemberData(nameof(GetEmptyQuantityTestData))]
209+
public async Task PlaceOrder_WithEmptyQuantity_ShouldReturnBadRequest(
210+
string? quantityValue, string expectedError)
211+
{
212+
// Arrange - Set up product in ERP first
213+
var baseSku = "AUTO-EQ-500";
214+
var unitPrice = 150.00m;
215+
216+
var sku = await _erpApiClient.Products().CreateProductAsync(baseSku, unitPrice);
217+
218+
// Act
219+
var httpResponse = await _shopApiClient.Orders().PlaceOrderAsync(sku, quantityValue ?? "", "US");
220+
221+
// Assert
222+
_shopApiClient.Orders().AssertOrderPlacementFailed(httpResponse);
223+
var errorMessage = await _shopApiClient.Orders().GetErrorMessageAsync(httpResponse);
224+
Assert.Contains(expectedError, errorMessage);
225+
}
226+
227+
public static IEnumerable<object[]> GetEmptyQuantityTestData()
228+
{
229+
yield return new object?[] { null, "Quantity must not be empty" };
230+
yield return new object[] { "", "Quantity must not be empty" };
231+
yield return new object[] { " ", "Quantity must not be empty" };
232+
}
233+
234+
[Theory]
235+
[MemberData(nameof(GetNonIntegerQuantityTestData))]
236+
public async Task PlaceOrder_WithNonIntegerQuantity_ShouldReturnBadRequest(
237+
string quantityValue, string expectedError)
238+
{
239+
// Arrange - Set up product in ERP first
240+
var baseSku = "AUTO-NIQ-600";
241+
var unitPrice = 175.00m;
242+
243+
var sku = await _erpApiClient.Products().CreateProductAsync(baseSku, unitPrice);
244+
245+
// Act
246+
var httpResponse = await _shopApiClient.Orders().PlaceOrderAsync(sku, quantityValue, "US");
247+
248+
// Assert
249+
_shopApiClient.Orders().AssertOrderPlacementFailed(httpResponse);
250+
var errorMessage = await _shopApiClient.Orders().GetErrorMessageAsync(httpResponse);
251+
Assert.Contains(expectedError, errorMessage);
252+
}
253+
254+
public static IEnumerable<object[]> GetNonIntegerQuantityTestData()
255+
{
256+
yield return new object[] { "3.5", "Quantity must be an integer" };
257+
yield return new object[] { "lala", "Quantity must be an integer" };
258+
}
259+
260+
[Theory]
261+
[MemberData(nameof(GetEmptyCountryTestData))]
262+
public async Task PlaceOrder_WithEmptyCountry_ShouldReturnBadRequest(
263+
string? countryValue, string expectedError)
264+
{
265+
// Arrange - Set up product in ERP first and get unique SKU
266+
var baseSku = "AUTO-EC-700";
267+
var unitPrice = 225.00m;
268+
269+
var sku = await _erpApiClient.Products().CreateProductAsync(baseSku, unitPrice);
270+
271+
// Act
272+
var httpResponse = await _shopApiClient.Orders().PlaceOrderAsync(sku, "5", countryValue ?? "");
273+
274+
// Assert
275+
_shopApiClient.Orders().AssertOrderPlacementFailed(httpResponse);
276+
var errorMessage = await _shopApiClient.Orders().GetErrorMessageAsync(httpResponse);
277+
Assert.Contains(expectedError, errorMessage);
278+
}
279+
280+
public static IEnumerable<object[]> GetEmptyCountryTestData()
281+
{
282+
yield return new object?[] { null, "Country must not be empty" };
283+
yield return new object[] { "", "Country must not be empty" };
284+
yield return new object[] { " ", "Country must not be empty" };
285+
}
286+
183287
[Fact]
184288
public async Task PlaceOrder_WithMissingFields_ShouldReturnBadRequest()
185289
{

0 commit comments

Comments
 (0)