diff --git a/docs/src/concepts/entities.md b/docs/src/concepts/entities.md index 55fd9e14..c08679fc 100644 --- a/docs/src/concepts/entities.md +++ b/docs/src/concepts/entities.md @@ -404,6 +404,59 @@ 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 | +| `409` | Target entity has required relations | +| `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..c856af84 100644 --- a/docs/src/static/swagger.yaml +++ b/docs/src/static/swagger.yaml @@ -411,6 +411,56 @@ 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: + minLength: 1 + type: string + - name: entityIdentifier + in: path + required: true + schema: + minLength: 1 + 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' + '409': + description: Target entity has required relations + 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/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/port/EntityRepositoryPort.java b/src/main/java/com/decathlon/idp_core/domain/port/EntityRepositoryPort.java index 0718ea94..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 @@ -52,4 +52,9 @@ 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..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 @@ -1,6 +1,11 @@ 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; +import java.util.Objects; import jakarta.transaction.Transactional; import jakarta.validation.Valid; @@ -11,13 +16,16 @@ 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; 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; @@ -100,9 +108,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. @@ -148,9 +154,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()); @@ -159,4 +163,225 @@ 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 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 + /// @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); + Entity entityToDelete = retrieveEntity(templateIdentifier, entityIdentifier); + removeRelatedRelations(entityToDelete); + 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. 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 entityToDelete the identifier of the entity that was deleted, used + /// to find and clean up related entities + /// @throws EntityDeletionBlockedException if the entity is referenced by + /// required relations + private void removeRelatedRelations(final Entity entityToDelete) { + List parentEntities = entityRepository.findEntitiesRelated(entityToDelete.identifier()); + + Map parentTemplates = parentEntities.stream() + .map(Entity::templateIdentifier).distinct() + .collect(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).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(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(); + } + + /// 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(); + currentRelations + .forEach(relation -> retrieveAndCleanTargetEntitiesAgainstRelation(parentTemplate, + entityIdentifierToRemove, relation, updatedRelations)); + + return new Entity(parent.id(), parent.templateIdentifier(), parent.name(), parent.identifier(), + parent.properties(), updatedRelations); + } + + 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); + return; + } + + cleanLinkedRelation(parentTemplate, entityIdentifierToRemove, relation, currentTargets, + updatedRelations); + } + + 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()) { + return; + } + } + 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); + } + + /// 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 + /// @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 + 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..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 @@ -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"; @@ -83,11 +85,13 @@ 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"; 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..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 @@ -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; @@ -14,6 +16,7 @@ import static com.decathlon.idp_core.infrastructure.adapters.api.configuration.SwaggerDescription.FORBIDDEN_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; @@ -22,8 +25,10 @@ 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_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; @@ -34,6 +39,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; @@ -43,6 +49,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; 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; @@ -229,4 +236,33 @@ public EntityDtoOut updateEntity(@NotBlank @PathVariable String templateIdentifi 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 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. + /// + /// @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 = 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(@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..8f11d981 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,12 +14,15 @@ 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; +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 +303,43 @@ 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()); + 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 or missing path variable: {}", ex.getMessage()); + return createErrorResponse(NOT_FOUND, "Malformed request URL or missing path variable."); + } + 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/PostgresEntityAdapter.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/PostgresEntityAdapter.java index 4cc14a22..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 @@ -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..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 @@ -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 targetEntityIdentifier + WHERE targetEntityIdentifier = :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..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 @@ -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,13 +29,16 @@ 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; 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; @@ -252,6 +258,162 @@ void shouldPropagateTwoValidationErrorsWhenUpdatingInvalidEntity() { verifyNoMoreInteractions(entityRepository); } + @Test + @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")))); + + var parentTemplate = new EntityTemplate(UUID.randomUUID(), "application", "Application", "desc", + List.of(), + List.of(new RelationDefinition(UUID.randomUUID(), "owner", "team", true, false))); + + when(entityRepository.findByTemplateIdentifierAndIdentifier("team", "team-a")) + .thenReturn(Optional + .of(new Entity(UUID.randomUUID(), "team", "team-a", "team-a", List.of(), List.of()))); + + when(entityRepository.findEntitiesRelated("team-a")).thenReturn(List.of(parent)); + + // The fixed service will now ask for the PARENT's template + when(entityTemplateService.getEntityTemplateByIdentifier("application")) + .thenReturn(parentTemplate); + + 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 + @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))); + + // 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"); + } + + @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 428a036c..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 @@ -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; @@ -759,4 +760,315 @@ 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 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())) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser() + @DisplayName("Should return 400 when template identifier path segment is blank") + 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())) + .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 return 404 on repeated deletion attempts (first 204, second 404)") + 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()); + } + + @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');