Align furniture inventory validation (#52)
This commit is contained in:
1 parent
66fa95dabd
commit
82293a322d
8 files changed
+712
-227
No files matched your search
+45
-20
@@ -10,13 +10,11 @@ import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryWarning
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureLine;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
|
||||
import fr.nessar.lmnporganizer.rental.domain.lease.LeaseId;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@@ -47,34 +45,62 @@ Response get(@AuthenticationPrincipal OidcUser principal, @PathVariable String l
|
||||
Response replace(
|
||||
@AuthenticationPrincipal OidcUser principal,
|
||||
@PathVariable String leaseId,
|
||||
@Valid @RequestBody Request request) {
|
||||
@RequestBody Request request) {
|
||||
var landlordId = landlord.id(principal);
|
||||
var id = new LeaseId(leaseId);
|
||||
var privateAreas = inventories.privateAreas(landlordId, id);
|
||||
List<FieldError> errors = FurnitureInventoryRequestValidator.validate(request, privateAreas);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new InventoryValidationException(errors);
|
||||
}
|
||||
return Response.from(
|
||||
inventories.replace(
|
||||
landlordId,
|
||||
id,
|
||||
request.version(),
|
||||
request.lines().stream().map(LineRequest::toDomain).toList()),
|
||||
inventories.privateAreas(landlordId, id));
|
||||
privateAreas);
|
||||
}
|
||||
|
||||
record Request(@NotNull @Min(0) Long version, @NotNull @Valid List<LineRequest> lines) {}
|
||||
@ExceptionHandler(InventoryValidationException.class)
|
||||
ResponseEntity<ValidationError> invalidInventory(InventoryValidationException exception) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ValidationError("validation_failed", exception.errors()));
|
||||
}
|
||||
}
|
||||
|
||||
record Request(Long version, List<LineRequest> lines) {}
|
||||
|
||||
record LineRequest(
|
||||
@NotBlank String id,
|
||||
String id,
|
||||
FurnitureCategory category,
|
||||
FurniturePresence presence,
|
||||
@NotNull FurnitureEquipmentKind equipmentKind,
|
||||
@NotBlank String privateAreaId,
|
||||
@NotBlank String designation,
|
||||
@Min(0) int quantity,
|
||||
FurnitureEquipmentKind equipmentKind,
|
||||
String privateAreaId,
|
||||
String designation,
|
||||
Integer quantity,
|
||||
String description) {
|
||||
FurnitureLine toDomain() {
|
||||
FurnitureLine toDomain() {
|
||||
return new FurnitureLine(
|
||||
id, category, presence, equipmentKind, privateAreaId, designation, quantity, description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class InventoryValidationException extends RuntimeException {
|
||||
private final List<FieldError> errors;
|
||||
|
||||
InventoryValidationException(List<FieldError> errors) {
|
||||
this.errors = List.copyOf(errors);
|
||||
}
|
||||
|
||||
List<FieldError> errors() {
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
record ValidationError(String code, List<FieldError> errors) {}
|
||||
|
||||
record FieldError(String field, String code) {}
|
||||
|
||||
record LineResponse(
|
||||
String id,
|
||||
@@ -85,7 +111,7 @@ record LineResponse(
|
||||
String designation,
|
||||
int quantity,
|
||||
String description) {
|
||||
static LineResponse from(FurnitureLine line) {
|
||||
static LineResponse from(FurnitureLine line) {
|
||||
return new LineResponse(
|
||||
line.id(),
|
||||
line.category(),
|
||||
@@ -95,13 +121,13 @@ record LineResponse(
|
||||
line.designation(),
|
||||
line.quantity(),
|
||||
line.description());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record PrivateAreaResponse(String id, String label) {
|
||||
static PrivateAreaResponse from(PrivateArea area) {
|
||||
static PrivateAreaResponse from(PrivateArea area) {
|
||||
return new PrivateAreaResponse(area.id(), area.label());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record Response(
|
||||
@@ -115,7 +141,7 @@ record Response(
|
||||
boolean readyForLeaseDocument,
|
||||
boolean frozen,
|
||||
long version) {
|
||||
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
|
||||
static Response from(FurnitureInventory inventory, List<PrivateArea> privateAreas) {
|
||||
return new Response(
|
||||
inventory.id().value(),
|
||||
inventory.templateVersion(),
|
||||
@@ -127,6 +153,5 @@ record Response(
|
||||
inventory.isReadyForLeaseDocument(),
|
||||
inventory.frozen(),
|
||||
inventory.version());
|
||||
}
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import fr.nessar.lmnporganizer.rental.domain.apartment.PrivateArea;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureCategory;
|
||||
import fr.nessar.lmnporganizer.rental.domain.furniture.FurniturePresence;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
final class FurnitureInventoryRequestValidator {
|
||||
private FurnitureInventoryRequestValidator() {}
|
||||
|
||||
static List<FieldError> validate(Request request, List<PrivateArea> privateAreas) {
|
||||
List<FieldError> errors = new ArrayList<>();
|
||||
if (request == null) {
|
||||
errors.add(new FieldError("request", "not_null"));
|
||||
return errors;
|
||||
}
|
||||
if (request.version() == null) {
|
||||
errors.add(new FieldError("version", "not_null"));
|
||||
} else if (request.version() < 0) {
|
||||
errors.add(new FieldError("version", "min"));
|
||||
}
|
||||
if (request.lines() == null) {
|
||||
errors.add(new FieldError("lines", "not_null"));
|
||||
return errors;
|
||||
}
|
||||
|
||||
Set<String> privateAreaIds = new HashSet<>();
|
||||
for (PrivateArea privateArea : privateAreas) {
|
||||
privateAreaIds.add(privateArea.id());
|
||||
}
|
||||
Map<String, Integer> lineIds = new HashMap<>();
|
||||
Map<FurnitureCategory, Integer> categories = new HashMap<>();
|
||||
for (int index = 0; index < request.lines().size(); index++) {
|
||||
LineRequest line = request.lines().get(index);
|
||||
if (line == null) {
|
||||
errors.add(new FieldError(field(index, "line"), "not_null"));
|
||||
continue;
|
||||
}
|
||||
validate(errors, privateAreaIds, line, index);
|
||||
duplicate(errors, lineIds, trimmed(line.id()), index, "id");
|
||||
if (line.category() != null) {
|
||||
duplicate(errors, categories, line.category(), index, "category");
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static void validate(
|
||||
List<FieldError> errors, Set<String> privateAreaIds, LineRequest line, int index) {
|
||||
if (blank(line.id())) errors.add(new FieldError(field(index, "id"), "not_blank"));
|
||||
if (line.equipmentKind() == null) {
|
||||
errors.add(new FieldError(field(index, "equipmentKind"), "not_null"));
|
||||
}
|
||||
if (blank(line.privateAreaId())) {
|
||||
errors.add(new FieldError(field(index, "privateAreaId"), "not_blank"));
|
||||
} else if (!privateAreaIds.contains(line.privateAreaId().trim())) {
|
||||
errors.add(new FieldError(field(index, "privateAreaId"), "unknown_private_area"));
|
||||
}
|
||||
if (blank(line.designation()))
|
||||
errors.add(new FieldError(field(index, "designation"), "not_blank"));
|
||||
if (line.quantity() == null) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "not_null"));
|
||||
} else if (line.quantity() < 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "min"));
|
||||
}
|
||||
if (line.category() == null && line.presence() != null) {
|
||||
errors.add(new FieldError(field(index, "presence"), "invalid_presence"));
|
||||
}
|
||||
if (line.category() != null && line.equipmentKind() != null) {
|
||||
if (line.equipmentKind() != line.category().equipmentKind()) {
|
||||
errors.add(new FieldError(field(index, "equipmentKind"), "invalid_equipment_kind"));
|
||||
}
|
||||
if (line.presence() == null && line.quantity() != null && line.quantity() != 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "undeclared_quantity"));
|
||||
}
|
||||
}
|
||||
if (line.presence() == FurniturePresence.PRESENT
|
||||
&& line.quantity() != null
|
||||
&& line.quantity() == 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "present_quantity"));
|
||||
}
|
||||
if (line.presence() == FurniturePresence.ABSENT
|
||||
&& line.quantity() != null
|
||||
&& line.quantity() != 0) {
|
||||
errors.add(new FieldError(field(index, "quantity"), "absent_quantity"));
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> void duplicate(
|
||||
List<FieldError> errors, Map<T, Integer> values, T value, int index, String name) {
|
||||
if (value == null) return;
|
||||
Integer first = values.putIfAbsent(value, index);
|
||||
if (first != null) {
|
||||
errors.add(new FieldError(field(first, name), "duplicate"));
|
||||
errors.add(new FieldError(field(index, name), "duplicate"));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean blank(String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
private static String trimmed(String value) {
|
||||
return blank(value) ? null : value.trim();
|
||||
}
|
||||
|
||||
private static String field(int index, String name) {
|
||||
return "lines[" + index + "]." + name;
|
||||
}
|
||||
}
|
||||
+53
-1
@@ -1,5 +1,6 @@
|
||||
package fr.nessar.lmnporganizer.rental.adapter.in.web.lease;
|
||||
|
||||
import static org.hamcrest.Matchers.hasItems;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.verify;
|
||||
@@ -84,13 +85,21 @@ void getInitializesAndExposesUndeclaredLegalCategories() throws Exception {
|
||||
@Test
|
||||
void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
|
||||
when(inventories.replace(eq(LANDLORD_ID), eq(LEASE_ID), eq(0L), any())).thenReturn(inventory());
|
||||
when(inventories.privateAreas(LANDLORD_ID, LEASE_ID))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new PrivateArea(
|
||||
"area-1",
|
||||
PrivateArea.Family.PIECE_PRINCIPALE,
|
||||
PrivateArea.Subtype.LIVING_ROOM,
|
||||
"Séjour")));
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/leases/lease-1/inventory")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"equipmentKind\":\"NONE\",\"privateAreaId\":\"area-1\",\"designation\":\"Lampe\",\"quantity\":1}]}")
|
||||
"{\"version\":0,\"lines\":[{\"id\":\"line-1\",\"category\":\"BEDDING\",\"equipmentKind\":\"NONE\",\"privateAreaId\":\"area-1\",\"designation\":\"Literie\",\"quantity\":0}]}")
|
||||
.with(landlord())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isOk());
|
||||
@@ -106,6 +115,49 @@ void replaceUsesTheClientVersionAndRejectsRequestsWithoutIt() throws Exception {
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsEveryIndexedInventoryLineErrorTogether() throws Exception {
|
||||
when(inventories.privateAreas(LANDLORD_ID, LEASE_ID))
|
||||
.thenReturn(
|
||||
List.of(
|
||||
new PrivateArea(
|
||||
"area-1",
|
||||
PrivateArea.Family.PIECE_PRINCIPALE,
|
||||
PrivateArea.Subtype.LIVING_ROOM,
|
||||
"Séjour")));
|
||||
|
||||
mockMvc
|
||||
.perform(
|
||||
put("/api/leases/lease-1/inventory")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(
|
||||
"""
|
||||
{"version":-1,"lines":[
|
||||
{"id":"","category":"BEDDING","presence":"PRESENT","equipmentKind":"OTHER","privateAreaId":"","designation":"","quantity":0},
|
||||
{"id":"","category":"BEDDING","presence":"ABSENT","equipmentKind":"NONE","privateAreaId":"unknown","designation":"Lit","quantity":1},
|
||||
{"id":"free","presence":"PRESENT","equipmentKind":"NONE","privateAreaId":"area-1","designation":"Lampe","quantity":1}
|
||||
]}
|
||||
""")
|
||||
.with(landlord())
|
||||
.with(csrf()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value("validation_failed"))
|
||||
.andExpect(jsonPath("$.errors.length()").value(12))
|
||||
.andExpect(
|
||||
jsonPath("$.errors[*].field")
|
||||
.value(
|
||||
hasItems(
|
||||
"version",
|
||||
"lines[0].privateAreaId",
|
||||
"lines[0].designation",
|
||||
"lines[0].quantity",
|
||||
"lines[1].privateAreaId",
|
||||
"lines[1].quantity",
|
||||
"lines[2].presence",
|
||||
"lines[0].category",
|
||||
"lines[1].category")));
|
||||
}
|
||||
|
||||
private static FurnitureInventory inventory() {
|
||||
return FurnitureInventory.prefilled(
|
||||
new fr.nessar.lmnporganizer.rental.domain.furniture.FurnitureInventoryId("inventory-1"),
|
||||
|
||||
Reference in new issue
Block a user