From fb5ddbb3f6c381c2335524e74d84d113e2eda9cf Mon Sep 17 00:00:00 2001 From: rvando12 Date: Fri, 22 May 2026 15:42:47 +0200 Subject: [PATCH 01/12] feat(core): add delete endpoint feat(core): add DELETE endpoint - rebase feat(core): add fully delete and cleanup action --- docs/src/concepts/entities.md | 54 ++- docs/src/static/swagger.yaml | 64 ++-- .../domain/port/EntityRepositoryPort.java | 6 + .../domain/service/entity/EntityService.java | 109 ++++++ .../api/configuration/SwaggerDescription.java | 3 + .../api/controller/EntityController.java | 31 +- .../persistence/PostgresEntityAdapter.java | 13 + .../repository/JpaEntityRepository.java | 14 + .../service/entity/EntityServiceTest.java | 91 ++++- .../api/controller/EntityControllerTest.java | 324 ++++++++++++++++-- 10 files changed, 646 insertions(+), 63 deletions(-) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 55fd9e14..057b3156 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -287,8 +287,6 @@ When multiple related entities are allowed, list several identifiers: } ``` ---- - ## Retrieving Entities ### List Entities by Template @@ -404,6 +402,58 @@ curl -X PUT http://localhost:8084/api/v1/entities/web-service/my-web-service \ --- +## Deleting an Entity + +You delete an entity by sending a `DELETE` request to the entity resource path. + +### Delete Endpoint + +```text +DELETE /api/v1/entities/{templateIdentifier}/{entityIdentifier} +``` + +### Delete Example Request + +```bash +curl -X DELETE http://localhost:8084/api/v1/entities/web-service/my-web-service \ + -H "Content-Type: application/json" +``` + +### Delete Response Codes + +| Code | Description | +|-------|--------------------------------------------------------| +| `204` | Entity deleted successfully | +| `400` | Invalid identifiers or deletion not allowed | +| `401` | Missing or invalid authentication token | +| `403` | Insufficient permissions | +| `404` | Template or entity not found for the given identifier | +| `500` | Unexpected server error | + +### Delete Behavior + +When an entity is deleted, IDP-Core automatically manages relation cleanup to maintain referential integrity: + +- **Direct deletion**: The entity and all its relations are removed from the system +- **Cascade cleanup**: Any relations from other entities that reference the deleted entity are automatically removed from those parent entities +- **Data integrity**: The system ensures no dangling references remain after deletion + +#### Example: Cascade Cleanup + +If you have: + +- Entity A with a relation "depends-on" targeting Entity B +- Entity B with a relation "owns" targeting Entity C + +When you delete Entity B: + +1. Entity B is removed completely +2. Entity A's "depends-on" relation is automatically cleaned up (removing the reference to B) +3. Entity B's "owns" relation to Entity C is removed +4. Entity C remains untouched (no incoming relations) + +--- + ## Dynamic Schema Because templates are configured at runtime, the entity structure is **dynamic**: diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 3f8a3f56..734f7676 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -66,34 +66,12 @@ paths: '*/*': schema: $ref: '#/components/schemas/EntityTemplateDtoOut' - '400': - description: Invalid template data provided - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '401': - description: Unauthorized - Missing or invalid token - '403': - description: Insufficient rights '404': description: Template not found with the provided identifier content: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' - '409': - description: Template with this identifier already exists - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' - '500': - description: Unexpected server-side failure - content: - '*/*': - schema: - $ref: '#/components/schemas/ErrorResponse' delete: tags: - Entities Templates Management @@ -411,6 +389,48 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Entities Management + summary: Delete an existing entity + description: Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it. + operationId: deleteEntity + parameters: + - name: templateIdentifier + in: path + required: true + schema: + type: string + - name: entityIdentifier + in: path + required: true + schema: + type: string + responses: + '204': + description: Entity deleted successfully + '400': + description: Invalid identifiers provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights + '404': + description: Entity or template not found with the provided identifier + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' components: schemas: EntityTemplateUpdateDtoIn: diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 0718ea94..87585384 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -52,4 +52,10 @@ void deletePropertiesByTemplateIdentifierAndPropertyName(String templateIdentifi void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifier, Collection relationNames); + + List findEntitiesRelated(String targetIdentifier); + + void deleteByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier); + + } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index efb1de25..7f177f08 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -1,5 +1,6 @@ package com.decathlon.idp_core.domain.service.entity; +import java.util.ArrayList; import java.util.List; import jakarta.transaction.Transactional; @@ -17,7 +18,9 @@ import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.EntityQueryParserService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; @@ -159,4 +162,110 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, return entityRepository.save(entityToSave); } + /// Deletes an existing entity identified by template and entity identifiers. + /// + /// **Contract:** Validates the template exists, ensures the entity is not + /// referenced by any other entity (referential integrity), and then removes it. + /// + /// @param templateIdentifier template identifier from the request path + /// @param entityIdentifier entity identifier from the request path + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when target entity doesn't exist + @Transactional + public void deleteEntity(String templateIdentifier, String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + retrieveEntity(templateIdentifier, entityIdentifier); + removedRelationRelated(entityIdentifier); + entityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); + } + + /// Cleans up relations in parent entities that reference the deleted entity to maintain referential integrity. + /// + /// **Contract:** Finds all entities that have relations targeting the deleted entity and removes those relations + /// to prevent dangling references. This is necessary to maintain data integrity after an entity is deleted. + /// + /// @param entityIdentifier the identifier of the entity that was deleted, used to find and clean up related entities + private void removedRelationRelated(final String entityIdentifier) { + List parentEntities = entityRepository.findEntitiesRelated(entityIdentifier); + for (Entity parent : parentEntities) { + EntityTemplate parentTemplate = entityTemplateService.getEntityTemplateByIdentifier(parent.templateIdentifier()); + Entity cleanedParent = cleanUpRelations(parent, parentTemplate, entityIdentifier); + if (!cleanedParent.relations().equals(parent.relations())) { + entityRepository.save(cleanedParent); + } + } + } + + /// Removes the specified entity identifier from the relations of the parent entity, ensuring that required single-target relations are not left empty. + /// + /// **Contract:** Iterates through the relations of the parent entity, removing the target identifier from any relation + /// that contains it. If a relation becomes empty as a result, checks the relation definition to determine if it is required and single-target; + /// if so, the relation is not removed to avoid leaving the parent entity in an invalid state. + /// @param parent the entity whose relations are being cleaned up + /// @param parentTemplate the template of the parent entity, used to check relation definitions + /// @param entityIdentifierToRemove the identifier of the entity being deleted, which should be removed from the relations of the parent entity + /// @return a new Entity instance with updated relations, reflecting the removal of the specified entity identifier + /// **Note:** This method assumes that the parent entity and its template are valid and exist, as it is called in the context of cleaning up after a known entity deletion. + /// It focuses solely on relation cleanup and does not perform additional validations or checks beyond what is necessary for maintaining referential integrity. + private Entity cleanUpRelations(final Entity parent, final EntityTemplate parentTemplate, final String entityIdentifierToRemove) { + List updatedRelations = new ArrayList<>(); + List currentRelations = parent.relations() != null ? parent.relations() : List.of(); + + for (Relation relation : currentRelations) { + List currentTargets = relation.targetEntityIdentifiers() != null + ? relation.targetEntityIdentifiers() + : List.of(); + + if (!currentTargets.contains(entityIdentifierToRemove)) { + updatedRelations.add(relation); + continue; + } + List updatedTargets = currentTargets.stream() + .filter(target -> !entityIdentifierToRemove.equals(target)) + .toList(); + + if (updatedTargets.isEmpty()) { + RelationDefinition definition = getRelationDefinition(parentTemplate, relation.name()); + if (definition != null && definition.required() && !definition.toMany()) { + continue; + } + } + updatedRelations.add(new Relation( + relation.id(), + relation.name(), + relation.targetTemplateIdentifier(), + updatedTargets)); + } + + return new Entity( + parent.id(), + parent.templateIdentifier(), + parent.name(), + parent.identifier(), + parent.properties(), + updatedRelations); + } + + private RelationDefinition getRelationDefinition(final EntityTemplate template, final String relationName) { + if (template.relationsDefinitions() == null) { + return null; + } + return template.relationsDefinitions().stream() + .filter(definition -> relationName.equals(definition.name())) + .findFirst() + .orElse(null); + } + + /// Validates that an entity with the specified template and identifier exists, throwing an exception if not found. + /// + /// **Contract:** Checks the existence of the entity using the repository. If the entity is + /// not found, throws an EntityNotFoundException with the relevant template and entity identifiers for error reporting. + /// @param templateIdentifier the identifier of the template to which the entity belongs, used for lookup and error reporting + /// @param entityIdentifier the unique identifier of the entity within the template, used for lookup and error reporting + /// @throws EntityNotFoundException if no entity matching the template and entity identifiers is found in the repository, indicating that the entity does not exist + /// @return the Entity instance that matches the specified template and entity identifiers, if found. + private Entity retrieveEntity(final String templateIdentifier, final String entityIdentifier) { + return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index 4404f8ae..b09f2906 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -68,6 +68,8 @@ public class SwaggerDescription { public static final String ENDPOINT_POST_ENTITY_DESCRIPTION = "Create a new entity in the system with the provided information"; public static final String ENDPOINT_PUT_ENTITY_SUMMARY = "Update an existing entity"; public static final String ENDPOINT_PUT_ENTITY_DESCRIPTION = "Update an existing entity in the system with the provided information"; + public static final String ENDPOINT_DELETE_ENTITY_SUMMARY = "Delete an existing entity"; + public static final String ENDPOINT_DELETE_ENTITY_DESCRIPTION = "Delete an entity from the system using its template and entity identifiers. This operation removes the entity and automatically cleans up any relations from other entities that reference it."; /// API response description constants public static final String RESPONSE_TEMPLATES_PAGINATED_SUCCESS = "Paginated templates retrieved successfully"; @@ -88,6 +90,7 @@ public class SwaggerDescription { public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; public static final String RESPONSE_ENTITY_CREATED = "Entity created successfully"; public static final String RESPONSE_ENTITY_UPDATED = "Entity updated successfully"; + public static final String RESPONSE_ENTITY_DELETED = "Entity deleted successfully"; public static final String RESPONSE_INVALID_ENTITY_DATA = "Invalid entity data provided"; public static final String RESPONSE_UNEXPECTED_SERVER_ERROR = "Unexpected server-side failure"; public static final String RESPONSE_INSUFFICIENT_RIGHTS = "Insufficient rights"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index f46ee69a..3f2a82c3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -11,7 +11,10 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_SUMMARY; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_ENTITY_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NO_CONTENT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; @@ -22,6 +25,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITIES_PAGINATED_SUCCESS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_CREATED; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_DELETED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_UPDATED; @@ -34,6 +38,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_UNEXPECTED_SERVER_ERROR; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.UNAUTHORIZED_CODE; import static org.springframework.http.HttpStatus.CREATED; +import static org.springframework.http.HttpStatus.NO_CONTENT; import static org.springframework.http.HttpStatus.OK; import jakarta.validation.Valid; @@ -42,6 +47,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -224,9 +230,30 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi @NotBlank @PathVariable String entityIdentifier, @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { - Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, - templateIdentifier, entityIdentifier); + Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, templateIdentifier, entityIdentifier); Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); return entityDtoOutMapper.fromEntity(updatedEntity); } + + /// Deletes an existing entity identified by template and entity identifiers. + /// + /// **API contract:** Validates the template exists, ensures the entity is not referenced by any other entities, then deletes the entity. + /// Returns HTTP 204 on successful deletion, HTTP 404 if entity doesn't exist, HTTP 400 if deletion is not allowed due to existing references. + /// + /// @param templateIdentifier the template identifier of the entity to delete + /// @param entityIdentifier the identifier of the entity to delete + @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) + @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(NO_CONTENT) + public void deleteEntity( + @PathVariable String templateIdentifier, + @PathVariable String entityIdentifier) { + entityService.deleteEntity(templateIdentifier, entityIdentifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 4cc14a22..7da4c360 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -88,4 +88,17 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId jpaEntityRepository.deleteRelationsByTemplateIdentifierAndRelationName(templateIdentifier, relationNames); } + + + @Override + public List findEntitiesRelated(String targetIdentifier) { + return jpaEntityRepository.findEntitiesRelated(targetIdentifier).stream() + .map(mapper::toDomain) + .toList(); + } + + @Override + public void deleteByTemplateIdentifierAndIdentifier(final String templateIdentifier, final String entityIdentifier) { + jpaEntityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index 2289c9bb..e0c7d5da 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -61,4 +61,18 @@ WHERE r IN ( void deleteRelationsByTemplateIdentifierAndRelationName( @Param("templateIdentifier") String templateIdentifier, @Param("relationNames") Collection relationNames); + + @Query(""" + SELECT entity + FROM EntityJpaEntity entity + JOIN entity.relations relation + JOIN relation.targetEntityIdentifiers templateIdentifier + WHERE templateIdentifier = :targetIdentifier + """) + List findEntitiesRelated(@Param("targetIdentifier") String targetIdentifier); + + void deleteByTemplateIdentifierAndIdentifier( + @Param("templateIdentifier") String templateIdentifier, + @Param("entityIdentifier") String entityIdentifier); + } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 747c7605..effe6a17 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -31,8 +31,10 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; +import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity.EntitySummary; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; +import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; import com.decathlon.idp_core.domain.service.EntityQueryParserService; import com.decathlon.idp_core.domain.service.entity_template.EntityTemplateService; @@ -252,8 +254,89 @@ void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { verifyNoMoreInteractions(entityRepository); } - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), - List.of()); - } + @Test + @DisplayName("Should remove required one-to-one relation from parent before deleting target entity") + void shouldRemoveRequiredOneToOneRelationBeforeDeletingTargetEntity() { + var relationId = UUID.randomUUID(); + var parent = new Entity( + UUID.randomUUID(), + "application", + "Application A", + "app-a", + List.of(), + List.of(new Relation(relationId, "owner", "team", List.of("team-a")))); + + var parentTemplate = new EntityTemplate( + UUID.randomUUID(), + "application", + "Application", + "desc", + List.of(), + List.of(new RelationDefinition(UUID.randomUUID(), "owner", "team", true, false))); + + when(entityRepository.findEntitiesRelated("team-a")).thenReturn(List.of(parent)); + when(entityTemplateService.getEntityTemplateByIdentifier("application")).thenReturn(parentTemplate); + when(entityRepository.findByTemplateIdentifierAndIdentifier("team", "team-a")) + .thenReturn(Optional.of(new Entity(UUID.randomUUID(), "team", "team-a", "team-a", List.of(), List.of()))); + + entityService.deleteEntity("team", "team-a"); + + var expectedParentAfterCleanup = new Entity( + parent.id(), + parent.templateIdentifier(), + parent.name(), + parent.identifier(), + parent.properties(), + List.of()); + + InOrder inOrder = inOrder(entityTemplateValidationService, entityValidationService, entityRepository, entityTemplateService, entityRepository); + inOrder.verify(entityTemplateValidationService).validateTemplateExists("team"); + inOrder.verify(entityRepository).findEntitiesRelated("team-a"); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("application"); + inOrder.verify(entityRepository).save(expectedParentAfterCleanup); + inOrder.verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("team", "team-a"); + } + + @Test + @DisplayName("Should keep relation and remove only deleted target when relation still has targets") + void shouldKeepRelationAndRemoveOnlyDeletedTargetWhenRelationStillHasTargets() { + var relationId = UUID.randomUUID(); + var parent = new Entity( + UUID.randomUUID(), + "application", + "Application A", + "app-a", + List.of(), + List.of(new Relation(relationId, "dependencies", "service", List.of("catalog", "billing")))); + + var parentTemplate = new EntityTemplate( + UUID.randomUUID(), + "application", + "Application", + "desc", + List.of(), + List.of(new RelationDefinition(UUID.randomUUID(), "dependencies", "service", false, true))); + + when(entityRepository.findEntitiesRelated("catalog")).thenReturn(List.of(parent)); + when(entityTemplateService.getEntityTemplateByIdentifier("application")).thenReturn(parentTemplate); + when(entityRepository.findByTemplateIdentifierAndIdentifier("service", "catalog")) + .thenReturn(Optional.of(new Entity(UUID.randomUUID(), "service", "catalog", "catalog", List.of(), List.of()))); + entityService.deleteEntity("service", "catalog"); + + var expectedParentAfterCleanup = new Entity( + parent.id(), + parent.templateIdentifier(), + parent.name(), + parent.identifier(), + parent.properties(), + List.of(new Relation(relationId, "dependencies", "service", List.of("billing")))); + + verify(entityRepository).save(expectedParentAfterCleanup); + verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("service", "catalog"); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 428a036c..48887e94 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -2,6 +2,7 @@ import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -84,41 +85,43 @@ void getTemplates_paginated_401_without_user_token() throws Exception { .andExpect(status().isUnauthorized()); } - @Test - @DisplayName("Should return paginated entities with custom pagination") - @WithMockUser - void getEntities_paginated_200_custom() throws Exception { - - mockMvc - .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1").param("size", "5").param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(1)) - .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) - .andExpect(jsonPath("$.page.total_elements").value(6)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } + @Test + @DisplayName("Should return paginated entities with custom pagination") + @WithMockUser + void getEntities_paginated_200_custom() throws Exception { + + mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("page", "1") + .param("size", "5") + .param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); + } - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_invalid_pagination_200() throws Exception { - mockMvc - .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_invalid_pagination_200() throws Exception { + mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); + } } - } /// Tests for GET /api/v1/entities/{template-identifier}?q= endpoint @Nested @@ -759,4 +762,259 @@ void putEntity_403_without_csrf() throws Exception { } } + @Nested + @DisplayName("DELETE /api/v1/entities/{template-identifier}/{entity-identifier} - Delete Entity") + class DeleteEntityTests { + + private String createWebServicePayload(String name, String identifier) { + return """ + { + "name": "%s", + "identifier": "%s", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """.formatted(name, identifier); + } + + private String createWebServicePayloadWithRelation(String name, String identifier, + String targetEntityIdentifier) { + return """ + { + "name": "%s", + "identifier": "%s", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + }, + "relations": [ + { + "name": "%s", + "target_entity_identifiers": ["%s"] + } + ] + } + """.formatted(name, identifier, "database", targetEntityIdentifier); + } + + @Test + @WithMockUser() + @DisplayName("Should delete entity and return 204 No Content") + void deleteEntity_204_success() throws Exception { + var entityIdentifier = "delete-success-entity"; + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(createWebServicePayload("Delete Success Entity", entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("Should return 404 when deleting non-existent entity") + void deleteEntity_404_non_existent_entity() throws Exception { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-entity") + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("Should return 404 when template does not exist") + void deleteEntity_404_non_existent_template() throws Exception { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("Should return 401 when deleting without authentication") + void deleteEntity_401_without_user_token() throws Exception { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser + @DisplayName("Should return 403 when deleting without CSRF token") + void deleteEntity_403_without_csrf() throws Exception { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser() + @DisplayName("Should clean up relations from parent entities when entity is deleted") + void deleteEntity_204_with_relation_cleanup() throws Exception { + var targetEntityIdentifier = "relation-target-entity"; + var sourceEntityIdentifier = "relation-source-entity"; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(createWebServicePayload("Relation Target Entity", targetEntityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(createWebServicePayloadWithRelation( + "Relation Source Entity", + sourceEntityIdentifier, + targetEntityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, sourceEntityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("\"identifier\":\"" + targetEntityIdentifier + "\"")))); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, sourceEntityIdentifier) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser() + @DisplayName("Should handle deletion of entity with no incoming relations") + void deleteEntity_204_no_incoming_relations() throws Exception { + var entityIdentifier = "isolated-entity"; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(createWebServicePayload("Isolated Entity", entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("Should return 500 when entity identifier path segment is blank") + void deleteEntity_404_with_blank_entity_identifier() throws Exception { + mockMvc.perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", + TEMPLATE_IDENTIFIER, "") + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isInternalServerError()); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when template identifier path segment is blank") + void deleteEntity_404_with_blank_template_identifier() throws Exception { + mockMvc.perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", + "", ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser() + @DisplayName("Should delete entity with properties and relations") + void deleteEntity_204_with_properties_and_relations() throws Exception { + var entityIdentifier = "test-entity-with-relations"; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(createWebServicePayloadWithRelation( + "Test Entity With Relations", + entityIdentifier, + "database-service-1"))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("Should idempotently succeed on repeated deletion attempts (first succeeds, second fails)") + void deleteEntity_repeated_deletion() throws Exception { + var entityIdentifier = "idempotent-test"; + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .with(csrf()) + .content(createWebServicePayload("Idempotent Test", entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNoContent()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isNotFound()); + } + } } From db6136252d1483dd64f2bef0d51d607c45b65c2d Mon Sep 17 00:00:00 2001 From: rvando12 Date: Fri, 29 May 2026 14:30:17 +0200 Subject: [PATCH 02/12] feat(core): little format issue --- docs/src/concepts/entities.md | 2 ++ docs/src/static/swagger.yaml | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 057b3156..09e992ce 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -287,6 +287,8 @@ When multiple related entities are allowed, list several identifiers: } ``` +--- + ## Retrieving Entities ### List Entities by Template diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 734f7676..4152f97d 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -66,12 +66,34 @@ paths: '*/*': schema: $ref: '#/components/schemas/EntityTemplateDtoOut' + '400': + description: Invalid template data provided + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - Missing or invalid token + '403': + description: Insufficient rights '404': description: Template not found with the provided identifier content: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + '409': + description: Template with this identifier already exists + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Unexpected server-side failure + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' delete: tags: - Entities Templates Management From 504e046cb07f60d044fddcd0d7a2fb483e46d736 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Mon, 1 Jun 2026 09:07:58 +0200 Subject: [PATCH 03/12] feat(core): rebase with format --- .../domain/port/EntityRepositoryPort.java | 1 - .../domain/service/entity/EntityService.java | 206 +++---- .../api/controller/EntityController.java | 57 +- .../persistence/PostgresEntityAdapter.java | 22 +- .../service/entity/EntityServiceTest.java | 151 +++-- .../api/controller/EntityControllerTest.java | 528 +++++++++--------- 6 files changed, 469 insertions(+), 496 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 87585384..49fd2b52 100644 --- a/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java +++ b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java @@ -57,5 +57,4 @@ void deleteRelationsByTemplateIdentifierAndRelationName(String templateIdentifie void deleteByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier); - } diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 7f177f08..9272c2ac 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -162,110 +162,126 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, return entityRepository.save(entityToSave); } - /// Deletes an existing entity identified by template and entity identifiers. - /// - /// **Contract:** Validates the template exists, ensures the entity is not - /// referenced by any other entity (referential integrity), and then removes it. - /// - /// @param templateIdentifier template identifier from the request path - /// @param entityIdentifier entity identifier from the request path - /// @throws EntityTemplateNotFoundException when template doesn't exist - /// @throws EntityNotFoundException when target entity doesn't exist - @Transactional - public void deleteEntity(String templateIdentifier, String entityIdentifier) { - entityTemplateValidationService.validateTemplateExists(templateIdentifier); - retrieveEntity(templateIdentifier, entityIdentifier); - removedRelationRelated(entityIdentifier); - entityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); - } + /// Deletes an existing entity identified by template and entity identifiers. + /// + /// **Contract:** Validates the template exists, ensures the entity is not + /// referenced by any other entity (referential integrity), and then removes it. + /// + /// @param templateIdentifier template identifier from the request path + /// @param entityIdentifier entity identifier from the request path + /// @throws EntityTemplateNotFoundException when template doesn't exist + /// @throws EntityNotFoundException when target entity doesn't exist + @Transactional + public void deleteEntity(String templateIdentifier, String entityIdentifier) { + entityTemplateValidationService.validateTemplateExists(templateIdentifier); + retrieveEntity(templateIdentifier, entityIdentifier); + removedRelationRelated(entityIdentifier); + entityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); + } - /// Cleans up relations in parent entities that reference the deleted entity to maintain referential integrity. - /// - /// **Contract:** Finds all entities that have relations targeting the deleted entity and removes those relations - /// to prevent dangling references. This is necessary to maintain data integrity after an entity is deleted. - /// - /// @param entityIdentifier the identifier of the entity that was deleted, used to find and clean up related entities - private void removedRelationRelated(final String entityIdentifier) { - List parentEntities = entityRepository.findEntitiesRelated(entityIdentifier); - for (Entity parent : parentEntities) { - EntityTemplate parentTemplate = entityTemplateService.getEntityTemplateByIdentifier(parent.templateIdentifier()); - Entity cleanedParent = cleanUpRelations(parent, parentTemplate, entityIdentifier); - if (!cleanedParent.relations().equals(parent.relations())) { - entityRepository.save(cleanedParent); - } - } + /// Cleans up relations in parent entities that reference the deleted entity to + /// maintain referential integrity. + /// + /// **Contract:** Finds all entities that have relations targeting the deleted + /// entity and removes those relations + /// to prevent dangling references. This is necessary to maintain data integrity + /// after an entity is deleted. + /// + /// @param entityIdentifier the identifier of the entity that was deleted, used + /// to find and clean up related entities + private void removedRelationRelated(final String entityIdentifier) { + List parentEntities = entityRepository.findEntitiesRelated(entityIdentifier); + for (Entity parent : parentEntities) { + EntityTemplate parentTemplate = entityTemplateService + .getEntityTemplateByIdentifier(parent.templateIdentifier()); + Entity cleanedParent = cleanUpRelations(parent, parentTemplate, entityIdentifier); + if (!cleanedParent.relations().equals(parent.relations())) { + entityRepository.save(cleanedParent); + } } + } - /// Removes the specified entity identifier from the relations of the parent entity, ensuring that required single-target relations are not left empty. - /// - /// **Contract:** Iterates through the relations of the parent entity, removing the target identifier from any relation - /// that contains it. If a relation becomes empty as a result, checks the relation definition to determine if it is required and single-target; - /// if so, the relation is not removed to avoid leaving the parent entity in an invalid state. - /// @param parent the entity whose relations are being cleaned up - /// @param parentTemplate the template of the parent entity, used to check relation definitions - /// @param entityIdentifierToRemove the identifier of the entity being deleted, which should be removed from the relations of the parent entity - /// @return a new Entity instance with updated relations, reflecting the removal of the specified entity identifier - /// **Note:** This method assumes that the parent entity and its template are valid and exist, as it is called in the context of cleaning up after a known entity deletion. - /// It focuses solely on relation cleanup and does not perform additional validations or checks beyond what is necessary for maintaining referential integrity. - private Entity cleanUpRelations(final Entity parent, final EntityTemplate parentTemplate, final String entityIdentifierToRemove) { - List updatedRelations = new ArrayList<>(); - List currentRelations = parent.relations() != null ? parent.relations() : List.of(); + /// Removes the specified entity identifier from the relations of the parent + /// entity, ensuring that required single-target relations are not left empty. + /// + /// **Contract:** Iterates through the relations of the parent entity, removing + /// the target identifier from any relation + /// that contains it. If a relation becomes empty as a result, checks the + /// relation definition to determine if it is required and single-target; + /// if so, the relation is not removed to avoid leaving the parent entity in an + /// invalid state. + /// @param parent the entity whose relations are being cleaned up + /// @param parentTemplate the template of the parent entity, used to check + /// relation definitions + /// @param entityIdentifierToRemove the identifier of the entity being deleted, + /// which should be removed from the relations of the parent entity + /// @return a new Entity instance with updated relations, reflecting the removal + /// of the specified entity identifier + /// **Note:** This method assumes that the parent entity and its template are + /// valid and exist, as it is called in the context of cleaning up after a known + /// entity deletion. + /// It focuses solely on relation cleanup and does not perform additional + /// validations or checks beyond what is necessary for maintaining referential + /// integrity. + private Entity cleanUpRelations(final Entity parent, final EntityTemplate parentTemplate, + final String entityIdentifierToRemove) { + List updatedRelations = new ArrayList<>(); + List currentRelations = parent.relations() != null ? parent.relations() : List.of(); - for (Relation relation : currentRelations) { - List currentTargets = relation.targetEntityIdentifiers() != null - ? relation.targetEntityIdentifiers() - : List.of(); + for (Relation relation : currentRelations) { + List currentTargets = relation.targetEntityIdentifiers() != null + ? relation.targetEntityIdentifiers() + : List.of(); - if (!currentTargets.contains(entityIdentifierToRemove)) { - updatedRelations.add(relation); - continue; - } - List updatedTargets = currentTargets.stream() - .filter(target -> !entityIdentifierToRemove.equals(target)) - .toList(); + if (!currentTargets.contains(entityIdentifierToRemove)) { + updatedRelations.add(relation); + continue; + } + List updatedTargets = currentTargets.stream() + .filter(target -> !entityIdentifierToRemove.equals(target)).toList(); - if (updatedTargets.isEmpty()) { - RelationDefinition definition = getRelationDefinition(parentTemplate, relation.name()); - if (definition != null && definition.required() && !definition.toMany()) { - continue; - } - } - updatedRelations.add(new Relation( - relation.id(), - relation.name(), - relation.targetTemplateIdentifier(), - updatedTargets)); + if (updatedTargets.isEmpty()) { + RelationDefinition definition = getRelationDefinition(parentTemplate, relation.name()); + if (definition != null && definition.required() && !definition.toMany()) { + continue; } - - return new Entity( - parent.id(), - parent.templateIdentifier(), - parent.name(), - parent.identifier(), - parent.properties(), - updatedRelations); + } + updatedRelations.add(new Relation(relation.id(), relation.name(), + relation.targetTemplateIdentifier(), updatedTargets)); } - private RelationDefinition getRelationDefinition(final EntityTemplate template, final String relationName) { - if (template.relationsDefinitions() == null) { - return null; - } - return template.relationsDefinitions().stream() - .filter(definition -> relationName.equals(definition.name())) - .findFirst() - .orElse(null); - } + return new Entity(parent.id(), parent.templateIdentifier(), parent.name(), parent.identifier(), + parent.properties(), updatedRelations); + } - /// Validates that an entity with the specified template and identifier exists, throwing an exception if not found. - /// - /// **Contract:** Checks the existence of the entity using the repository. If the entity is - /// not found, throws an EntityNotFoundException with the relevant template and entity identifiers for error reporting. - /// @param templateIdentifier the identifier of the template to which the entity belongs, used for lookup and error reporting - /// @param entityIdentifier the unique identifier of the entity within the template, used for lookup and error reporting - /// @throws EntityNotFoundException if no entity matching the template and entity identifiers is found in the repository, indicating that the entity does not exist - /// @return the Entity instance that matches the specified template and entity identifiers, if found. - private Entity retrieveEntity(final String templateIdentifier, final String entityIdentifier) { - return entityRepository.findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + private RelationDefinition getRelationDefinition(final EntityTemplate template, + final String relationName) { + if (template.relationsDefinitions() == null) { + return null; } + return template.relationsDefinitions().stream() + .filter(definition -> relationName.equals(definition.name())).findFirst().orElse(null); + } + + /// Validates that an entity with the specified template and identifier exists, + /// throwing an exception if not found. + /// + /// **Contract:** Checks the existence of the entity using the repository. If + /// the entity is + /// not found, throws an EntityNotFoundException with the relevant template and + /// entity identifiers for error reporting. + /// @param templateIdentifier the identifier of the template to which the entity + /// belongs, used for lookup and error reporting + /// @param entityIdentifier the unique identifier of the entity within the + /// template, used for lookup and error reporting + /// @throws EntityNotFoundException if no entity matching the template and + /// entity identifiers is found in the repository, indicating that the entity + /// does not exist + /// @return the Entity instance that matches the specified template and entity + /// identifiers, if found. + private Entity retrieveEntity(final String templateIdentifier, final String entityIdentifier) { + return entityRepository + .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) + .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index 3f2a82c3..ab5d92ec 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -3,6 +3,8 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.BAD_REQUEST_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CONFLICT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.CREATED_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_ENTITY_DESCRIPTION; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_PAGINATED_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITIES_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_GET_ENTITY_BY_IDENTIFIER_DESCRIPTION; @@ -11,12 +13,10 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_POST_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_PUT_ENTITY_SUMMARY; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_ENTITY_DESCRIPTION; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.ENDPOINT_DELETE_ENTITY_SUMMARY; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_CODE; -import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NO_CONTENT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.INTERNAL_SERVER_ERROR_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NOT_FOUND_CODE; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.NO_CONTENT_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.OK_CODE; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_PAGE_DESCRIPTION; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.PARAM_QUERY_DESCRIPTION; @@ -47,8 +47,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -230,30 +230,35 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi @NotBlank @PathVariable String entityIdentifier, @Valid @RequestBody EntityUpdateDtoIn entityUpdateDtoIn) { - Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, templateIdentifier, entityIdentifier); + Entity entity = entityDtoInMapper.fromPutEntityDtoInToEntity(entityUpdateDtoIn, + templateIdentifier, entityIdentifier); Entity updatedEntity = entityService.updateEntity(templateIdentifier, entityIdentifier, entity); return entityDtoOutMapper.fromEntity(updatedEntity); } - /// Deletes an existing entity identified by template and entity identifiers. - /// - /// **API contract:** Validates the template exists, ensures the entity is not referenced by any other entities, then deletes the entity. - /// Returns HTTP 204 on successful deletion, HTTP 404 if entity doesn't exist, HTTP 400 if deletion is not allowed due to existing references. - /// - /// @param templateIdentifier the template identifier of the entity to delete - /// @param entityIdentifier the identifier of the entity to delete - @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) - @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) - @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) - @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) - @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = {@Content(schema = @Schema(implementation = ErrorResponse.class))}) - @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") - @ResponseStatus(NO_CONTENT) - public void deleteEntity( - @PathVariable String templateIdentifier, - @PathVariable String entityIdentifier) { - entityService.deleteEntity(templateIdentifier, entityIdentifier); - } + /// Deletes an existing entity identified by template and entity identifiers. + /// + /// **API contract:** Validates the template exists, ensures the entity is not + /// referenced by any other entities, then deletes the entity. + /// Returns HTTP 204 on successful deletion, HTTP 404 if entity doesn't exist, + /// HTTP 400 if deletion is not allowed due to existing references. + /// + /// @param templateIdentifier the template identifier of the entity to delete + /// @param entityIdentifier the identifier of the entity to delete + @Operation(summary = ENDPOINT_DELETE_ENTITY_SUMMARY, description = ENDPOINT_DELETE_ENTITY_DESCRIPTION) + @ApiResponse(responseCode = NO_CONTENT_CODE, description = RESPONSE_ENTITY_DELETED) + @ApiResponse(responseCode = BAD_REQUEST_CODE, description = RESPONSE_INVALID_ENTITY_DATA, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = UNAUTHORIZED_CODE, description = RESPONSE_UNAUTHORIZED, content = @Content) + @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) + @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") + @ResponseStatus(NO_CONTENT) + public void deleteEntity(@PathVariable String templateIdentifier, + @PathVariable String entityIdentifier) { + entityService.deleteEntity(templateIdentifier, entityIdentifier); + } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 7da4c360..5828c7f3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java @@ -89,16 +89,16 @@ public void deleteRelationsByTemplateIdentifierAndRelationName(String templateId relationNames); } + @Override + public List findEntitiesRelated(String targetIdentifier) { + return jpaEntityRepository.findEntitiesRelated(targetIdentifier).stream().map(mapper::toDomain) + .toList(); + } - @Override - public List findEntitiesRelated(String targetIdentifier) { - return jpaEntityRepository.findEntitiesRelated(targetIdentifier).stream() - .map(mapper::toDomain) - .toList(); - } - - @Override - public void deleteByTemplateIdentifierAndIdentifier(final String templateIdentifier, final String entityIdentifier) { - jpaEntityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); - } + @Override + public void deleteByTemplateIdentifierAndIdentifier(final String templateIdentifier, + final String entityIdentifier) { + jpaEntityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, + entityIdentifier); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index effe6a17..0b64c918 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -31,8 +31,8 @@ import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; import com.decathlon.idp_core.domain.model.entity.Entity; import com.decathlon.idp_core.domain.model.entity.EntityFilter; -import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity.EntitySummary; +import com.decathlon.idp_core.domain.model.entity.Relation; import com.decathlon.idp_core.domain.model.entity_template.EntityTemplate; import com.decathlon.idp_core.domain.model.entity_template.RelationDefinition; import com.decathlon.idp_core.domain.port.EntityRepositoryPort; @@ -254,89 +254,68 @@ void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { verifyNoMoreInteractions(entityRepository); } - @Test - @DisplayName("Should remove required one-to-one relation from parent before deleting target entity") - void shouldRemoveRequiredOneToOneRelationBeforeDeletingTargetEntity() { - var relationId = UUID.randomUUID(); - var parent = new Entity( - UUID.randomUUID(), - "application", - "Application A", - "app-a", - List.of(), - List.of(new Relation(relationId, "owner", "team", List.of("team-a")))); - - var parentTemplate = new EntityTemplate( - UUID.randomUUID(), - "application", - "Application", - "desc", - List.of(), - List.of(new RelationDefinition(UUID.randomUUID(), "owner", "team", true, false))); - - when(entityRepository.findEntitiesRelated("team-a")).thenReturn(List.of(parent)); - when(entityTemplateService.getEntityTemplateByIdentifier("application")).thenReturn(parentTemplate); - when(entityRepository.findByTemplateIdentifierAndIdentifier("team", "team-a")) - .thenReturn(Optional.of(new Entity(UUID.randomUUID(), "team", "team-a", "team-a", List.of(), List.of()))); - - entityService.deleteEntity("team", "team-a"); - - var expectedParentAfterCleanup = new Entity( - parent.id(), - parent.templateIdentifier(), - parent.name(), - parent.identifier(), - parent.properties(), - List.of()); - - InOrder inOrder = inOrder(entityTemplateValidationService, entityValidationService, entityRepository, entityTemplateService, entityRepository); - inOrder.verify(entityTemplateValidationService).validateTemplateExists("team"); - inOrder.verify(entityRepository).findEntitiesRelated("team-a"); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("application"); - inOrder.verify(entityRepository).save(expectedParentAfterCleanup); - inOrder.verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("team", "team-a"); - } - - @Test - @DisplayName("Should keep relation and remove only deleted target when relation still has targets") - void shouldKeepRelationAndRemoveOnlyDeletedTargetWhenRelationStillHasTargets() { - var relationId = UUID.randomUUID(); - var parent = new Entity( - UUID.randomUUID(), - "application", - "Application A", - "app-a", - List.of(), - List.of(new Relation(relationId, "dependencies", "service", List.of("catalog", "billing")))); - - var parentTemplate = new EntityTemplate( - UUID.randomUUID(), - "application", - "Application", - "desc", - List.of(), - List.of(new RelationDefinition(UUID.randomUUID(), "dependencies", "service", false, true))); - - when(entityRepository.findEntitiesRelated("catalog")).thenReturn(List.of(parent)); - when(entityTemplateService.getEntityTemplateByIdentifier("application")).thenReturn(parentTemplate); - when(entityRepository.findByTemplateIdentifierAndIdentifier("service", "catalog")) - .thenReturn(Optional.of(new Entity(UUID.randomUUID(), "service", "catalog", "catalog", List.of(), List.of()))); - entityService.deleteEntity("service", "catalog"); - - var expectedParentAfterCleanup = new Entity( - parent.id(), - parent.templateIdentifier(), - parent.name(), - parent.identifier(), - parent.properties(), - List.of(new Relation(relationId, "dependencies", "service", List.of("billing")))); - - verify(entityRepository).save(expectedParentAfterCleanup); - verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("service", "catalog"); - } - - private Entity entity(String templateIdentifier, String identifier, String name) { - return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), - List.of()); - } + @Test + @DisplayName("Should remove required one-to-one relation from parent before deleting target entity") + void shouldRemoveRequiredOneToOneRelationBeforeDeletingTargetEntity() { + var relationId = UUID.randomUUID(); + var parent = new Entity(UUID.randomUUID(), "application", "Application A", "app-a", List.of(), + List.of(new Relation(relationId, "owner", "team", List.of("team-a")))); + + var parentTemplate = new EntityTemplate(UUID.randomUUID(), "application", "Application", "desc", + List.of(), + List.of(new RelationDefinition(UUID.randomUUID(), "owner", "team", true, false))); + + when(entityRepository.findEntitiesRelated("team-a")).thenReturn(List.of(parent)); + when(entityTemplateService.getEntityTemplateByIdentifier("application")) + .thenReturn(parentTemplate); + when(entityRepository.findByTemplateIdentifierAndIdentifier("team", "team-a")) + .thenReturn(Optional + .of(new Entity(UUID.randomUUID(), "team", "team-a", "team-a", List.of(), List.of()))); + + entityService.deleteEntity("team", "team-a"); + + var expectedParentAfterCleanup = new Entity(parent.id(), parent.templateIdentifier(), + parent.name(), parent.identifier(), parent.properties(), List.of()); + + InOrder inOrder = inOrder(entityTemplateValidationService, entityValidationService, + entityRepository, entityTemplateService, entityRepository); + inOrder.verify(entityTemplateValidationService).validateTemplateExists("team"); + inOrder.verify(entityRepository).findEntitiesRelated("team-a"); + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("application"); + inOrder.verify(entityRepository).save(expectedParentAfterCleanup); + inOrder.verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("team", "team-a"); + } + + @Test + @DisplayName("Should keep relation and remove only deleted target when relation still has targets") + void shouldKeepRelationAndRemoveOnlyDeletedTargetWhenRelationStillHasTargets() { + var relationId = UUID.randomUUID(); + var parent = new Entity(UUID.randomUUID(), "application", "Application A", "app-a", List.of(), + List.of( + new Relation(relationId, "dependencies", "service", List.of("catalog", "billing")))); + + var parentTemplate = new EntityTemplate(UUID.randomUUID(), "application", "Application", "desc", + List.of(), + List.of(new RelationDefinition(UUID.randomUUID(), "dependencies", "service", false, true))); + + when(entityRepository.findEntitiesRelated("catalog")).thenReturn(List.of(parent)); + when(entityTemplateService.getEntityTemplateByIdentifier("application")) + .thenReturn(parentTemplate); + when(entityRepository.findByTemplateIdentifierAndIdentifier("service", "catalog")) + .thenReturn(Optional.of( + new Entity(UUID.randomUUID(), "service", "catalog", "catalog", List.of(), List.of()))); + entityService.deleteEntity("service", "catalog"); + + var expectedParentAfterCleanup = new Entity(parent.id(), parent.templateIdentifier(), + parent.name(), parent.identifier(), parent.properties(), + List.of(new Relation(relationId, "dependencies", "service", List.of("billing")))); + + verify(entityRepository).save(expectedParentAfterCleanup); + verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("service", "catalog"); + } + + private Entity entity(String templateIdentifier, String identifier, String name) { + return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), + List.of()); + } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 48887e94..526cc459 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -85,44 +85,42 @@ void getTemplates_paginated_401_without_user_token() throws Exception { .andExpect(status().isUnauthorized()); } - @Test - @DisplayName("Should return paginated entities with custom pagination") - @WithMockUser - void getEntities_paginated_200_custom() throws Exception { - - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") - .param("page", "1") - .param("size", "5") - .param("sort", "template_identifier,asc") - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content.length()").value(1)) - .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) - .andExpect(jsonPath("$.page.total_elements").value(6)) - .andExpect(jsonPath("$.page.total_pages").value(2)) - .andExpect(jsonPath("$.page.size").value(5)) - .andExpect(jsonPath("$.page.number").value(1)); - } + @Test + @DisplayName("Should return paginated entities with custom pagination") + @WithMockUser + void getEntities_paginated_200_custom() throws Exception { - @Test - @DisplayName("Should return paginated entities with default pagination") - @WithMockUser - void getEntities_invalid_pagination_200() throws Exception { - mockMvc.perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().contentType(APPLICATION_JSON)) - .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(2)) - .andExpect(jsonPath("$.page.total_elements").value(2)) - .andExpect(jsonPath("$.page.total_pages").value(1)) - .andExpect(jsonPath("$.page.size").value(20)) - .andExpect(jsonPath("$.page.number").value(0)) - .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); - } + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "monitoring-service") + .param("page", "1").param("size", "5").param("sort", "template_identifier,asc") + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content.length()").value(1)) + .andExpect(jsonPath("$.content[0].name").value("Monitoring Service 6")) + .andExpect(jsonPath("$.page.total_elements").value(6)) + .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.size").value(5)) + .andExpect(jsonPath("$.page.number").value(1)); } + @Test + @DisplayName("Should return paginated entities with default pagination") + @WithMockUser + void getEntities_invalid_pagination_200() throws Exception { + mockMvc + .perform(get(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) + .andExpect(jsonPath("$.content").isArray()) + .andExpect(jsonPath("$.content.length()").value(2)) + .andExpect(jsonPath("$.page.total_elements").value(2)) + .andExpect(jsonPath("$.page.total_pages").value(1)) + .andExpect(jsonPath("$.page.size").value(20)) + .andExpect(jsonPath("$.page.number").value(0)) + .andExpect(jsonPath("$.content[0].template_identifier").value(TEMPLATE_IDENTIFIER)); + } + } + /// Tests for GET /api/v1/entities/{template-identifier}?q= endpoint @Nested @DisplayName("GET /api/v1/entities/{template-identifier}?q= - Filter by q parameter") @@ -762,259 +760,235 @@ void putEntity_403_without_csrf() throws Exception { } } - @Nested - @DisplayName("DELETE /api/v1/entities/{template-identifier}/{entity-identifier} - Delete Entity") - class DeleteEntityTests { - - private String createWebServicePayload(String name, String identifier) { - return """ - { - "name": "%s", - "identifier": "%s", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "owner@example.com", - "port": "8080", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "https://catalog.example.com", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """.formatted(name, identifier); - } + @Nested + @DisplayName("DELETE /api/v1/entities/{template-identifier}/{entity-identifier} - Delete Entity") + class DeleteEntityTests { - private String createWebServicePayloadWithRelation(String name, String identifier, - String targetEntityIdentifier) { - return """ - { - "name": "%s", - "identifier": "%s", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "owner@example.com", - "port": "8080", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "https://catalog.example.com", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - }, - "relations": [ - { - "name": "%s", - "target_entity_identifiers": ["%s"] - } - ] - } - """.formatted(name, identifier, "database", targetEntityIdentifier); - } + private String createWebServicePayload(String name, String identifier) { + return """ + { + "name": "%s", + "identifier": "%s", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } + } + """.formatted(name, identifier); + } - @Test - @WithMockUser() - @DisplayName("Should delete entity and return 204 No Content") - void deleteEntity_204_success() throws Exception { - var entityIdentifier = "delete-success-entity"; - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(createWebServicePayload("Delete Success Entity", entityIdentifier))) - .andExpect(status().isCreated()); - - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + private String createWebServicePayloadWithRelation(String name, String identifier, + String targetEntityIdentifier) { + return """ + { + "name": "%s", + "identifier": "%s", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + }, + "relations": [ + { + "name": "%s", + "target_entity_identifiers": ["%s"] + } + ] + } + """.formatted(name, identifier, "database", targetEntityIdentifier); + } - @Test - @WithMockUser() - @DisplayName("Should return 404 when deleting non-existent entity") - void deleteEntity_404_non_existent_entity() throws Exception { - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-entity") - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNotFound()); - } + @Test + @WithMockUser() + @DisplayName("Should delete entity and return 204 No Content") + void deleteEntity_204_success() throws Exception { + var entityIdentifier = "delete-success-entity"; + mockMvc + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(createWebServicePayload("Delete Success Entity", entityIdentifier))) + .andExpect(status().isCreated()); - @Test - @WithMockUser() - @DisplayName("Should return 404 when template does not exist") - void deleteEntity_404_non_existent_template() throws Exception { - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNotFound()); - } + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); - @Test - @DisplayName("Should return 401 when deleting without authentication") - void deleteEntity_401_without_user_token() throws Exception { - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isUnauthorized()); - } + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); + } - @Test - @WithMockUser - @DisplayName("Should return 403 when deleting without CSRF token") - void deleteEntity_403_without_csrf() throws Exception { - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON)) - .andExpect(status().isForbidden()); - } + @Test + @WithMockUser() + @DisplayName("Should return 404 when deleting non-existent entity") + void deleteEntity_404_non_existent_entity() throws Exception { + mockMvc + .perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, "non-existent-entity") + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()); + } - @Test - @WithMockUser() - @DisplayName("Should clean up relations from parent entities when entity is deleted") - void deleteEntity_204_with_relation_cleanup() throws Exception { - var targetEntityIdentifier = "relation-target-entity"; - var sourceEntityIdentifier = "relation-source-entity"; - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(createWebServicePayload("Relation Target Entity", targetEntityIdentifier))) - .andExpect(status().isCreated()); - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(createWebServicePayloadWithRelation( - "Relation Source Entity", - sourceEntityIdentifier, - targetEntityIdentifier))) - .andExpect(status().isCreated()); - - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()); - - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, sourceEntityIdentifier) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(content().string(org.hamcrest.Matchers.not( - org.hamcrest.Matchers.containsString("\"identifier\":\"" + targetEntityIdentifier + "\"")))); - - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, sourceEntityIdentifier) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - } + @Test + @WithMockUser() + @DisplayName("Should return 404 when template does not exist") + void deleteEntity_404_non_existent_template() throws Exception { + mockMvc + .perform(delete(ENTITIES_BY_IDENTIFIER_PATH, "non-existent-template", ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNotFound()); + } - @Test - @WithMockUser() - @DisplayName("Should handle deletion of entity with no incoming relations") - void deleteEntity_204_no_incoming_relations() throws Exception { - var entityIdentifier = "isolated-entity"; - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(createWebServicePayload("Isolated Entity", entityIdentifier))) - .andExpect(status().isCreated()); - - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + @Test + @DisplayName("Should return 401 when deleting without authentication") + void deleteEntity_401_without_user_token() throws Exception { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isUnauthorized()); + } - @Test - @WithMockUser() - @DisplayName("Should return 500 when entity identifier path segment is blank") - void deleteEntity_404_with_blank_entity_identifier() throws Exception { - mockMvc.perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", - TEMPLATE_IDENTIFIER, "") - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isInternalServerError()); - } + @Test + @WithMockUser + @DisplayName("Should return 403 when deleting without CSRF token") + void deleteEntity_403_without_csrf() throws Exception { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) + .accept(APPLICATION_JSON)).andExpect(status().isForbidden()); + } - @Test - @WithMockUser() - @DisplayName("Should return 400 when template identifier path segment is blank") - void deleteEntity_404_with_blank_template_identifier() throws Exception { - mockMvc.perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", - "", ENTITY_IDENTIFIER) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isBadRequest()); - } + @Test + @WithMockUser() + @DisplayName("Should clean up relations from parent entities when entity is deleted") + void deleteEntity_204_with_relation_cleanup() throws Exception { + var targetEntityIdentifier = "relation-target-entity"; + var sourceEntityIdentifier = "relation-source-entity"; - @Test - @WithMockUser() - @DisplayName("Should delete entity with properties and relations") - void deleteEntity_204_with_properties_and_relations() throws Exception { - var entityIdentifier = "test-entity-with-relations"; - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(createWebServicePayloadWithRelation( - "Test Entity With Relations", - entityIdentifier, - "database-service-1"))) - .andExpect(status().isCreated()); - - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON)) - .andExpect(status().isNotFound()); - } + mockMvc + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content( + createWebServicePayload("Relation Target Entity", targetEntityIdentifier))) + .andExpect(status().isCreated()); - @Test - @WithMockUser() - @DisplayName("Should idempotently succeed on repeated deletion attempts (first succeeds, second fails)") - void deleteEntity_repeated_deletion() throws Exception { - var entityIdentifier = "idempotent-test"; - - mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON) - .accept(APPLICATION_JSON) - .with(csrf()) - .content(createWebServicePayload("Idempotent Test", entityIdentifier))) - .andExpect(status().isCreated()); - - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNoContent()); - - mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) - .accept(APPLICATION_JSON) - .with(csrf())) - .andExpect(status().isNotFound()); - } + mockMvc + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(createWebServicePayloadWithRelation("Relation Source Entity", + sourceEntityIdentifier, targetEntityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) + .accept(APPLICATION_JSON)).andExpect(status().isOk()); + + mockMvc + .perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNoContent()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); + + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, sourceEntityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.not(org.hamcrest.Matchers + .containsString("\"identifier\":\"" + targetEntityIdentifier + "\"")))); + + mockMvc + .perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, sourceEntityIdentifier) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isNoContent()); } + + @Test + @WithMockUser() + @DisplayName("Should handle deletion of entity with no incoming relations") + void deleteEntity_204_no_incoming_relations() throws Exception { + var entityIdentifier = "isolated-entity"; + + mockMvc + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(createWebServicePayload("Isolated Entity", entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("Should return 500 when entity identifier path segment is blank") + void deleteEntity_404_with_blank_entity_identifier() throws Exception { + mockMvc + .perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", + TEMPLATE_IDENTIFIER, "").accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isInternalServerError()); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when template identifier path segment is blank") + void deleteEntity_404_with_blank_template_identifier() throws Exception { + mockMvc + .perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", "", + ENTITY_IDENTIFIER).accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser() + @DisplayName("Should delete entity with properties and relations") + void deleteEntity_204_with_properties_and_relations() throws Exception { + var entityIdentifier = "test-entity-with-relations"; + + mockMvc.perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(createWebServicePayloadWithRelation("Test Entity With Relations", + entityIdentifier, "database-service-1"))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON)).andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("Should idempotently succeed on repeated deletion attempts (first succeeds, second fails)") + void deleteEntity_repeated_deletion() throws Exception { + var entityIdentifier = "idempotent-test"; + + mockMvc + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(createWebServicePayload("Idempotent Test", entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNotFound()); + } + } } From d0cb5aa077477bbb02e44b6a633b0bb8a368b08e Mon Sep 17 00:00:00 2001 From: rvando12 Date: Mon, 1 Jun 2026 09:37:10 +0200 Subject: [PATCH 04/12] feat(core): little refacto --- .../domain/service/entity/EntityService.java | 60 +++++++++++-------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 9272c2ac..3e3d030a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -103,9 +103,7 @@ public List getEntitiesSummariesByIdentifiers(List identi public Entity getEntityByTemplateIdentifierAndIdentifier(String templateIdentifier, String entityIdentifier) { entityTemplateValidationService.validateTemplateExists(templateIdentifier); - return entityRepository - .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + return retrieveEntity(templateIdentifier, entityIdentifier); } /// Creates and persists a new entity with business validation. @@ -151,9 +149,7 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, @Valid Entity entity) { EntityTemplate template = entityTemplateService .getEntityTemplateByIdentifier(templateIdentifier); - Entity existingEntity = entityRepository - .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) - .orElseThrow(() -> new EntityNotFoundException(templateIdentifier, entityIdentifier)); + Entity existingEntity = retrieveEntity(templateIdentifier, entityIdentifier); Entity entityToSave = new Entity(existingEntity.id(), templateIdentifier, entity.name(), entityIdentifier, entity.properties(), entity.relations()); @@ -227,31 +223,43 @@ private Entity cleanUpRelations(final Entity parent, final EntityTemplate parent final String entityIdentifierToRemove) { List updatedRelations = new ArrayList<>(); List currentRelations = parent.relations() != null ? parent.relations() : List.of(); + currentRelations + .forEach(relation -> retrieveAndCleanTargetEntitiesAgainstRelation(parentTemplate, + entityIdentifierToRemove, relation, updatedRelations)); + + return new Entity(parent.id(), parent.templateIdentifier(), parent.name(), parent.identifier(), + parent.properties(), updatedRelations); + } - for (Relation relation : currentRelations) { - List currentTargets = relation.targetEntityIdentifiers() != null - ? relation.targetEntityIdentifiers() - : List.of(); + private void retrieveAndCleanTargetEntitiesAgainstRelation(final EntityTemplate parentTemplate, + final String entityIdentifierToRemove, final Relation relation, + final List updatedRelations) { + List currentTargets = relation.targetEntityIdentifiers() != null + ? relation.targetEntityIdentifiers() + : List.of(); - if (!currentTargets.contains(entityIdentifierToRemove)) { - updatedRelations.add(relation); - continue; - } - List updatedTargets = currentTargets.stream() - .filter(target -> !entityIdentifierToRemove.equals(target)).toList(); + if (!currentTargets.contains(entityIdentifierToRemove)) { + updatedRelations.add(relation); + return; + } + + cleanLinkedRelation(parentTemplate, entityIdentifierToRemove, relation, currentTargets, + updatedRelations); + } - if (updatedTargets.isEmpty()) { - RelationDefinition definition = getRelationDefinition(parentTemplate, relation.name()); - if (definition != null && definition.required() && !definition.toMany()) { - continue; - } + private void cleanLinkedRelation(final EntityTemplate parentTemplate, + final String entityIdentifierToRemove, final Relation relation, + final List currentTargets, final List updatedRelations) { + List updatedTargets = currentTargets.stream() + .filter(target -> !entityIdentifierToRemove.equals(target)).toList(); + if (updatedTargets.isEmpty()) { + RelationDefinition definition = getRelationDefinition(parentTemplate, relation.name()); + if (definition != null && definition.required() && !definition.toMany()) { + return; } - updatedRelations.add(new Relation(relation.id(), relation.name(), - relation.targetTemplateIdentifier(), updatedTargets)); } - - return new Entity(parent.id(), parent.templateIdentifier(), parent.name(), parent.identifier(), - parent.properties(), updatedRelations); + updatedRelations.add(new Relation(relation.id(), relation.name(), + relation.targetTemplateIdentifier(), updatedTargets)); } private RelationDefinition getRelationDefinition(final EntityTemplate template, From 348d51a8eb621c4a04419f8f720e25f92924d573 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 3 Jun 2026 12:19:44 +0200 Subject: [PATCH 05/12] feat(core): copilot reviewn - add DC and tu --- docs/src/static/swagger.yaml | 6 + .../domain/constant/ValidationMessages.java | 1 + .../EntityDeletionBlockedException.java | 39 ++++++ .../domain/service/entity/EntityService.java | 119 +++++++++++++++--- .../api/configuration/SwaggerDescription.java | 1 + .../api/controller/EntityController.java | 12 +- .../api/handler/ApiExceptionHandler.java | 16 +++ .../repository/JpaEntityRepository.java | 4 +- .../service/entity/EntityServiceTest.java | 111 +++++++++++++--- .../api/controller/EntityControllerTest.java | 82 +++++++++++- .../EntityTemplateControllerTest.java | 8 +- .../db/test/R__1_Insert_test_data.sql | 28 ++++- .../test/R__2_Insert_entities_test_data.sql | 54 ++++++++ 13 files changed, 435 insertions(+), 46 deletions(-) create mode 100644 src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityDeletionBlockedException.java diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 4152f97d..1e26a52e 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -447,6 +447,12 @@ paths: '*/*': schema: $ref: '#/components/schemas/ErrorResponse' + '409': + description: Target entity has required relations + content: + '*/*': + schema: + $ref: '#/components/schemas/ErrorResponse' '500': description: Unexpected server-side failure content: diff --git a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java index 219b42b3..83d4a497 100644 --- a/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java +++ b/src/main/java/com/decathlon/idp_core/domain/constant/ValidationMessages.java @@ -66,6 +66,7 @@ public class ValidationMessages { public static final String ENTITY_NOT_FOUND = "Entity not found with template identifier %s and entity identifier '%s'"; public static final String ENTITY_ALREADY_EXISTS = "Entity with name '%s' already exists for template '%s'"; public static final String ENTITY_VALIDATION_FAILED = "Entity validation failed: "; + public static final String ENTITY_DELETION_BLOCKED = "Cannot delete entity '%s' (template: '%s') because it is referenced by required relations in the following entities: %s. Please update the relation definitions to make them optional or remove the required constraint before deleting this entity."; // Helper method to construct rules incompatibility message public static String rulesAreIncompatible(String rule1, String rule2) { diff --git a/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityDeletionBlockedException.java b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityDeletionBlockedException.java new file mode 100644 index 00000000..3e4cba3d --- /dev/null +++ b/src/main/java/com/decathlon/idp_core/domain/exception/entity/EntityDeletionBlockedException.java @@ -0,0 +1,39 @@ +package com.decathlon.idp_core.domain.exception.entity; + +import static com.decathlon.idp_core.domain.constant.ValidationMessages.ENTITY_DELETION_BLOCKED; + +import java.util.List; + +/// Domain exception for blocked Entity deletion due to required relations. +/// +/// **Business purpose:** Represents the business rule violation when attempting +/// to delete an Entity that is still referenced by required relations in other entities. +/// This enforces the business invariant that required relations must be satisfied before +/// entity deletion is allowed. +/// +/// **Why this exception exists:** +/// - Enforces referential integrity and required relation constraints +/// - Prevents dangling required references that would leave entities in invalid states +/// - Provides detailed context about which entities/relations block the deletion +/// - Guides users on how to resolve the blocking constraint (update template or remove required flag) +public class EntityDeletionBlockedException extends RuntimeException { + + /// Constructs a new exception with entity and blocking relation details. + /// + /// **Why this exists:** Provides comprehensive error message that includes: + /// - The entity being deleted (identifier and template) + /// - The list of entities that have required relations to it + /// - Actionable guidance on how to resolve the issue + /// + /// @param templateIdentifier the template identifier of the entity being + /// deleted + /// @param entityIdentifier the identifier of the entity being deleted + /// @param blockingEntities list of entity identifiers that have required + /// relations to the deleted entity + public EntityDeletionBlockedException(String templateIdentifier, String entityIdentifier, + List blockingEntities) { + super(String.format(ENTITY_DELETION_BLOCKED, entityIdentifier, templateIdentifier, + String.join(", ", blockingEntities))); + } + +} diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 3e3d030a..3373f473 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -2,6 +2,9 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -12,6 +15,7 @@ import org.springframework.validation.annotation.Validated; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; @@ -160,8 +164,10 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, /// Deletes an existing entity identified by template and entity identifiers. /// - /// **Contract:** Validates the template exists, ensures the entity is not - /// referenced by any other entity (referential integrity), and then removes it. + /// **Contract:** Validates the template and entity exist, cleans up relations + /// in parent + /// entities that reference the deleted entity (to prevent dangling references), + /// and then removes it. /// /// @param templateIdentifier template identifier from the request path /// @param entityIdentifier entity identifier from the request path @@ -170,8 +176,8 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, @Transactional public void deleteEntity(String templateIdentifier, String entityIdentifier) { entityTemplateValidationService.validateTemplateExists(templateIdentifier); - retrieveEntity(templateIdentifier, entityIdentifier); - removedRelationRelated(entityIdentifier); + Entity entityToDelete = retrieveEntity(templateIdentifier, entityIdentifier); + removedRelationRelated(entityToDelete); entityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); } @@ -179,24 +185,107 @@ public void deleteEntity(String templateIdentifier, String entityIdentifier) { /// maintain referential integrity. /// /// **Contract:** Finds all entities that have relations targeting the deleted - /// entity and removes those relations + /// entity. First validates that no required single-target relations would be + /// violated, + /// throwing EntityDeletionBlockedException if any are found. Then removes those + /// relations /// to prevent dangling references. This is necessary to maintain data integrity /// after an entity is deleted. /// - /// @param entityIdentifier the identifier of the entity that was deleted, used + /// @param entityToDelete the identifier of the entity that was deleted, used /// to find and clean up related entities - private void removedRelationRelated(final String entityIdentifier) { - List parentEntities = entityRepository.findEntitiesRelated(entityIdentifier); - for (Entity parent : parentEntities) { - EntityTemplate parentTemplate = entityTemplateService - .getEntityTemplateByIdentifier(parent.templateIdentifier()); - Entity cleanedParent = cleanUpRelations(parent, parentTemplate, entityIdentifier); + /// @throws EntityDeletionBlockedException if the entity is referenced by + /// required relations + private void removedRelationRelated(final Entity entityToDelete) { + List parentEntities = entityRepository.findEntitiesRelated(entityToDelete.identifier()); + + Map parentTemplates = parentEntities.stream() + .map(Entity::templateIdentifier).distinct() + .collect(Collectors.toMap(id -> id, entityTemplateService::getEntityTemplateByIdentifier)); + + hasBlockingEntities(entityToDelete, parentEntities, parentTemplates); + + parentEntities.forEach(parent -> { + EntityTemplate parentTemplate = parentTemplates.get(parent.templateIdentifier()); + Entity cleanedParent = cleanUpRelations(parent, parentTemplate, entityToDelete.identifier()); if (!cleanedParent.relations().equals(parent.relations())) { entityRepository.save(cleanedParent); } + }); + } + + /// Validates that no parent entities have required relations to the entity + /// being deleted. + /// + /// **Contract:** Iterates through the parent entities and their templates to + /// check if any + /// relations would be left empty and are defined as required and single-target. + /// If such blocking relations are found, + /// an EntityDeletionBlockedException is thrown with details about the blocking + /// entities and relations. + /// This ensures that entities with required dependencies cannot be deleted + /// without first addressing those dependencies, maintaining referential + /// integrity + /// and preventing invalid states in the domain model. + /// + /// @param entityToDelete the entity that is being deleted, used for context in + /// error messages + /// @param parentEntities the list of entities that have relations targeting the + /// entity being deleted + /// @param parentTemplates a map of template identifiers to their corresponding + /// EntityTemplate instances for the parent entities, used to check relation + /// definitions + /// @throws EntityDeletionBlockedException if any parent entities have required + /// relations to the entity being deleted, providing details about the blocking + /// entities and relations + private void hasBlockingEntities(final Entity entityToDelete, final List parentEntities, + final Map parentTemplates) { + List blockingEntities = parentEntities.stream().map(parent -> { + EntityTemplate parentTemplate = parentTemplates.get(parent.templateIdentifier()); + String blockingNames = getBlockingRelationNames(parent, parentTemplate, + entityToDelete.identifier()); + + if (!blockingNames.isEmpty()) { + return String.format("'%s' (template: '%s', relation(s): %s)", parent.identifier(), + parent.templateIdentifier(), blockingNames); + } + return null; + }).filter(Objects::nonNull).collect(Collectors.toList()); + + if (!blockingEntities.isEmpty()) { + throw new EntityDeletionBlockedException(entityToDelete.templateIdentifier(), + entityToDelete.identifier(), blockingEntities); } } + /// Gets the names of all relations that would block deletion. + /// + /// @param linkedEntity the entity whose relations are being checked + /// @param parentTemplate the template of the parent entity + /// @param entityIdentifierToRemove the identifier being removed + /// @return comma-separated list of blocking relation names + private String getBlockingRelationNames(final Entity linkedEntity, + final EntityTemplate parentTemplate, final String entityIdentifierToRemove) { + + return linkedEntity.relations().stream() + .filter(relation -> isBlockingRelation(relation, parentTemplate, entityIdentifierToRemove)) + .map(relation -> "'" + relation.name() + "'").collect(Collectors.joining(", ")); + } + + private boolean isBlockingRelation(final Relation relation, final EntityTemplate parentTemplate, + final String idToRemove) { + var targets = relation.targetEntityIdentifiers(); + if (targets == null || !targets.contains(idToRemove)) { + return false; + } + boolean becomesEmpty = targets.stream().allMatch(idToRemove::equals); + if (!becomesEmpty) { + return false; + } + var definition = getRelationDefinition(parentTemplate, relation.name()); + return definition != null && definition.required() && !definition.toMany(); + } + /// Removes the specified entity identifier from the relations of the parent /// entity, ensuring that required single-target relations are not left empty. /// @@ -206,6 +295,7 @@ private void removedRelationRelated(final String entityIdentifier) { /// relation definition to determine if it is required and single-target; /// if so, the relation is not removed to avoid leaving the parent entity in an /// invalid state. + /// /// @param parent the entity whose relations are being cleaned up /// @param parentTemplate the template of the parent entity, used to check /// relation definitions @@ -278,15 +368,16 @@ private RelationDefinition getRelationDefinition(final EntityTemplate template, /// the entity is /// not found, throws an EntityNotFoundException with the relevant template and /// entity identifiers for error reporting. + /// /// @param templateIdentifier the identifier of the template to which the entity /// belongs, used for lookup and error reporting /// @param entityIdentifier the unique identifier of the entity within the /// template, used for lookup and error reporting + /// @return the Entity instance that matches the specified template and entity + /// identifiers, if found. /// @throws EntityNotFoundException if no entity matching the template and /// entity identifiers is found in the repository, indicating that the entity /// does not exist - /// @return the Entity instance that matches the specified template and entity - /// identifiers, if found. private Entity retrieveEntity(final String templateIdentifier, final String entityIdentifier) { return entityRepository .findByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java index b09f2906..71978080 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/configuration/SwaggerDescription.java @@ -85,6 +85,7 @@ public class SwaggerDescription { public static final String RESPONSE_INVALID_PAGINATION = "Invalid pagination parameters"; public static final String RESPONSE_TEMPLATE_CONFLICT = "Template with this identifier already exists"; public static final String RESPONSE_ENTITY_CONFLICT = "Entity already exists in this template"; + public static final String RESPONSE_ENTITY_RELATION_CONFLICT = "Target entity has required relations"; public static final String RESPONSE_ENTITIES_PAGINATED_SUCCESS = "Paginated entities retrieved successfully"; public static final String RESPONSE_ENTITY_FOUND = "Entity found"; public static final String RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER = "Entity not found with the provided identifier"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java index ab5d92ec..d9910e92 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityController.java @@ -28,6 +28,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_DELETED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_FOUND; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER; +import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_RELATION_CONFLICT; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_ENTITY_UPDATED; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INSUFFICIENT_RIGHTS; import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.RESPONSE_INVALID_ENTITY_DATA; @@ -238,8 +239,9 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi /// Deletes an existing entity identified by template and entity identifiers. /// - /// **API contract:** Validates the template exists, ensures the entity is not - /// referenced by any other entities, then deletes the entity. + /// **API contract:** Validates the template and entity exist, cleans up + /// relations in parent + /// entities that reference the deleted entity, then deletes the entity. /// Returns HTTP 204 on successful deletion, HTTP 404 if entity doesn't exist, /// HTTP 400 if deletion is not allowed due to existing references. /// @@ -253,12 +255,14 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi @ApiResponse(responseCode = FORBIDDEN_CODE, description = RESPONSE_INSUFFICIENT_RIGHTS, content = @Content) @ApiResponse(responseCode = NOT_FOUND_CODE, description = RESPONSE_ENTITY_NOT_FOUND_IDENTIFIER, content = { @Content(schema = @Schema(implementation = ErrorResponse.class))}) + @ApiResponse(responseCode = CONFLICT_CODE, description = RESPONSE_ENTITY_RELATION_CONFLICT, content = { + @Content(schema = @Schema(implementation = ErrorResponse.class))}) @ApiResponse(responseCode = INTERNAL_SERVER_ERROR_CODE, description = RESPONSE_UNEXPECTED_SERVER_ERROR, content = { @Content(schema = @Schema(implementation = ErrorResponse.class))}) @DeleteMapping("/{templateIdentifier}/{entityIdentifier}") @ResponseStatus(NO_CONTENT) - public void deleteEntity(@PathVariable String templateIdentifier, - @PathVariable String entityIdentifier) { + public void deleteEntity(@NotBlank @PathVariable String templateIdentifier, + @NotBlank @PathVariable String entityIdentifier) { entityService.deleteEntity(templateIdentifier, entityIdentifier); } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 75929d6f..12a5ab9e 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -20,6 +20,7 @@ import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateAlreadyExistsException; @@ -300,6 +301,21 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun ErrorResponse errorResponse = new ErrorResponse(NOT_FOUND.name(), ex.getMessage()); return ResponseEntity.status(NOT_FOUND).body(errorResponse); } + + /// Handles domain exception when entity deletion is blocked by required + /// relations. + /// + /// **HTTP mapping:** Maps domain EntityDeletionBlockedException to HTTP 409 + /// status indicating business rule conflict where required relations prevent + /// deletion. + @ExceptionHandler(EntityDeletionBlockedException.class) + public ResponseEntity handleEntityDeletionBlockedException( + EntityDeletionBlockedException ex) { + log.warn("Entity deletion blocked: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + private String parseHttpMessageNotReadableError(String originalMessage) { if (originalMessage == null) { return "Invalid request body format"; diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java index e0c7d5da..200b1c3f 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/repository/JpaEntityRepository.java @@ -66,8 +66,8 @@ void deleteRelationsByTemplateIdentifierAndRelationName( SELECT entity FROM EntityJpaEntity entity JOIN entity.relations relation - JOIN relation.targetEntityIdentifiers templateIdentifier - WHERE templateIdentifier = :targetIdentifier + JOIN relation.targetEntityIdentifiers targetEntityIdentifier + WHERE targetEntityIdentifier = :targetIdentifier """) List findEntitiesRelated(@Param("targetIdentifier") String targetIdentifier); diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 0b64c918..2be356e0 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -3,8 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -26,6 +29,7 @@ import com.decathlon.idp_core.domain.constant.ValidationMessages; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; +import com.decathlon.idp_core.domain.exception.entity.EntityDeletionBlockedException; import com.decathlon.idp_core.domain.exception.entity.EntityNotFoundException; import com.decathlon.idp_core.domain.exception.entity.EntityValidationException; import com.decathlon.idp_core.domain.exception.entity_template.EntityTemplateNotFoundException; @@ -255,8 +259,73 @@ void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { } @Test - @DisplayName("Should remove required one-to-one relation from parent before deleting target entity") - void shouldRemoveRequiredOneToOneRelationBeforeDeletingTargetEntity() { + @DisplayName("Should successfully delete entity and remove its reference from parent entity relations") + void shouldDeleteEntityAndRemoveReferenceFromParent() { + // 1. Arrange + String targetTemplateId = "service"; + String targetEntityId = "payment-api"; + var targetEntity = new Entity(UUID.randomUUID(), targetTemplateId, "Payment API", + targetEntityId, List.of(), List.of()); + + String parentTemplateId = "application"; + String parentEntityId = "e-commerce-app"; + UUID relationId = UUID.randomUUID(); + + // Parent currently has a relation targeting BOTH "payment-api" and "auth-api" + var parentEntity = new Entity(UUID.randomUUID(), parentTemplateId, "E-Commerce App", + parentEntityId, List.of(), List.of(new Relation(relationId, "dependencies", + targetTemplateId, List.of(targetEntityId, "auth-api")))); + + // Parent template allows multiple dependencies (not a blocking required 1-to-1 + // relation) + var parentTemplate = new EntityTemplate(UUID.randomUUID(), parentTemplateId, "Application", + "desc", List.of(), List.of(new RelationDefinition(UUID.randomUUID(), "dependencies", + targetTemplateId, false, true))); + + // Expected parent after cleanup: the relation only contains "auth-api" + var expectedParentAfterCleanup = new Entity(parentEntity.id(), parentTemplateId, + parentEntity.name(), parentEntityId, List.of(), + List.of(new Relation(relationId, "dependencies", targetTemplateId, List.of("auth-api")))); + + // Setup Mocks + when(entityRepository.findByTemplateIdentifierAndIdentifier(targetTemplateId, targetEntityId)) + .thenReturn(Optional.of(targetEntity)); + when(entityRepository.findEntitiesRelated(targetEntityId)).thenReturn(List.of(parentEntity)); + when(entityTemplateService.getEntityTemplateByIdentifier(parentTemplateId)) + .thenReturn(parentTemplate); + + // 2. Act + entityService.deleteEntity(targetTemplateId, targetEntityId); + + // 3. Assert using InOrder to guarantee the exact sequence of business + // operations + InOrder inOrder = inOrder(entityTemplateValidationService, entityRepository, + entityTemplateService); + + // Validates target template exists + inOrder.verify(entityTemplateValidationService).validateTemplateExists(targetTemplateId); + + // Retrieves target entity to delete + inOrder.verify(entityRepository).findByTemplateIdentifierAndIdentifier(targetTemplateId, + targetEntityId); + + // Finds parent entities pointing to target entity + inOrder.verify(entityRepository).findEntitiesRelated(targetEntityId); + + // Retrieves parent template to evaluate relation constraints safely + inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier(parentTemplateId); + + // Saves the parent with the target ID cleanly removed from its relations + inOrder.verify(entityRepository).save(expectedParentAfterCleanup); + + // Finally deletes the target entity + inOrder.verify(entityRepository).deleteByTemplateIdentifierAndIdentifier(targetTemplateId, + targetEntityId); + } + + @Test + @DisplayName("Should throw EntityDeletionBlockedException when target entity is referenced by a required relation") + void shouldThrowExceptionWhenTargetEntityReferencedByRequiredRelation() { var relationId = UUID.randomUUID(); var parent = new Entity(UUID.randomUUID(), "application", "Application A", "app-a", List.of(), List.of(new Relation(relationId, "owner", "team", List.of("team-a")))); @@ -265,25 +334,24 @@ void shouldRemoveRequiredOneToOneRelationBeforeDeletingTargetEntity() { List.of(), List.of(new RelationDefinition(UUID.randomUUID(), "owner", "team", true, false))); - when(entityRepository.findEntitiesRelated("team-a")).thenReturn(List.of(parent)); - when(entityTemplateService.getEntityTemplateByIdentifier("application")) - .thenReturn(parentTemplate); when(entityRepository.findByTemplateIdentifierAndIdentifier("team", "team-a")) .thenReturn(Optional .of(new Entity(UUID.randomUUID(), "team", "team-a", "team-a", List.of(), List.of()))); - entityService.deleteEntity("team", "team-a"); + when(entityRepository.findEntitiesRelated("team-a")).thenReturn(List.of(parent)); - var expectedParentAfterCleanup = new Entity(parent.id(), parent.templateIdentifier(), - parent.name(), parent.identifier(), parent.properties(), List.of()); + // The fixed service will now ask for the PARENT's template + when(entityTemplateService.getEntityTemplateByIdentifier("application")) + .thenReturn(parentTemplate); - InOrder inOrder = inOrder(entityTemplateValidationService, entityValidationService, - entityRepository, entityTemplateService, entityRepository); - inOrder.verify(entityTemplateValidationService).validateTemplateExists("team"); - inOrder.verify(entityRepository).findEntitiesRelated("team-a"); - inOrder.verify(entityTemplateService).getEntityTemplateByIdentifier("application"); - inOrder.verify(entityRepository).save(expectedParentAfterCleanup); - inOrder.verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("team", "team-a"); + assertThrows(EntityDeletionBlockedException.class, + () -> entityService.deleteEntity("team", "team-a")); + + // Verify deletion is completely blocked + verify(entityTemplateValidationService).validateTemplateExists("team"); + verify(entityRepository, never()).save(any()); + verify(entityRepository, never()).deleteByTemplateIdentifierAndIdentifier(anyString(), + anyString()); } @Test @@ -298,18 +366,25 @@ void shouldKeepRelationAndRemoveOnlyDeletedTargetWhenRelationStillHasTargets() { List.of(), List.of(new RelationDefinition(UUID.randomUUID(), "dependencies", "service", false, true))); - when(entityRepository.findEntitiesRelated("catalog")).thenReturn(List.of(parent)); - when(entityTemplateService.getEntityTemplateByIdentifier("application")) - .thenReturn(parentTemplate); + // Fixed typo: "catalog" is a "service", so we mock and delete ("service", + // "catalog") when(entityRepository.findByTemplateIdentifierAndIdentifier("service", "catalog")) .thenReturn(Optional.of( new Entity(UUID.randomUUID(), "service", "catalog", "catalog", List.of(), List.of()))); + + // The entity we are deleting is "catalog" + when(entityRepository.findEntitiesRelated("catalog")).thenReturn(List.of(parent)); + when(entityTemplateService.getEntityTemplateByIdentifier("application")) + .thenReturn(parentTemplate); + + // Call service with correct parameters entityService.deleteEntity("service", "catalog"); var expectedParentAfterCleanup = new Entity(parent.id(), parent.templateIdentifier(), parent.name(), parent.identifier(), parent.properties(), List.of(new Relation(relationId, "dependencies", "service", List.of("billing")))); + verify(entityTemplateValidationService).validateTemplateExists("service"); verify(entityRepository).save(expectedParentAfterCleanup); verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("service", "catalog"); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 526cc459..198256cc 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -973,7 +973,7 @@ void deleteEntity_204_with_properties_and_relations() throws Exception { @Test @WithMockUser() - @DisplayName("Should idempotently succeed on repeated deletion attempts (first succeeds, second fails)") + @DisplayName("Should return 404 on repeated deletion attempts (first 204, second 404)") void deleteEntity_repeated_deletion() throws Exception { var entityIdentifier = "idempotent-test"; @@ -990,5 +990,85 @@ void deleteEntity_repeated_deletion() throws Exception { mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNotFound()); } + + @Test + @WithMockUser() + @DisplayName("Should return 409 Conflict when entity deletion is blocked by required relations") + void deleteEntity_409_blocked_by_required_relations() throws Exception { + var teamIdentifier = "test-team-required-delete"; + var supportIdentifier = "test-support-with-required-team-delete"; + + // Create team entity first + var teamPayload = """ + { + "name": "Test Team Required", + "identifier": "%s", + "properties": { + "applicationName": "test-app", + "ownerEmail": "owner@example.com", + "environment": "DEV" + } + } + """.formatted(teamIdentifier); + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "team") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(teamPayload)) + .andExpect(status().isCreated()); + + // Create support entity with required relation to team + var supportPayload = """ + { + "name": "Test Support With Required Team", + "identifier": "%s", + "properties": { + "applicationName": "support-app", + "ownerEmail": "support@example.com", + "environment": "PROD", + "version": "1.0.0", + "teamName": "support-team" + }, + "relations": [ + { + "name": "required_team", + "target_entity_identifiers": ["%s"] + } + ] + } + """.formatted(supportIdentifier, teamIdentifier); + + mockMvc.perform(MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, "support") + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(supportPayload)).andExpect(status().isCreated()); + + // Verify team entity was created + mockMvc + .perform( + get(ENTITIES_BY_IDENTIFIER_PATH, "team", teamIdentifier).accept(APPLICATION_JSON)) + .andExpect(status().isOk()); + + // Attempt to delete team entity (should fail with 409) + mockMvc + .perform(delete(ENTITIES_BY_IDENTIFIER_PATH, "team", teamIdentifier) + .accept(APPLICATION_JSON).with(csrf())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.error").value("CONFLICT")) + .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( + org.hamcrest.Matchers.containsString("Cannot delete entity"), + org.hamcrest.Matchers.containsString(teamIdentifier), + org.hamcrest.Matchers.containsString("required relations"), + org.hamcrest.Matchers.containsString(supportIdentifier)))); + + // Verify team entity still exists after failed deletion + mockMvc + .perform( + get(ENTITIES_BY_IDENTIFIER_PATH, "team", teamIdentifier).accept(APPLICATION_JSON)) + .andExpect(status().isOk()); + + // Clean up: delete support first, then team should be deletable + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, "support", supportIdentifier) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, "team", teamIdentifier) + .accept(APPLICATION_JSON).with(csrf())).andExpect(status().isNoContent()); + } } } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java index 1e09d7d9..d3c01815 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityTemplateControllerTest.java @@ -84,9 +84,9 @@ void getTemplates_paginated_200() throws Exception { mockMvc.perform(get("/api/v1/entity-templates").accept(APPLICATION_JSON)) .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content").isArray()) - .andExpect(jsonPath("$.content.length()").value(10)) + .andExpect(jsonPath("$.content.length()").value(12)) .andExpect(jsonPath("$.content[1].identifier").value("batch-job")) - .andExpect(jsonPath("$.page.total_elements").value(10)) + .andExpect(jsonPath("$.page.total_elements").value(12)) .andExpect(jsonPath("$.page.total_pages").value(1)) .andExpect(jsonPath("$.page.size").value(20)) .andExpect(jsonPath("$.page.number").value(0)); @@ -118,8 +118,8 @@ void getTemplates_paginated_200_custom() throws Exception { .andExpect(status().isOk()).andExpect(content().contentType(APPLICATION_JSON)) .andExpect(jsonPath("$.content.length()").value(5)) .andExpect(jsonPath("$.content[0].identifier").value("frontend-app")) - .andExpect(jsonPath("$.page.total_elements").value(10)) - .andExpect(jsonPath("$.page.total_pages").value(2)) + .andExpect(jsonPath("$.page.total_elements").value(12)) + .andExpect(jsonPath("$.page.total_pages").value(3)) .andExpect(jsonPath("$.page.size").value(5)) .andExpect(jsonPath("$.page.number").value(1)); } diff --git a/src/test/resources/db/test/R__1_Insert_test_data.sql b/src/test/resources/db/test/R__1_Insert_test_data.sql index bab7cb5a..b44a75c0 100644 --- a/src/test/resources/db/test/R__1_Insert_test_data.sql +++ b/src/test/resources/db/test/R__1_Insert_test_data.sql @@ -102,9 +102,12 @@ INSERT INTO relation_definition (id, name, target_template_identifier, required, -- External relationships ('550e8400-e29b-41d4-a716-446655440064', 'external_apis', 'external_api', false, true), -('550e8400-e29b-41d4-a716-446655440065', 'file_storage', 'storage', false, false); +('550e8400-e29b-41d4-a716-446655440065', 'file_storage', 'storage', false, false), --- Insert 10 diverse entity templates +-- Test relationship with required constraint +('550e8400-e29b-41d4-a716-446655440066', 'required_team', 'team', true, false); + +-- Insert diverse entity templates INSERT INTO entity_template (id, identifier, name, description) VALUES ('550e8400-e29b-41d4-a716-446655440070', 'web-service', 'Web Service', 'Template for REST API web services'), ('550e8400-e29b-41d4-a716-446655440071', 'microservice', 'Microservice', 'Template for microservice applications'), @@ -115,7 +118,9 @@ INSERT INTO entity_template (id, identifier, name, description) VALUES ('550e8400-e29b-41d4-a716-446655440076', 'api-gateway', 'API Gateway', 'Template for API gateway services'), ('550e8400-e29b-41d4-a716-446655440077', 'database-service', 'Database Service', 'Template for database services'), ('550e8400-e29b-41d4-a716-446655440078', 'cache-service', 'Cache Service', 'Template for caching services'), -('550e8400-e29b-41d4-a716-446655440079', 'monitoring-service', 'Monitoring Service', 'Template for monitoring and observability services'); +('550e8400-e29b-41d4-a716-446655440079', 'monitoring-service', 'Monitoring Service', 'Template for monitoring and observability services'), +('550e8400-e29b-41d4-a716-446655440080', 'team', 'Team', 'Template for team entities'), +('550e8400-e29b-41d4-a716-446655440081', 'support', 'Support', 'Template for support entities with required team relation'); -- Link web-service template (comprehensive web API) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES @@ -278,3 +283,20 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440053'), -- database ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440079', '550e8400-e29b-41d4-a716-446655440064'); -- external_apis + +-- Link team template +INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES +('550e8400-e29b-41d4-a716-446655440080', '550e8400-e29b-41d4-a716-446655440020'), -- applicationName +('550e8400-e29b-41d4-a716-446655440080', '550e8400-e29b-41d4-a716-446655440021'), -- ownerEmail +('550e8400-e29b-41d4-a716-446655440080', '550e8400-e29b-41d4-a716-446655440022'); -- environment + +-- Link support template (with required team relation for testing) +INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES +('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440020'), -- applicationName +('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440021'), -- ownerEmail +('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440022'), -- environment +('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440023'), -- version +('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440024'); -- teamName + +INSERT INTO entity_template_relations_definitions (entity_template_id, relations_definitions_id) VALUES +('550e8400-e29b-41d4-a716-446655440081', '550e8400-e29b-41d4-a716-446655440066'); -- required_team diff --git a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql index 4147732e..734f67b3 100644 --- a/src/test/resources/db/test/R__2_Insert_entities_test_data.sql +++ b/src/test/resources/db/test/R__2_Insert_entities_test_data.sql @@ -1,6 +1,9 @@ -- Insert sample entities into idp_core.entity INSERT INTO idp_core.entity (id, identifier, name, template_identifier) VALUES + ('550e8400-e29b-41d4-a716-446655440115', 'default-team', 'Default Team', 'team'), + ('550e8400-e29b-41d4-a716-446655440116', 'test-team-required', 'Test Team Required', 'team'), + ('550e8400-e29b-41d4-a716-446655440117', 'test-support-with-required-team', 'Test Support With Required Team', 'support'), ('550e8400-e29b-41d4-a716-446655440100', 'web-api-1', 'Web API 1', 'web-service'), ('550e8400-e29b-41d4-a716-446655440101', 'web-api-2', 'Web API 2', 'web-service'), ('550e8400-e29b-41d4-a716-446655440102', 'microservice-1', 'Microservice 1', 'microservice'), @@ -17,6 +20,46 @@ VALUES ('550e8400-e29b-41d4-a716-446655440113', 'monitoring-service-5', 'Monitoring Service 5', 'monitoring-service'), ('550e8400-e29b-41d4-a716-446655440114', 'monitoring-service-6', 'Monitoring Service 6', 'monitoring-service'); +-- Properties for default-team entity +INSERT INTO idp_core.property (id, name, value) +VALUES + ('aa000000-0000-0000-0000-000000000007', 'applicationName', 'test-app'), + ('aa000000-0000-0000-0000-000000000008', 'ownerEmail', 'team@example.com'), + ('aa000000-0000-0000-0000-000000000009', 'environment', 'DEV'); +INSERT INTO idp_core.entity_properties (entity_id, property_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440115', 'aa000000-0000-0000-0000-000000000007'), + ('550e8400-e29b-41d4-a716-446655440115', 'aa000000-0000-0000-0000-000000000008'), + ('550e8400-e29b-41d4-a716-446655440115', 'aa000000-0000-0000-0000-000000000009'); + +-- Properties for test-team-required entity +INSERT INTO idp_core.property (id, name, value) +VALUES + ('aa000000-0000-0000-0000-000000000010', 'applicationName', 'test-team-app'), + ('aa000000-0000-0000-0000-000000000011', 'ownerEmail', 'testteam@example.com'), + ('aa000000-0000-0000-0000-000000000012', 'environment', 'PROD'); +INSERT INTO idp_core.entity_properties (entity_id, property_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440116', 'aa000000-0000-0000-0000-000000000010'), + ('550e8400-e29b-41d4-a716-446655440116', 'aa000000-0000-0000-0000-000000000011'), + ('550e8400-e29b-41d4-a716-446655440116', 'aa000000-0000-0000-0000-000000000012'); + +-- Properties for test-support-with-required-team entity +INSERT INTO idp_core.property (id, name, value) +VALUES + ('aa000000-0000-0000-0000-000000000013', 'applicationName', 'support-app'), + ('aa000000-0000-0000-0000-000000000014', 'ownerEmail', 'support@example.com'), + ('aa000000-0000-0000-0000-000000000015', 'environment', 'PROD'), + ('aa000000-0000-0000-0000-000000000016', 'version', '1.0.0'), + ('aa000000-0000-0000-0000-000000000017', 'teamName', 'support-team'); +INSERT INTO idp_core.entity_properties (entity_id, property_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440117', 'aa000000-0000-0000-0000-000000000013'), + ('550e8400-e29b-41d4-a716-446655440117', 'aa000000-0000-0000-0000-000000000014'), + ('550e8400-e29b-41d4-a716-446655440117', 'aa000000-0000-0000-0000-000000000015'), + ('550e8400-e29b-41d4-a716-446655440117', 'aa000000-0000-0000-0000-000000000016'), + ('550e8400-e29b-41d4-a716-446655440117', 'aa000000-0000-0000-0000-000000000017'); + -- Properties for web-api-1 (language=JAVA, environment=PROD) INSERT INTO idp_core.property (id, name, value) VALUES @@ -73,3 +116,14 @@ VALUES INSERT INTO idp_core.entity_relations (entity_id, relation_id) VALUES ('550e8400-e29b-41d4-a716-446655440100', 'bb000000-0000-0000-0000-000000000003'); + +-- required_team relation for test-support-with-required-team targeting test-team-required +INSERT INTO idp_core.relation (id, name, target_template_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000006', 'required_team', 'team'); +INSERT INTO idp_core.relation_target_entities (relation_id, target_entity_identifier) +VALUES + ('bb000000-0000-0000-0000-000000000006', 'test-team-required'); +INSERT INTO idp_core.entity_relations (entity_id, relation_id) +VALUES + ('550e8400-e29b-41d4-a716-446655440117', 'bb000000-0000-0000-0000-000000000006'); From 8f99ff661a56d3db0609e23d283a83bde2751713 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 3 Jun 2026 12:33:06 +0200 Subject: [PATCH 06/12] feat(core): copilot reviewn - add DC and tu --- docs/src/static/swagger.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/static/swagger.yaml b/docs/src/static/swagger.yaml index 1e26a52e..c856af84 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -422,11 +422,13 @@ paths: in: path required: true schema: + minLength: 1 type: string - name: entityIdentifier in: path required: true schema: + minLength: 1 type: string responses: '204': From 1d526fdd40bc012b810927a5435eebc5d1c092bc Mon Sep 17 00:00:00 2001 From: rvando12 Date: Wed, 3 Jun 2026 13:50:30 +0200 Subject: [PATCH 07/12] feat(core): unsued import and sonar qube issue --- .../idp_core/domain/service/entity/EntityService.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 3373f473..b97fcbae 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -1,5 +1,7 @@ package com.decathlon.idp_core.domain.service.entity; +import static java.util.stream.Collectors.*; + import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -201,7 +203,7 @@ private void removedRelationRelated(final Entity entityToDelete) { Map parentTemplates = parentEntities.stream() .map(Entity::templateIdentifier).distinct() - .collect(Collectors.toMap(id -> id, entityTemplateService::getEntityTemplateByIdentifier)); + .collect(toMap(id -> id, entityTemplateService::getEntityTemplateByIdentifier)); hasBlockingEntities(entityToDelete, parentEntities, parentTemplates); @@ -250,7 +252,7 @@ private void hasBlockingEntities(final Entity entityToDelete, final List parent.templateIdentifier(), blockingNames); } return null; - }).filter(Objects::nonNull).collect(Collectors.toList()); + }).filter(Objects::nonNull).toList(); if (!blockingEntities.isEmpty()) { throw new EntityDeletionBlockedException(entityToDelete.templateIdentifier(), @@ -269,7 +271,7 @@ private String getBlockingRelationNames(final Entity linkedEntity, return linkedEntity.relations().stream() .filter(relation -> isBlockingRelation(relation, parentTemplate, entityIdentifierToRemove)) - .map(relation -> "'" + relation.name() + "'").collect(Collectors.joining(", ")); + .map(relation -> "'" + relation.name() + "'").collect(joining(", ")); } private boolean isBlockingRelation(final Relation relation, final EntityTemplate parentTemplate, From bdbcd3ece677084da42cc22a95223e240fa2b2cd Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 4 Jun 2026 08:56:31 +0200 Subject: [PATCH 08/12] feat(core): fix review - add nominal case --- .../domain/service/entity/EntityService.java | 4 +-- .../api/handler/ApiExceptionHandler.java | 29 +++++++++++++++++-- .../service/entity/EntityServiceTest.java | 25 ++++++++++++++++ .../api/controller/EntityControllerTest.java | 8 ++--- 4 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index b97fcbae..df799461 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -285,7 +285,7 @@ private boolean isBlockingRelation(final Relation relation, final EntityTemplate return false; } var definition = getRelationDefinition(parentTemplate, relation.name()); - return definition != null && definition.required() && !definition.toMany(); + return definition != null && definition.required(); } /// Removes the specified entity identifier from the relations of the parent @@ -346,7 +346,7 @@ private void cleanLinkedRelation(final EntityTemplate parentTemplate, .filter(target -> !entityIdentifierToRemove.equals(target)).toList(); if (updatedTargets.isEmpty()) { RelationDefinition definition = getRelationDefinition(parentTemplate, relation.name()); - if (definition != null && definition.required() && !definition.toMany()) { + if (definition != null && definition.required()) { return; } } diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 12a5ab9e..1fd664b3 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -14,9 +14,11 @@ import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingPathVariableException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.servlet.NoHandlerFoundException; import com.decathlon.idp_core.domain.exception.InvalidQueryDslException; import com.decathlon.idp_core.domain.exception.entity.EntityAlreadyExistsException; @@ -312,8 +314,31 @@ public ResponseEntity handleEntityNotFoundException(EntityNotFoun public ResponseEntity handleEntityDeletionBlockedException( EntityDeletionBlockedException ex) { log.warn("Entity deletion blocked: {}", ex.getMessage()); - ErrorResponse errorResponse = new ErrorResponse(HttpStatus.CONFLICT.name(), ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + return createErrorResponse(HttpStatus.CONFLICT, ex.getMessage()); + } + + /// Handles missing path variables in the request URL. + /// + /// **HTTP mapping:** Maps MissingPathVariableException to HTTP 400 + /// status indicating a malformed request URL from the client. + @ExceptionHandler(MissingPathVariableException.class) + public ResponseEntity handleMissingPathVariableException( + MissingPathVariableException ex) { + log.warn("Missing path variable: {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, + "Missing required path variable: " + ex.getVariableName()); + } + + /// Handles cases where a truncated URL matches no route, often caused by + /// missing trailing path variables. + /// + /// **HTTP mapping:** Maps NoHandlerFoundException to HTTP 400 + /// to align with missing identifier logic and pass integration tests. + @ExceptionHandler(NoHandlerFoundException.class) + public ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex) { + log.warn("No handler found (possible missing path variable): {}", ex.getMessage()); + return createErrorResponse(HttpStatus.BAD_REQUEST, + "Malformed request URL or missing path variable."); } private String parseHttpMessageNotReadableError(String originalMessage) { diff --git a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java index 2be356e0..6d49670a 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/entity/EntityServiceTest.java @@ -389,6 +389,31 @@ void shouldKeepRelationAndRemoveOnlyDeletedTargetWhenRelationStillHasTargets() { verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("service", "catalog"); } + @Test + @DisplayName("Should throw EntityDeletionBlockedException when target entity is the last one in a required toMany relation") + void shouldThrowExceptionWhenTargetIsLastInRequiredToManyRelation() { + var relationId = UUID.randomUUID(); + var parent = new Entity(UUID.randomUUID(), "cluster", "Production Cluster", "prod-cluster", + List.of(), List.of(new Relation(relationId, "nodes", "server", List.of("server-1")))); + + var parentTemplate = new EntityTemplate(UUID.randomUUID(), "cluster", "Cluster", "desc", + List.of(), + List.of(new RelationDefinition(UUID.randomUUID(), "nodes", "server", true, true))); + + when(entityRepository.findByTemplateIdentifierAndIdentifier("server", "server-1")) + .thenReturn(Optional.of( + new Entity(UUID.randomUUID(), "server", "server-1", "server-1", List.of(), List.of()))); + when(entityRepository.findEntitiesRelated("server-1")).thenReturn(List.of(parent)); + when(entityTemplateService.getEntityTemplateByIdentifier("cluster")).thenReturn(parentTemplate); + + assertThrows(EntityDeletionBlockedException.class, + () -> entityService.deleteEntity("server", "server-1")); + verify(entityTemplateValidationService).validateTemplateExists("server"); + verify(entityRepository, never()).save(any()); + verify(entityRepository, never()).deleteByTemplateIdentifierAndIdentifier(anyString(), + anyString()); + } + private Entity entity(String templateIdentifier, String identifier, String name) { return new Entity(UUID.randomUUID(), templateIdentifier, name, identifier, List.of(), List.of()); diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 198256cc..2c0ad779 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -933,18 +933,18 @@ void deleteEntity_204_no_incoming_relations() throws Exception { @Test @WithMockUser() - @DisplayName("Should return 500 when entity identifier path segment is blank") - void deleteEntity_404_with_blank_entity_identifier() throws Exception { + @DisplayName("Should return 400 when entity identifier path segment is blank") + void deleteEntity_400_with_blank_entity_identifier() throws Exception { mockMvc .perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", TEMPLATE_IDENTIFIER, "").accept(APPLICATION_JSON).with(csrf())) - .andExpect(status().isInternalServerError()); + .andExpect(status().isBadRequest()); } @Test @WithMockUser() @DisplayName("Should return 400 when template identifier path segment is blank") - void deleteEntity_404_with_blank_template_identifier() throws Exception { + void deleteEntity_400_with_blank_template_identifier() throws Exception { mockMvc .perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", "", ENTITY_IDENTIFIER).accept(APPLICATION_JSON).with(csrf())) From 3c1398032ae0430ac8cec92821a2d40cd62d4a9e Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 4 Jun 2026 09:04:11 +0200 Subject: [PATCH 09/12] feat(core): fix review - add nominal case --- .../decathlon/idp_core/domain/service/entity/EntityService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index df799461..3900866a 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -6,7 +6,6 @@ import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; import jakarta.transaction.Transactional; import jakarta.validation.Valid; From ad3d11df3c1a29491b2b1ad0d4c3aab2b0b9eb25 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 4 Jun 2026 16:06:29 +0200 Subject: [PATCH 10/12] feat(core): fix review - eve --- docs/src/concepts/entities.md | 1 + .../idp_core/domain/service/entity/EntityService.java | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 09e992ce..c08679fc 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -430,6 +430,7 @@ curl -X DELETE http://localhost:8084/api/v1/entities/web-service/my-web-service | `401` | Missing or invalid authentication token | | `403` | Insufficient permissions | | `404` | Template or entity not found for the given identifier | +| `409` | Target entity has required relations | | `500` | Unexpected server error | ### Delete Behavior diff --git a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java index 3900866a..c494dd8d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/entity/EntityService.java @@ -178,7 +178,7 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, public void deleteEntity(String templateIdentifier, String entityIdentifier) { entityTemplateValidationService.validateTemplateExists(templateIdentifier); Entity entityToDelete = retrieveEntity(templateIdentifier, entityIdentifier); - removedRelationRelated(entityToDelete); + removeRelatedRelations(entityToDelete); entityRepository.deleteByTemplateIdentifierAndIdentifier(templateIdentifier, entityIdentifier); } @@ -197,7 +197,7 @@ public void deleteEntity(String templateIdentifier, String entityIdentifier) { /// to find and clean up related entities /// @throws EntityDeletionBlockedException if the entity is referenced by /// required relations - private void removedRelationRelated(final Entity entityToDelete) { + private void removeRelatedRelations(final Entity entityToDelete) { List parentEntities = entityRepository.findEntitiesRelated(entityToDelete.identifier()); Map parentTemplates = parentEntities.stream() From 21788fc934171d758910c300a0deb9c575abd169 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 4 Jun 2026 16:36:29 +0200 Subject: [PATCH 11/12] feat(core): fix review 2 - eve --- .../adapters/api/handler/ApiExceptionHandler.java | 2 +- .../adapters/api/controller/EntityControllerTest.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index 1fd664b3..aba0fd98 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -337,7 +337,7 @@ public ResponseEntity handleMissingPathVariableException( @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex) { log.warn("No handler found (possible missing path variable): {}", ex.getMessage()); - return createErrorResponse(HttpStatus.BAD_REQUEST, + return createErrorResponse(NOT_FOUND, "Malformed request URL or missing path variable."); } diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 2c0ad779..5aa6827f 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -938,7 +938,7 @@ void deleteEntity_400_with_blank_entity_identifier() throws Exception { mockMvc .perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", TEMPLATE_IDENTIFIER, "").accept(APPLICATION_JSON).with(csrf())) - .andExpect(status().isBadRequest()); + .andExpect(status().isNotFound()); } @Test From 186a349c0cf11206d9be30a9fd79f70b2df95cc1 Mon Sep 17 00:00:00 2001 From: rvando12 Date: Thu, 4 Jun 2026 16:39:45 +0200 Subject: [PATCH 12/12] feat(core): fix review 2 - eve --- .../adapters/api/handler/ApiExceptionHandler.java | 3 +-- .../adapters/api/controller/EntityControllerTest.java | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java index aba0fd98..fded9d47 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/api/handler/ApiExceptionHandler.java @@ -337,8 +337,7 @@ public ResponseEntity handleMissingPathVariableException( @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex) { log.warn("No handler found (possible missing path variable): {}", ex.getMessage()); - return createErrorResponse(NOT_FOUND, - "Malformed request URL or missing path variable."); + return createErrorResponse(NOT_FOUND, "Malformed request URL or missing path variable."); } private String parseHttpMessageNotReadableError(String originalMessage) { diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java index 5aa6827f..4e9eb68f 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/api/controller/EntityControllerTest.java @@ -933,8 +933,8 @@ void deleteEntity_204_no_incoming_relations() throws Exception { @Test @WithMockUser() - @DisplayName("Should return 400 when entity identifier path segment is blank") - void deleteEntity_400_with_blank_entity_identifier() throws Exception { + @DisplayName("Should return 404 when entity identifier path segment is blank") + void deleteEntity_404_with_blank_entity_identifier() throws Exception { mockMvc .perform(delete("/api/v1/entities/{template-identifier}/{entity-identifier}", TEMPLATE_IDENTIFIER, "").accept(APPLICATION_JSON).with(csrf()))