Align furniture inventory validation (#52)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 66fa95dabd
commit 82293a322d
8 files changed
+712 -227

No files matched your search

@@ -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());
}
}
}
@@ -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;
}
}
@@ -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"),