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 cf57583..4eac54f 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 @@ -56,6 +56,7 @@ public class ValidationMessages { public static final String RELATION_REQUIRED_MISSING = "Relation '%s' is required by template '%s'"; public static final String RELATION_TOO_MANY_TARGETS = "Relation '%s' allows only one target in template '%s'"; public static final String RELATION_TARGET_ENTITY_NOT_FOUND = "Relation '%s': target entity '%s' does not exist"; + public static final String RELATION_TARGET_TEMPLATE_MISMATCH = "Relation '%s' targets entities with template '%s', but entity '%s' has template '%s'"; public static final String RELATION_TARGET_TEMPLATE_CANNOT_CHANGE = "Cannot change target template of relation '%s' from '%s' to '%s'. Target template cannot be modified after creation. Please delete and recreate the relation instead."; public static final String RELATION_CANNOT_TARGET_ITSELF = "Relation '%s' cannot reference its own template '%s' as the target."; 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 735bdf6..fccf260 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,11 +1,14 @@ package com.decathlon.idp_core.domain.service.entity; -import static java.util.stream.Collectors.*; +import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toMap; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import jakarta.validation.Valid; @@ -26,6 +29,7 @@ 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.Property; 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; @@ -176,9 +180,115 @@ public Entity updateEntity(String templateIdentifier, String entityIdentifier, Entity enrichedEntity = enrichRelationsWithTargetTemplates(entityToSave, template); entityValidationService.validateForUpdate(enrichedEntity, template); - return entityRepository.save(enrichedEntity); + + // Only save if there are actual changes to avoid unnecessary audit records + if (hasEntityChanged(existingEntity, enrichedEntity)) { + return entityRepository.save(enrichedEntity); + } + + return existingEntity; + } + + /// Detects if an entity has actually changed by comparing its core fields, + /// properties, and relations with the incoming entity. + /// + /// @param existingEntity the current entity in the repository + /// @param incomingEntity the new entity data to be applied + /// @return true if any field, property, or relation has changed; false + /// otherwise + private boolean hasEntityChanged(Entity existingEntity, Entity incomingEntity) { + // Check name change + if (!Objects.equals(existingEntity.name(), incomingEntity.name())) { + return true; + } + + // Check properties change + if (havePropertiesChanged(existingEntity.properties(), incomingEntity.properties())) { + return true; + } + + // last Check relations change + return haveRelationsChanged(existingEntity.relations(), incomingEntity.relations()); } + /// Compares two property lists for equality, ignoring UUID ids which are + /// only present in persisted properties. + /// + /// @param existing the current list of properties in the repository + /// @param request the new list of properties to be applied + /// @return true if any property has changed; false otherwise + private boolean havePropertiesChanged(List existing, List request) { + if (existing == request) + return false; + if (existing == null || request == null) + return true; + if (existing.size() != request.size()) + return true; + + Map existingMap = existing.stream() + .collect(Collectors.toMap(Property::name, Property::value, (_, v2) -> v2)); + Map incomingMap = request.stream() + .collect(Collectors.toMap(Property::name, Property::value, (_, v2) -> v2)); + + return !existingMap.equals(incomingMap); + } + + /// Compares two relation lists for equality by name and target identifiers, + /// ignoring the UUID id field which is only present in persisted relations. + /// + /// @param existing the current list of relations in the repository + /// @param request the new list of relations to be applied + /// @return true if any relation has changed; false otherwise + private boolean haveRelationsChanged(List existing, List request) { + if (existing == request) + return false; + if (existing == null || request == null) + return true; + if (existing.size() != request.size()) + return true; + + Map existingMap = existing.stream() + .collect(Collectors.toMap(Relation::name, r -> r, (_, r) -> r)); + Map incomingMap = request.stream() + .collect(Collectors.toMap(Relation::name, r -> r, (_, r) -> r)); + + if (!existingMap.keySet().equals(incomingMap.keySet())) { + return true; + } + + for (Map.Entry entry : existingMap.entrySet()) { + Relation existingRel = entry.getValue(); + Relation incomingRel = incomingMap.get(entry.getKey()); + + if (haveRelationChanged(existingRel, incomingRel)) { + return true; + } + } + return false; + } + + /// Compares two relations for equality by target template identifier and target + /// entity identifiers, ignoring the UUID id field which is only present in + /// persisted relations. + /// @param existingRelation the current relation in the repository + /// @param requestRelation the new relation data to be applied + /// @return true if the relation has changed; false otherwise + private boolean haveRelationChanged(Relation existingRelation, Relation requestRelation) { + if (!Objects.equals(existingRelation.targetTemplateIdentifier(), + requestRelation.targetTemplateIdentifier())) { + return true; + } + + Set existingEntityIdentifier = existingRelation.targetEntityIdentifiers() == null + ? Set.of() + : Set.copyOf(existingRelation.targetEntityIdentifiers()); + + Set requestEntityIdentifier = requestRelation.targetEntityIdentifiers() == null + ? Set.of() + : Set.copyOf(requestRelation.targetEntityIdentifiers()); + + return !existingEntityIdentifier.equals(requestEntityIdentifier); + } /// Enriches entity relations with target template identifiers from template /// definition. /// diff --git a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java index 00fd7c8..09a541d 100644 --- a/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java +++ b/src/main/java/com/decathlon/idp_core/domain/service/relation/RelationValidationService.java @@ -4,6 +4,7 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_TEMPLATE_MISMATCH; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; import java.util.List; @@ -155,7 +156,7 @@ public void validateRelationsAgainstTemplate(EntityTemplate template, } /// Validates that all target entity identifiers in the relation actually exist - /// in the database. + /// in the database and match the expected target template. /// /// @param relation the relation whose target entity identifiers are to be /// validated @@ -177,6 +178,31 @@ private void validateRelationTargetEntityExistence(Relation relation, Violations violations.add(RELATION_TARGET_ENTITY_NOT_FOUND, relation.name(), identifier); } } + + // Validate that target entities match the expected target template + validateRelationTargetTemplates(relation, existingEntities, violations); + } + + /// Validates that all target entities have the expected target template + /// identifier + /// as defined in the relation. + /// + /// @param relation the relation with target template identifier + /// @param existingEntities the existing entities to validate + /// @param violations the accumulator for validation violations + private void validateRelationTargetTemplates(Relation relation, + List existingEntities, Violations violations) { + if (relation.targetTemplateIdentifier() == null || existingEntities.isEmpty()) { + return; + } + + for (EntitySummary entity : existingEntities) { + if (extractValidTargetIdentifiers(relation).contains(entity.identifier()) + && !relation.targetTemplateIdentifier().equals(entity.templateIdentifier())) { + violations.add(RELATION_TARGET_TEMPLATE_MISMATCH, relation.name(), + relation.targetTemplateIdentifier(), entity.identifier(), entity.templateIdentifier()); + } + } } /// Extracts non-null, non-blank target entity identifiers from the relation, diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java index 3a8eedf..b09c48c 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapper.java @@ -130,10 +130,13 @@ private void mergeProperties(final Entity entity, final EntityJpaEntity existing /// Merges relations from the incoming domain entity into the existing JPA /// entity. - /// Removes relations that are no longer present, updates existing ones, and - /// adds - /// new relations as needed. This ensures that the JPA entity accurately - /// reflects the current state of the domain entity. + /// Removes relations that are no longer present, updates existing ones only if + /// they changed, and adds new relations as needed. This prevents unnecessary + /// updates and avoids triggering Envers audit records for unchanged data. + /// + /// **Optimization:** Change detection prevents false positives where Hibernate + /// marks collections as modified even though their values haven't changed, + /// which would cause Envers to create duplicate audit table entries. private void mergeRelations(final Entity entity, final EntityJpaEntity existing) { existing.getRelations().removeIf(existingRel -> entity.relations().stream() @@ -142,15 +145,47 @@ private void mergeRelations(final Entity entity, final EntityJpaEntity existing) for (Relation domainRel : entity.relations()) { existing.getRelations().stream().filter(r -> r.getName().equals(domainRel.name())).findFirst() .ifPresentOrElse(existingRel -> { - existingRel.setTargetTemplateIdentifier(domainRel.targetTemplateIdentifier()); - existingRel.setTargetEntities(domainRel.targetEntityIdentifiers() != null - ? resolveBatchTargetEntities(domainRel.targetTemplateIdentifier(), - domainRel.targetEntityIdentifiers()) - : Set.of()); + if (hasRelationChanged(existingRel, domainRel)) { + existingRel.setTargetTemplateIdentifier(domainRel.targetTemplateIdentifier()); + Set newTargetEntities = domainRel + .targetEntityIdentifiers() != null + ? resolveBatchTargetEntities(domainRel.targetTemplateIdentifier(), + domainRel.targetEntityIdentifiers()) + : Set.of(); + existingRel.getTargetEntities() + .removeIf(target -> !newTargetEntities.contains(target)); + + newTargetEntities.stream() + .filter(target -> !existingRel.getTargetEntities().contains(target)) + .forEach(existingRel.getTargetEntities()::add); + } }, () -> existing.getRelations().add(toJpa(domainRel))); } } + /// Detects if a relation has actually changed by comparing template identifier + /// and target entities. Returns true only if there are actual differences, + /// preventing unnecessary updates to the database and Envers audit records. + boolean hasRelationChanged(RelationJpaEntity existingRel, Relation domainRel) { + if (!Objects.equals(existingRel.getTargetTemplateIdentifier(), + domainRel.targetTemplateIdentifier())) { + return true; + } + + Set newTargetEntities = domainRel.targetEntityIdentifiers() != null + ? resolveBatchTargetEntities(domainRel.targetTemplateIdentifier(), + domainRel.targetEntityIdentifiers()) + : Set.of(); + + var existingTargets = Objects.requireNonNullElse(existingRel.getTargetEntities(), + Set.of()); + if (existingTargets.size() != newTargetEntities.size()) { + return true; + } + + return !existingTargets.equals(newTargetEntities); + } + // ========================================================================= // Property Mapping // ========================================================================= diff --git a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java index 61cfbb7..af026db 100644 --- a/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java +++ b/src/main/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/entity/RelationTargetJpaEntity.java @@ -5,12 +5,15 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; +import org.hibernate.envers.Audited; + import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @Embeddable @Data +@Audited @NoArgsConstructor @AllArgsConstructor public class RelationTargetJpaEntity { diff --git a/src/main/resources/db/migration/V5_3__remove_unused_modified_flags_from_relation_targets_aud.sql b/src/main/resources/db/migration/V5_3__remove_unused_modified_flags_from_relation_targets_aud.sql new file mode 100644 index 0000000..684de7e --- /dev/null +++ b/src/main/resources/db/migration/V5_3__remove_unused_modified_flags_from_relation_targets_aud.sql @@ -0,0 +1,54 @@ +-- Flyway migration script: Remove unused modified flag columns from relation_target_entities_aud +-- Purpose: Hibernate Envers does not properly support withModifiedFlag for embeddables +-- within ElementCollections. These columns were never being populated correctly. +-- The collection-level modified flag (target_entities_mod in relation_aud) is sufficient +-- to track when the relation targets change. + +ALTER TABLE relation_target_entities_aud +DROP COLUMN IF EXISTS relation_id_mod; + +ALTER TABLE relation_target_entities_aud +DROP COLUMN IF EXISTS target_entity_identifier_mod; + +ALTER TABLE relation_target_entities_aud +DROP COLUMN IF EXISTS target_entity_uuid_mod; + +-- Add table comment for documentation +COMMENT ON TABLE relation_target_entities_aud IS 'Audit table for relation target entities. Modified flags removed as they are not supported for ElementCollection embeddables.'; + +ALTER TABLE entity_properties_aud +DROP COLUMN IF EXISTS entity_id_mod; + +ALTER TABLE entity_properties_aud +DROP COLUMN IF EXISTS property_id_mod; + +-- Add table comment for documentation +COMMENT ON TABLE entity_properties_aud IS 'Audit table for entity properties. Modified flags removed as they are not supported for ElementCollection embeddables.'; + +ALTER TABLE entity_relations_aud +DROP COLUMN IF EXISTS entity_id_mod; + +ALTER TABLE entity_relations_aud +DROP COLUMN IF EXISTS relation_id_mod; + +-- Add table comment for documentation +COMMENT ON TABLE entity_relations_aud IS 'Audit table for entity relations. Modified flags removed as they are not supported for ElementCollection embeddables.'; + +ALTER TABLE entity_template_properties_definitions_aud +DROP COLUMN IF EXISTS entity_template_id_mod; + +ALTER TABLE entity_template_properties_definitions_aud +DROP COLUMN IF EXISTS properties_definitions_id_mod; + +-- Add table comment for documentation +COMMENT ON TABLE entity_template_properties_definitions_aud IS 'Audit table for entity template properties definitions. Modified flags removed as they are not supported for ElementCollection embeddables.'; + + +ALTER TABLE entity_template_relations_definitions_aud +DROP COLUMN IF EXISTS entity_template_id_mod; + +ALTER TABLE entity_template_relations_definitions_aud +DROP COLUMN IF EXISTS relations_definitions_id_mod; + +-- Add table comment for documentation +COMMENT ON TABLE entity_template_relations_definitions_aud IS 'Audit table for entity template relations definitions. Modified flags removed as they are not supported for ElementCollection embeddables.'; 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 6a3c9ea..9d6f7f4 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 @@ -7,12 +7,14 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -26,6 +28,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; import com.decathlon.idp_core.domain.constant.SearchConstraints; import com.decathlon.idp_core.domain.constant.ValidationMessages; @@ -38,9 +41,13 @@ 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.FilterCriterion; +import com.decathlon.idp_core.domain.model.entity.Property; 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.model.enums.FilterKeyType; +import com.decathlon.idp_core.domain.model.enums.FilterOperator; import com.decathlon.idp_core.domain.model.search.LogicalConnector; import com.decathlon.idp_core.domain.model.search.PaginatedResult; import com.decathlon.idp_core.domain.model.search.PaginationCriteria; @@ -439,6 +446,41 @@ private void assertSearchThrows(PaginationCriteria paginationCriteria) { () -> entityService.searchEntities(filter, null, paginationCriteria)); } + private Property property(String name, String value) { + return new Property(UUID.randomUUID(), name, value); + } + + private Relation relation(String name, String targetTemplateIdentifier, String... targetIds) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, List.of(targetIds)); + } + + private Relation relation(UUID id, String name, String targetTemplateIdentifier, + String... targetIds) { + return new Relation(id, name, targetTemplateIdentifier, List.of(targetIds)); + } + + private RelationDefinition relationDefinition(String name, String targetTemplateIdentifier, + boolean required, boolean toMany) { + return new RelationDefinition(UUID.randomUUID(), name, targetTemplateIdentifier, required, + toMany); + } + + private EntityTemplate templateWithRelations(String identifier, + RelationDefinition... relationDefinitions) { + return new EntityTemplate(UUID.randomUUID(), identifier, identifier, "desc", List.of(), + List.of(relationDefinitions)); + } + + @SuppressWarnings("unchecked") + private T invokePrivateMethod(String methodName, Object... args) { + return (T) ReflectionTestUtils.invokeMethod(entityService, methodName, args); + } + + private void verifyNoCollaboratorInteractions() { + verifyNoInteractions(entityRepository, entityValidationService, entityTemplateValidationService, + entityTemplateService, entityFilterDslParser, searchFilterValidationService); + } + @Test @DisplayName("Should search entities with valid parameters") void shouldSearchEntitiesWithValidParameters() { @@ -505,4 +547,447 @@ void shouldRejectInvalidSortDirection() { void shouldRejectExtraSortSegments() { assertSearchThrows(new PaginationCriteria(0, 20, "identifier:asc:extra")); } + + // --------------------------------------------------------------------------- + // Additional coverage for EntityService private business rules + // --------------------------------------------------------------------------- + + @Test + @DisplayName("Should return entities page when non-null filter is provided") + void shouldReturnEntitiesByTemplateIdentifierWhenFilterProvided() { + // Arrange + var pageable = Pageable.ofSize(5); + var filter = new EntityFilter(List.of( + new FilterCriterion(FilterKeyType.ATTRIBUTE, "name", FilterOperator.CONTAINS, "catalog"))); + var template = templateWithRelations("template-a"); + var entity = entity("template-a", "catalog-api", "Catalog API"); + var page = new PageImpl<>(List.of(entity)); + + when(entityTemplateService.getEntityTemplateByIdentifier("template-a")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierWithFilter("template-a", filter, pageable)) + .thenReturn(page); + + // Act + var result = entityService.getEntitiesByTemplateIdentifier(pageable, "template-a", filter); + + // Assert + assertSame(page, result); + verify(entityFilterDslParser).validateFilterPropertyTypes(filter, template); + verify(entityRepository).findByTemplateIdentifierWithFilter("template-a", filter, pageable); + } + + @Test + @DisplayName("Should enrich relation target templates when creating entity") + void shouldEnrichRelationTargetTemplatesWhenCreatingEntity() { + // Arrange + var ownerRelation = relation("owner", "ignored-template", "team-a"); + var legacyRelation = relation("legacy-link", "legacy-template", "legacy-a"); + var entity = new Entity(UUID.randomUUID(), "application", "Commerce App", "commerce-app", + List.of(), List.of(ownerRelation, legacyRelation)); + var template = templateWithRelations("application", + relationDefinition("owner", "team", true, false)); + var expectedSaved = new Entity(entity.id(), "application", "Commerce App", "commerce-app", + List.of(), List.of(relation(ownerRelation.id(), "owner", "team", "team-a"), + relation(legacyRelation.id(), "legacy-link", "legacy-template", "legacy-a"))); + + when(entityTemplateService.getEntityTemplateByIdentifier("application")).thenReturn(template); + when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); + + // Act + var result = entityService.createEntity(entity); + + // Assert + assertEquals(expectedSaved, result); + verify(entityValidationService).validateForCreation(expectedSaved, template); + verify(entityRepository).save(expectedSaved); + } + + @Test + @DisplayName("Should save updated entity when properties change") + void shouldSaveUpdatedEntityWhenPropertiesChange() { + // Arrange + var existing = new Entity(UUID.randomUUID(), "web-service", "Catalog API", "catalog-api", + List.of(property("language", "java")), List.of()); + var payload = new Entity(null, "web-service", "Catalog API", "catalog-api", + List.of(new Property(null, "language", "kotlin")), List.of()); + var expectedSaved = new Entity(existing.id(), "web-service", "Catalog API", "catalog-api", + payload.properties(), List.of()); + var template = templateWithRelations("web-service"); + + when(entityTemplateService.getEntityTemplateByIdentifier("web-service")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("web-service", "catalog-api")) + .thenReturn(Optional.of(existing)); + when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); + + // Act + var result = entityService.updateEntity("web-service", "catalog-api", payload); + + // Assert + assertSame(expectedSaved, result); + verify(entityValidationService).validateForUpdate(expectedSaved, template); + verify(entityRepository).save(expectedSaved); + } + + @Test + @DisplayName("Should save updated entity when relations change") + void shouldSaveUpdatedEntityWhenRelationsChange() { + // Arrange + var existingRelation = relation("owner", "team", "team-a"); + var payloadRelation = relation("owner", "placeholder", "team-b"); + var existing = new Entity(UUID.randomUUID(), "application", "Commerce App", "commerce-app", + List.of(), List.of(existingRelation)); + var payload = new Entity(null, "application", "Commerce App", "commerce-app", List.of(), + List.of(payloadRelation)); + var template = templateWithRelations("application", + relationDefinition("owner", "team", true, false)); + var expectedSaved = new Entity(existing.id(), "application", "Commerce App", "commerce-app", + List.of(), List.of(relation(payloadRelation.id(), "owner", "team", "team-b"))); + + when(entityTemplateService.getEntityTemplateByIdentifier("application")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("application", "commerce-app")) + .thenReturn(Optional.of(existing)); + when(entityRepository.save(expectedSaved)).thenReturn(expectedSaved); + + // Act + var result = entityService.updateEntity("application", "commerce-app", payload); + + // Assert + assertSame(expectedSaved, result); + verify(entityValidationService).validateForUpdate(expectedSaved, template); + verify(entityRepository).save(expectedSaved); + } + + @Test + @DisplayName("Should return existing entity when update does not change content") + void shouldReturnExistingEntityWhenUpdateDoesNotChangeContent() { + // Arrange + var existing = new Entity(UUID.randomUUID(), "application", "Commerce App", "commerce-app", + List.of(property("language", "java")), + List.of(relation("owner", "team", "team-a", "team-b"))); + var payload = new Entity(null, "application", "Commerce App", "commerce-app", + List.of(new Property(null, "language", "java")), + List.of(new Relation(null, "owner", "placeholder", List.of("team-b", "team-a")))); + var template = templateWithRelations("application", + relationDefinition("owner", "team", true, true)); + var expectedValidated = new Entity(existing.id(), "application", "Commerce App", "commerce-app", + payload.properties(), + List.of(new Relation(null, "owner", "team", List.of("team-b", "team-a")))); + + when(entityTemplateService.getEntityTemplateByIdentifier("application")).thenReturn(template); + when(entityRepository.findByTemplateIdentifierAndIdentifier("application", "commerce-app")) + .thenReturn(Optional.of(existing)); + + // Act + var result = entityService.updateEntity("application", "commerce-app", payload); + + // Assert + assertSame(existing, result); + verify(entityValidationService).validateForUpdate(expectedValidated, template); + verify(entityRepository, never()).save(any()); + } + + @Test + @DisplayName("Should treat two null property lists as equal") + void shouldTreatTwoNullPropertyListsAsEqual() { + // Act + Boolean result = invokePrivateMethod("havePropertiesChanged", (Object) null, (Object) null); + + // Assert + assertEquals(false, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat one null property list as different") + void shouldTreatOneNullPropertyListAsDifferent() { + // Act + Boolean result = invokePrivateMethod("havePropertiesChanged", (Object) null, + List.of(property("language", "java"))); + + // Assert + assertEquals(true, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat property lists with different sizes as different") + void shouldTreatPropertyListsWithDifferentSizesAsDifferent() { + // Act + Boolean result = invokePrivateMethod("havePropertiesChanged", + List.of(property("language", "java")), + List.of(property("language", "java"), property("tier", "backend"))); + + // Assert + assertEquals(true, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat property lists with different values as different") + void shouldTreatPropertyListsWithDifferentValuesAsDifferent() { + // Act + Boolean result = invokePrivateMethod("havePropertiesChanged", + List.of(property("language", "java")), List.of(new Property(null, "language", "kotlin"))); + + // Assert + assertEquals(true, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat two null relation lists as equal") + void shouldTreatTwoNullRelationListsAsEqual() { + // Act + Boolean result = invokePrivateMethod("haveRelationsChanged", (Object) null, (Object) null); + + // Assert + assertEquals(false, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat one null relation list as different") + void shouldTreatOneNullRelationListAsDifferent() { + // Act + Boolean result = invokePrivateMethod("haveRelationsChanged", (Object) null, + List.of(relation("owner", "team", "team-a"))); + + // Assert + assertEquals(true, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat relation lists with different sizes as different") + void shouldTreatRelationListsWithDifferentSizesAsDifferent() { + // Act + Boolean result = invokePrivateMethod("haveRelationsChanged", + List.of(relation("owner", "team", "team-a")), + List.of(relation("owner", "team", "team-a"), relation("depends-on", "service", "svc-a"))); + + // Assert + assertEquals(true, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat relations with different target templates as different") + void shouldTreatRelationsWithDifferentTargetTemplatesAsDifferent() { + // Act + Boolean result = invokePrivateMethod("haveRelationsChanged", + List.of(relation("owner", "team", "team-a")), + List.of(relation("owner", "group", "team-a"))); + + // Assert + assertEquals(true, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should treat relations with different target identifiers as different") + void shouldTreatRelationsWithDifferentTargetIdentifiersAsDifferent() { + // Act + Boolean result = invokePrivateMethod("haveRelationsChanged", + List.of(relation("owner", "team", "team-a")), List.of(relation("owner", "team", "team-b"))); + + // Assert + assertEquals(true, result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should delete entity directly when no parent relations exist") + void shouldDeleteEntityDirectlyWhenNoParentRelationsExist() { + // Arrange + var targetEntity = entity("service", "catalog-api", "Catalog API"); + + when(entityRepository.findByTemplateIdentifierAndIdentifier("service", "catalog-api")) + .thenReturn(Optional.of(targetEntity)); + when(entityRepository.findEntitiesRelated("catalog-api")).thenReturn(List.of()); + + // Act + entityService.deleteEntity("service", "catalog-api"); + + // Assert + verify(entityRepository, never()).save(any()); + verify(entityTemplateService, never()).getEntityTemplateByIdentifier(anyString()); + verify(entityRepository).deleteByTemplateIdentifierAndIdentifier("service", "catalog-api"); + } + + @Test + @DisplayName("Should return blocking relation names for required relations only") + void shouldReturnBlockingRelationNamesForRequiredRelationsOnly() { + // Arrange + var linkedEntity = new Entity(UUID.randomUUID(), "application", "Commerce App", "commerce-app", + List.of(), + List.of(relation("owner", "team", "team-a"), relation("backup-owner", "team", "team-a"), + relation("dependencies", "service", "team-a", "billing-service"))); + var parentTemplate = templateWithRelations("application", + relationDefinition("owner", "team", true, false), + relationDefinition("backup-owner", "team", true, true), + relationDefinition("dependencies", "service", false, true)); + + // Act + String result = invokePrivateMethod("getBlockingRelationNames", linkedEntity, parentTemplate, + "team-a"); + + // Assert + assertEquals("'owner', 'backup-owner'", result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should return no blocking relation names when deletion remains valid") + void shouldReturnNoBlockingRelationNamesWhenDeletionRemainsValid() { + // Arrange + var linkedEntity = new Entity(UUID.randomUUID(), "application", "Commerce App", "commerce-app", + List.of(), List.of(relation("dependencies", "service", "catalog-api", "billing-api"))); + var parentTemplate = templateWithRelations("application", + relationDefinition("dependencies", "service", true, true)); + + // Act + String result = invokePrivateMethod("getBlockingRelationNames", linkedEntity, parentTemplate, + "catalog-api"); + + // Assert + assertEquals("", result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should evaluate blocking relation combinations") + void shouldEvaluateBlockingRelationCombinations() { + // Arrange + var parentTemplate = templateWithRelations("application", + relationDefinition("owner", "team", true, false), + relationDefinition("backup-owner", "team", false, false)); + + // Act + Boolean missingTarget = invokePrivateMethod("isBlockingRelation", + relation("owner", "team", "team-b"), parentTemplate, "team-a"); + Boolean stillHasOtherTargets = invokePrivateMethod("isBlockingRelation", + relation("owner", "team", "team-a", "team-b"), parentTemplate, "team-a"); + Boolean optionalRelation = invokePrivateMethod("isBlockingRelation", + relation("backup-owner", "team", "team-a"), parentTemplate, "team-a"); + Boolean requiredRelation = invokePrivateMethod("isBlockingRelation", + relation("owner", "team", "team-a"), parentTemplate, "team-a"); + + // Assert + assertEquals(false, missingTarget); + assertEquals(false, stillHasOtherTargets); + assertEquals(false, optionalRelation); + assertEquals(true, requiredRelation); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should clean up relations by removing only matching targets") + void shouldCleanUpRelationsByRemovingOnlyMatchingTargets() { + // Arrange + var dependencyRelation = relation("dependencies", "service", "catalog-api", "billing-api"); + var ownerRelation = relation("owner", "team", "team-a"); + var parent = new Entity(UUID.randomUUID(), "application", "Commerce App", "commerce-app", + List.of(), List.of(dependencyRelation, ownerRelation)); + var parentTemplate = templateWithRelations("application", + relationDefinition("dependencies", "service", false, true), + relationDefinition("owner", "team", true, false)); + + // Act + Entity result = invokePrivateMethod("cleanUpRelations", parent, parentTemplate, "catalog-api"); + + // Assert + assertEquals(new Entity(parent.id(), "application", "Commerce App", "commerce-app", List.of(), + List.of(relation(dependencyRelation.id(), "dependencies", "service", "billing-api"), + ownerRelation)), + result); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should keep existing relation when target identifier is not linked") + void shouldKeepExistingRelationWhenTargetIdentifierIsNotLinked() { + // Arrange + var updatedRelations = new ArrayList(); + var relation = relation("dependencies", "service", "billing-api"); + + // Act + invokePrivateMethod("retrieveAndCleanTargetEntitiesAgainstRelation", + templateWithRelations("application", + relationDefinition("dependencies", "service", false, true)), + "catalog-api", relation, updatedRelations); + + // Assert + assertEquals(List.of(relation), updatedRelations); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should clean related relation when target identifier matches") + void shouldCleanRelatedRelationWhenTargetIdentifierMatches() { + // Arrange + var updatedRelations = new ArrayList(); + var relation = relation("dependencies", "service", "catalog-api", "billing-api"); + + // Act + invokePrivateMethod("retrieveAndCleanTargetEntitiesAgainstRelation", + templateWithRelations("application", + relationDefinition("dependencies", "service", false, true)), + "catalog-api", relation, updatedRelations); + + // Assert + assertEquals(List.of(relation(relation.id(), "dependencies", "service", "billing-api")), + updatedRelations); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should skip adding required relation when cleanup would leave it empty") + void shouldSkipAddingRequiredRelationWhenCleanupWouldLeaveItEmpty() { + // Arrange + var updatedRelations = new ArrayList(); + var relation = relation("owner", "team", "team-a"); + + // Act + invokePrivateMethod("cleanLinkedRelation", + templateWithRelations("application", relationDefinition("owner", "team", true, false)), + "team-a", relation, relation.targetEntityIdentifiers(), updatedRelations); + + // Assert + assertEquals(List.of(), updatedRelations); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should add empty relation when optional relation becomes empty") + void shouldAddEmptyRelationWhenOptionalRelationBecomesEmpty() { + // Arrange + var updatedRelations = new ArrayList(); + var relation = relation("watcher", "team", "team-a"); + + // Act + invokePrivateMethod("cleanLinkedRelation", + templateWithRelations("application", relationDefinition("watcher", "team", false, false)), + "team-a", relation, relation.targetEntityIdentifiers(), updatedRelations); + + // Assert + assertEquals(List.of(new Relation(relation.id(), "watcher", "team", List.of())), + updatedRelations); + verifyNoCollaboratorInteractions(); + } + + @Test + @DisplayName("Should return null relation definition when template relation definitions are null") + void shouldReturnNullRelationDefinitionWhenTemplateRelationDefinitionsAreNull() { + // Arrange + var template = mock(EntityTemplate.class); + when(template.relationsDefinitions()).thenReturn(null); + + // Act + RelationDefinition result = invokePrivateMethod("getRelationDefinition", template, "owner"); + + // Assert + assertEquals(null, result); + verify(template).relationsDefinitions(); + verifyNoCollaboratorInteractions(); + } } diff --git a/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java index 2e4ca80..05ca391 100644 --- a/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java +++ b/src/test/java/com/decathlon/idp_core/domain/service/relation/RelationValidationServiceTest.java @@ -3,22 +3,24 @@ import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_NOT_DEFINED_IN_TEMPLATE; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_REQUIRED_MISSING; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_ENTITY_NOT_FOUND; +import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TARGET_TEMPLATE_MISMATCH; import static com.decathlon.idp_core.domain.constant.ValidationMessages.RELATION_TOO_MANY_TARGETS; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import java.util.Arrays; import java.util.List; import java.util.UUID; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -36,13 +38,9 @@ class RelationValidationServiceTest { @Mock private EntityRepositoryPort entityRepository; + @InjectMocks private RelationValidationService service; - @BeforeEach - void setUp() { - service = new RelationValidationService(entityRepository); - } - private void mockExistingEntities(String... identifiers) { var summaries = Arrays.stream(identifiers).map(id -> new EntitySummary(id, "Name", "template")) .toList(); @@ -73,11 +71,25 @@ private EntityTemplate template(String identifier, List rela } private RelationDefinition definition(String name, boolean required, boolean toMany) { - return new RelationDefinition(UUID.randomUUID(), name, "targetType", required, toMany); + return definition(name, "targetType", required, toMany); + } + + private RelationDefinition definition(String name, String targetTemplateIdentifier, + boolean required, boolean toMany) { + return new RelationDefinition(UUID.randomUUID(), name, targetTemplateIdentifier, required, + toMany); } private Relation relation(String name, List targets) { - return new Relation(UUID.randomUUID(), name, "targetType", targets); + return relation(name, "template", targets); + } + + private Relation relation(String name, String targetTemplateIdentifier, List targets) { + return new Relation(UUID.randomUUID(), name, targetTemplateIdentifier, targets); + } + + private EntitySummary entitySummary(String identifier, String templateIdentifier) { + return new EntitySummary(identifier, "Name", templateIdentifier); } @Nested @@ -179,6 +191,140 @@ void shouldNotReportViolationWhenOptionalRelationOmitted() { } } + @Nested + @DisplayName("Relation Target Template Checks") + class RelationTargetTemplateChecks { + + @Test + @DisplayName("Should skip template validation when relation target template identifier is null") + void shouldSkipTemplateValidationWhenTargetTemplateIdentifierIsNull() { + when(entityRepository.findByIdentifierIn(any())) + .thenReturn(List.of(entitySummary("team-a", "group"))); + var definition = definition("owned-by", "team", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", null, List.of("team-a")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); + } + + @Test + @DisplayName("Should not add template mismatch violations when no existing entities are available") + void shouldNotAddTemplateMismatchViolationsWhenExistingEntitiesListIsEmpty() { + when(entityRepository.findByIdentifierIn(any())).thenReturn(List.of()); + var definition = definition("owned-by", "team", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", "team", List.of("team-a")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_TARGET_ENTITY_NOT_FOUND, "owned-by", "team-a"); + verifyNoMoreInteractions(violations); + } + + @Test + @DisplayName("Should not report a mismatch when the targeted entity uses the expected template") + void shouldNotReportMismatchWhenTargetEntityMatchesExpectedTemplate() { + when(entityRepository.findByIdentifierIn(any())) + .thenReturn(List.of(entitySummary("team-a", "team"))); + var definition = definition("owned-by", "team", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", "team", List.of("team-a")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); + } + + @Test + @DisplayName("Should report a mismatch when the targeted entity uses a different template") + void shouldReportMismatchWhenTargetEntityUsesDifferentTemplate() { + when(entityRepository.findByIdentifierIn(any())) + .thenReturn(List.of(entitySummary("team-a", "group"))); + var definition = definition("owned-by", "team", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", "team", List.of("team-a")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_TARGET_TEMPLATE_MISMATCH, "owned-by", "team", "team-a", + "group"); + verifyNoMoreInteractions(violations); + } + + @Test + @DisplayName("Should ignore existing entities that are not targeted by the relation") + void shouldIgnoreExistingEntitiesThatAreNotTargetedByRelation() { + when(entityRepository.findByIdentifierIn(any())) + .thenReturn(List.of(entitySummary("team-a", "team"), entitySummary("team-b", "group"))); + var definition = definition("owned-by", "team", true, false); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", "team", List.of("team-a")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); + } + + @Test + @DisplayName("Should report a single mismatch when one targeted entity uses a different template") + void shouldReportSingleMismatchWhenOneOfMultipleTargetsUsesDifferentTemplate() { + when(entityRepository.findByIdentifierIn(any())) + .thenReturn(List.of(entitySummary("team-a", "team"), entitySummary("team-b", "group"))); + var definition = definition("collaborates-with", "team", false, true); + var template = template("system-template", List.of(definition)); + var relation = relation("collaborates-with", "team", List.of("team-a", "team-b")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_TARGET_TEMPLATE_MISMATCH, "collaborates-with", "team", + "team-b", "group"); + verifyNoMoreInteractions(violations); + } + + @Test + @DisplayName("Should report mismatches for every targeted entity using a different template") + void shouldReportMismatchForEveryTargetUsingDifferentTemplate() { + when(entityRepository.findByIdentifierIn(any())) + .thenReturn(List.of(entitySummary("team-a", "group"), entitySummary("team-b", "user"))); + var definition = definition("collaborates-with", "team", false, true); + var template = template("system-template", List.of(definition)); + var relation = relation("collaborates-with", "team", List.of("team-a", "team-b")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verify(violations).add(RELATION_TARGET_TEMPLATE_MISMATCH, "collaborates-with", "team", + "team-a", "group"); + verify(violations).add(RELATION_TARGET_TEMPLATE_MISMATCH, "collaborates-with", "team", + "team-b", "user"); + verifyNoMoreInteractions(violations); + } + + @Test + @DisplayName("Should ignore blank target identifiers when validating target templates") + void shouldIgnoreBlankTargetIdentifiersWhenValidatingTargetTemplates() { + when(entityRepository.findByIdentifierIn(any())) + .thenReturn(List.of(entitySummary("team-a", "team"), entitySummary("", "group"), + entitySummary(" ", "user"))); + var definition = definition("owned-by", "team", true, true); + var template = template("system-template", List.of(definition)); + var relation = relation("owned-by", "team", List.of("team-a", "", " ")); + var violations = mock(Violations.class); + + service.validateRelationsAgainstTemplate(template, List.of(relation), violations); + + verifyNoInteractions(violations); + } + } + @Nested @DisplayName("Relation Cardinality Checks") class CardinalityTests { 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 2efa61e..c5c5eb2 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 @@ -402,20 +402,12 @@ void postEntity_201() throws Exception { @WithMockUser() @DisplayName("Should return 400 when required template properties are missing") void postEntity_400_when_required_properties_missing() throws Exception { - var payload = """ - { - "name": "web-service-missing-required", - "identifier": "web-service-missing-required", - "properties": { - "port": "8080" - } - } - """; - mockMvc .perform(MockMvcRequestBuilders .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "postEntity_400_required_properties_missing.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value( org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); @@ -425,28 +417,12 @@ void postEntity_400_when_required_properties_missing() throws Exception { @WithMockUser() @DisplayName("Should return 400 when property type does not match template") void postEntity_400_when_property_type_mismatch() throws Exception { - var payload = """ - { - "name": "web-service-invalid-type", - "identifier": "web-service-invalid-type", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "owner@example.com", - "port": "not-a-number", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "https://catalog.example.com", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; - mockMvc - .perform(MockMvcRequestBuilders - .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "postEntity_400_property_type_mismatch.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value( org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); @@ -456,28 +432,12 @@ void postEntity_400_when_property_type_mismatch() throws Exception { @WithMockUser() @DisplayName("Should return 400 when property rules are not respected") void postEntity_400_when_property_rules_not_respected() throws Exception { - var payload = """ - { - "name": "web-service-invalid-rules", - "identifier": "web-service-invalid-rules", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "invalid-email", - "port": "80", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "invalid-url", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; - mockMvc - .perform(MockMvcRequestBuilders - .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + + "postEntity_400_property_rules_not_respected.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers @@ -496,29 +456,12 @@ void postEntity_400_when_property_rules_not_respected() throws Exception { @WithMockUser() @DisplayName("Should return 400 when payload contains a property not defined in template") void postEntity_400_when_property_not_defined_in_template() throws Exception { - var payload = """ - { - "name": "web-service-extra-property", - "identifier": "web-service-extra-property", - "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", - "status": "deprecated" - } - } - """; - mockMvc - .perform(MockMvcRequestBuilders - .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + + "postEntity_400_property_not_defined_in_template.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers .containsString("Property 'status' is not defined in template 'web-service'"))); @@ -528,34 +471,12 @@ void postEntity_400_when_property_not_defined_in_template() throws Exception { @WithMockUser() @DisplayName("Should return 400 when relation target entity does not exist") void postEntity_400_when_relation_target_entity_does_not_exist() throws Exception { - var payload = """ - { - "name": "web-service-missing-relation-target", - "identifier": "web-service-missing-relation-target", - "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": "database", - "target_entity_identifiers": ["missing-database"] - } - ] - } - """; - mockMvc - .perform(MockMvcRequestBuilders - .post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + + "postEntity_400_relation_target_entity_does_not_exist.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.containsString( "Relation 'database': target entity 'missing-database' does not exist"))); @@ -567,22 +488,8 @@ void postEntity_400_when_relation_target_entity_does_not_exist() throws Exceptio @DisplayName("PUT /api/v1/entities/{template-identifier}/{identifier} - Update entity") class PutEntitiesTests { - private static final String VALID_UPDATE_PAYLOAD = """ - { - "name": "Web API 2 Updated", - "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" - } - } - """; + private static final String VALID_UPDATE_PAYLOAD = getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "putEntity_200_valid_update.json"); @Test @WithMockUser @@ -620,18 +527,11 @@ void putEntity_404_non_existent_template() throws Exception { @WithMockUser @DisplayName("Should return 400 when required template properties are missing on update") void putEntity_400_when_required_properties_missing() throws Exception { - var payload = """ - { - "name": "Web API 2 Updated", - "properties": { - "port": "8080" - } - } - """; - mockMvc .perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "putEntity_400_required_properties_missing.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value( org.hamcrest.Matchers.containsString("Property 'applicationName' is required"))); @@ -641,26 +541,11 @@ void putEntity_400_when_required_properties_missing() throws Exception { @WithMockUser @DisplayName("Should return 400 when property type does not match template on update") void putEntity_400_when_property_type_mismatch() throws Exception { - var payload = """ - { - "name": "Web API 2 Updated", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "owner@example.com", - "port": "not-a-number", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "https://catalog.example.com", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; - mockMvc .perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "putEntity_400_property_type_mismatch.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value( org.hamcrest.Matchers.containsString("Property 'port' must be of type NUMBER"))); @@ -670,26 +555,11 @@ void putEntity_400_when_property_type_mismatch() throws Exception { @WithMockUser @DisplayName("Should return 400 when property rules are not respected on update") void putEntity_400_when_property_rules_not_respected() throws Exception { - var payload = """ - { - "name": "Web API 2 Updated", - "properties": { - "applicationName": "catalog-api", - "ownerEmail": "invalid-email", - "port": "80", - "environment": "DEV", - "version": "1.2.3", - "teamName": "platform-team", - "baseUrl": "invalid-url", - "protocol": "HTTP", - "programmingLanguage": "JAVA" - } - } - """; - mockMvc .perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "putEntity_400_property_rules_not_respected.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers @@ -708,33 +578,11 @@ void putEntity_400_when_property_rules_not_respected() throws Exception { @WithMockUser @DisplayName("Should return 400 when update contains a property not defined in template and a missing relation target") void putEntity_400_when_property_not_defined_and_relation_target_missing() throws Exception { - var payload = """ - { - "name": "Web API 2 Updated", - "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", - "status": "deprecated" - }, - "relations": [ - { - "name": "database", - "target_entity_identifiers": ["missing-database"] - } - ] - } - """; - mockMvc .perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, ENTITY_IDENTIFIER) - .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(payload)) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(getJsonTestFileContent(ENTITY_JSON_FILES_TEST_PATH + + "putEntity_400_property_not_defined_and_relation_target_missing.json"))) .andExpect(status().isBadRequest()).andExpect(jsonPath("$.error").value("BAD_REQUEST")) .andExpect(jsonPath("$.error_description").value(org.hamcrest.Matchers.allOf( org.hamcrest.Matchers @@ -760,7 +608,213 @@ void putEntity_403_without_csrf() throws Exception { .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).content(VALID_UPDATE_PAYLOAD)) .andExpect(status().isForbidden()); } + } + @Nested + @DisplayName("PUT /api/v1/entities/{template-identifier}/{identifier} - Update entity relations with merge") + class PutEntitiesRelationsMergeTests { + + private static final String UPDATE_WITH_ADD_RELATION = getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "putEntity_200_update_with_add_relation.json"); + + private static final String UPDATE_REMOVE_ALL_RELATIONS = getJsonTestFileContent( + ENTITY_JSON_FILES_TEST_PATH + "putEntity_200_update_remove_all_relations.json"); + + @Test + @WithMockUser + @DisplayName("Should add new relation during entity update") + void putEntity_200_adds_new_relation() throws Exception { + var entityIdentifier = "web-api-update-add-relation"; + try { + mockMvc.perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" + { + "name": "Web API for Relation Update", + "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(entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc + .perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(UPDATE_WITH_ADD_RELATION)) + .andExpect(status().isOk()).andExpect(jsonPath("$.relations.database").isArray()); + } finally { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())); + } + } + + @Test + @WithMockUser + @DisplayName("Should remove all relations during entity update when empty array provided") + void putEntity_200_removes_all_relations() throws Exception { + var entityIdentifier = "web-api-update-remove-relations"; + try { + mockMvc.perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" + { + "name": "Web API for Relation Removal", + "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": "database", + "target_entity_identifiers": ["database-service-1"] + } + ] + } + """.formatted(entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc + .perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(UPDATE_REMOVE_ALL_RELATIONS)) + .andExpect(status().isOk()).andExpect(jsonPath("$.relations").isEmpty()); + } finally { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())); + } + } + + @Test + @WithMockUser + @DisplayName("Should return relations after merge update") + void putEntity_200_returns_relations_after_merge_update() throws Exception { + var entityIdentifier = "web-api-preserve-relation-ids"; + try { + mockMvc.perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" + { + "name": "Web API for ID Preservation", + "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": "database", + "target_entity_identifiers": ["database-service-1"] + } + ] + } + """.formatted(entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc.perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) + .content(UPDATE_WITH_ADD_RELATION)).andExpect(status().isOk()); + + mockMvc + .perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()).andExpect(jsonPath("$.relations.database").isArray()); + } finally { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())); + } + } + + @Test + @WithMockUser + @DisplayName("Should preserve other properties during relation merge update") + void putEntity_200_preserves_properties_during_relation_merge() throws Exception { + var entityIdentifier = "web-api-preserve-properties"; + try { + mockMvc.perform( + MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" + { + "name": "Original Name", + "identifier": "%s", + "properties": { + "applicationName": "original-app", + "ownerEmail": "owner@example.com", + "port": "8080", + "environment": "DEV", + "version": "1.0.0", + "teamName": "platform-team", + "baseUrl": "https://original.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + }, + "relations": [ + { + "name": "database", + "target_entity_identifiers": ["database-service-1"] + } + ] + } + """.formatted(entityIdentifier))) + .andExpect(status().isCreated()); + + mockMvc + .perform(put(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()).content(""" + { + "name": "Updated Name", + "properties": { + "applicationName": "updated-app", + "ownerEmail": "owner@example.com", + "port": "9090", + "environment": "PROD", + "version": "2.0.0", + "teamName": "platform-team", + "baseUrl": "https://updated.example.com", + "protocol": "HTTPS", + "programmingLanguage": "JAVA" + }, + "relations": [ + { + "name": "database", + "target_entity_identifiers": ["database-service-1"] + } + ] + } + """)) + .andExpect(status().isOk()).andExpect(jsonPath("$.name").value("Updated Name")) + .andExpect(jsonPath("$.properties.applicationName").value("updated-app")) + .andExpect(jsonPath("$.properties.port").value(9090)) + .andExpect(jsonPath("$.relations.database").isArray()); + } finally { + mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) + .accept(APPLICATION_JSON).with(csrf())); + } + } } @Nested @@ -1824,7 +1878,7 @@ private String createWebServicePayload(String name, String identifier) { } private String createWebServicePayloadWithRelation(String name, String identifier, - String targetEntityIdentifier) { + String relationName, String targetEntityIdentifier) { return """ { "name": "%s", @@ -1847,7 +1901,7 @@ private String createWebServicePayloadWithRelation(String name, String identifie } ] } - """.formatted(name, identifier, "database", targetEntityIdentifier); + """.formatted(name, identifier, relationName, targetEntityIdentifier); } @Test @@ -1923,7 +1977,7 @@ void deleteEntity_204_with_relation_cleanup() throws Exception { MockMvcRequestBuilders.post(ENTITIES_BY_TEMPLATE_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER) .contentType(APPLICATION_JSON).accept(APPLICATION_JSON).with(csrf()) .content(createWebServicePayloadWithRelation("Relation Source Entity", - sourceEntityIdentifier, targetEntityIdentifier))) + sourceEntityIdentifier, "uses", targetEntityIdentifier))) .andExpect(status().isCreated()); mockMvc.perform(get(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, targetEntityIdentifier) @@ -1996,11 +2050,12 @@ void deleteEntity_400_with_blank_template_identifier() throws Exception { 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"))) + 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", "database-service-1"))) .andExpect(status().isCreated()); mockMvc.perform(delete(ENTITIES_BY_IDENTIFIER_PATH, TEMPLATE_IDENTIFIER, entityIdentifier) diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapperTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapperTest.java new file mode 100644 index 0000000..30aaffb --- /dev/null +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/mapper/EntityPersistenceMapperTest.java @@ -0,0 +1,1446 @@ +package com.decathlon.idp_core.infrastructure.adapters.persistence.mapper; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Instant; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.decathlon.idp_core.domain.model.entity.Entity; +import com.decathlon.idp_core.domain.model.entity.Property; +import com.decathlon.idp_core.domain.model.entity.Relation; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.EntityJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.PropertyJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.model.entity.RelationTargetJpaEntity; +import com.decathlon.idp_core.infrastructure.adapters.persistence.repository.JpaEntityRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EntityPersistenceMapper Test Suite") +class EntityPersistenceMapperTest { + + @Mock + private JpaEntityRepository entityRepository; + + @InjectMocks + private EntityPersistenceMapper mapper; + + // ========================================================================= + // Entity Mapping Tests - toDomain() + // ========================================================================= + + @Nested + @DisplayName("Entity toDomain() Tests") + class EntityToDomainTests { + + @Test + @DisplayName("Should return null when JPA entity is null") + void toDomain_WithNullJpaEntity_ReturnsNull() { + assertNull(mapper.toDomain((EntityJpaEntity) null)); + } + + @Test + @DisplayName("Should convert JPA entity with all properties to domain") + void toDomain_WithValidJpaEntity_ReturnsDomainEntity() { + UUID id = UUID.randomUUID(); + PropertyJpaEntity prop1 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop1") + .value("value1").build(); + PropertyJpaEntity prop2 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop2") + .value("value2").build(); + Set properties = Set.of(prop1, prop2); + + RelationTargetJpaEntity target = new RelationTargetJpaEntity(UUID.randomUUID(), "target-id"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target))).build(); + Set relations = Set.of(rel1); + + EntityJpaEntity jpa = EntityJpaEntity.builder().id(id).templateIdentifier("template-id") + .name("entity-name").identifier("entity-id").properties(properties).relations(relations) + .build(); + + Entity domain = mapper.toDomain(jpa); + + assertNotNull(domain); + assertEquals(id, domain.id()); + assertEquals("template-id", domain.templateIdentifier()); + assertEquals("entity-name", domain.name()); + assertEquals("entity-id", domain.identifier()); + assertEquals(2, domain.properties().size()); + assertEquals(1, domain.relations().size()); + } + + @Test + @DisplayName("Should sort properties by name in domain entity") + void toDomain_WithProperties_SortsPropertiesByName() { + UUID id = UUID.randomUUID(); + PropertyJpaEntity propZ = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("z-prop") + .value("z-value").build(); + PropertyJpaEntity propA = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("a-prop") + .value("a-value").build(); + PropertyJpaEntity propM = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("m-prop") + .value("m-value").build(); + Set properties = Set.of(propZ, propA, propM); + + EntityJpaEntity jpa = EntityJpaEntity.builder().id(id).templateIdentifier("template-id") + .name("entity-name").identifier("entity-id").properties(properties) + .relations(new HashSet<>()).build(); + + Entity domain = mapper.toDomain(jpa); + + assertEquals("a-prop", domain.properties().get(0).name()); + assertEquals("m-prop", domain.properties().get(1).name()); + assertEquals("z-prop", domain.properties().get(2).name()); + } + + @Test + @DisplayName("Should sort relations by name in domain entity") + void toDomain_WithRelations_SortsRelationsByName() { + UUID id = UUID.randomUUID(); + RelationJpaEntity relZ = RelationJpaEntity.builder().id(UUID.randomUUID()).name("z-rel") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + RelationJpaEntity relA = RelationJpaEntity.builder().id(UUID.randomUUID()).name("a-rel") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + RelationJpaEntity relM = RelationJpaEntity.builder().id(UUID.randomUUID()).name("m-rel") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + Set relations = Set.of(relZ, relA, relM); + + EntityJpaEntity jpa = EntityJpaEntity.builder().id(id).templateIdentifier("template-id") + .name("entity-name").identifier("entity-id").properties(new HashSet<>()) + .relations(relations).build(); + + Entity domain = mapper.toDomain(jpa); + + assertEquals("a-rel", domain.relations().get(0).name()); + assertEquals("m-rel", domain.relations().get(1).name()); + assertEquals("z-rel", domain.relations().get(2).name()); + } + + @Test + @DisplayName("Should handle null properties collection") + void toDomain_WithNullProperties_ReturnsEmptyList() { + UUID id = UUID.randomUUID(); + EntityJpaEntity jpa = EntityJpaEntity.builder().id(id).templateIdentifier("template-id") + .name("entity-name").identifier("entity-id").properties(null).relations(new HashSet<>()) + .build(); + + Entity domain = mapper.toDomain(jpa); + + assertNotNull(domain.properties()); + assertTrue(domain.properties().isEmpty()); + } + + @Test + @DisplayName("Should handle null relations collection") + void toDomain_WithNullRelations_ReturnsEmptyList() { + UUID id = UUID.randomUUID(); + EntityJpaEntity jpa = EntityJpaEntity.builder().id(id).templateIdentifier("template-id") + .name("entity-name").identifier("entity-id").properties(new HashSet<>()).relations(null) + .build(); + + Entity domain = mapper.toDomain(jpa); + + assertNotNull(domain.relations()); + assertTrue(domain.relations().isEmpty()); + } + + @Test + @DisplayName("Should handle empty properties and relations collections") + void toDomain_WithEmptyCollections_ReturnsEntity() { + UUID id = UUID.randomUUID(); + EntityJpaEntity jpa = EntityJpaEntity.builder().id(id).templateIdentifier("template-id") + .name("entity-name").identifier("entity-id").properties(new HashSet<>()) + .relations(new HashSet<>()).build(); + + Entity domain = mapper.toDomain(jpa); + + assertNotNull(domain); + assertTrue(domain.properties().isEmpty()); + assertTrue(domain.relations().isEmpty()); + } + } + + // ========================================================================= + // Entity Mapping Tests - toJpa() + // ========================================================================= + + @Nested + @DisplayName("Entity toJpa() Tests") + class EntityToJpaTests { + + @Test + @DisplayName("Should return null when domain entity is null") + void toJpa_WithNullDomainEntity_ReturnsNull() { + assertNull(mapper.toJpa((Entity) null)); + } + + @Test + @DisplayName("Should convert domain entity to JPA with properties and relations") + void toJpa_WithValidDomainEntity_ReturnsJpaEntity() { + UUID id = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("target-tmpl").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + Property prop = new Property(UUID.randomUUID(), "prop1", "value1"); + Relation rel = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of("target-id")); + Entity domain = new Entity(id, "template-id", "entity-name", "entity-id", List.of(prop), + List.of(rel)); + + long beforeCall = Instant.now().toEpochMilli(); + EntityJpaEntity jpa = mapper.toJpa(domain); + long afterCall = Instant.now().toEpochMilli(); + + assertNotNull(jpa); + assertEquals(id, jpa.getId()); + assertEquals("template-id", jpa.getTemplateIdentifier()); + assertEquals("entity-name", jpa.getName()); + assertEquals("entity-id", jpa.getIdentifier()); + assertTrue(jpa.getUpdatedAt() >= beforeCall && jpa.getUpdatedAt() <= afterCall); + assertEquals(1, jpa.getProperties().size()); + assertEquals(1, jpa.getRelations().size()); + } + + @Test + @DisplayName("Should convert domain list to JPA set for properties") + void toJpa_WithProperties_ConvertsToSet() { + UUID id = UUID.randomUUID(); + Property prop1 = new Property(UUID.randomUUID(), "prop1", "value1"); + Property prop2 = new Property(UUID.randomUUID(), "prop2", "value2"); + Entity domain = new Entity(id, "template-id", "entity-name", "entity-id", + List.of(prop1, prop2), List.of()); + + EntityJpaEntity jpa = mapper.toJpa(domain); + + assertEquals(2, jpa.getProperties().size()); + assertEquals(2, new HashSet<>(jpa.getProperties()).size()); + } + + @Test + @DisplayName("Should handle null properties in domain entity") + void toJpa_WithNullProperties_CreatesEmptySet() { + UUID id = UUID.randomUUID(); + Entity domain = new Entity(id, "template-id", "entity-name", "entity-id", null, List.of()); + + EntityJpaEntity jpa = mapper.toJpa(domain); + + assertNotNull(jpa.getProperties()); + assertTrue(jpa.getProperties().isEmpty()); + } + + @Test + @DisplayName("Should handle null relations in domain entity") + void toJpa_WithNullRelations_CreatesEmptySet() { + UUID id = UUID.randomUUID(); + Entity domain = new Entity(id, "template-id", "entity-name", "entity-id", List.of(), null); + + EntityJpaEntity jpa = mapper.toJpa(domain); + + assertNotNull(jpa.getRelations()); + assertTrue(jpa.getRelations().isEmpty()); + } + + @Test + @DisplayName("Should update timestamp in JPA entity") + void toJpa_UpdatesTimestamp() { + UUID id = UUID.randomUUID(); + Entity domain = new Entity(id, "template-id", "entity-name", "entity-id", List.of(), + List.of()); + + EntityJpaEntity jpa = mapper.toJpa(domain); + + assertTrue(jpa.getUpdatedAt() > 0); + } + } + + // ========================================================================= + // Entity Mapping Tests - toJpaWithMerge() + // ========================================================================= + + @Nested + @DisplayName("Entity toJpaWithMerge() Tests") + class EntityToJpaWithMergeTests { + + @Test + @DisplayName("Should return null when domain entity is null") + void toJpaWithMerge_WithNullDomainEntity_ReturnsNull() { + EntityJpaEntity existing = EntityJpaEntity.builder().id(UUID.randomUUID()).build(); + assertNull(mapper.toJpaWithMerge(null, existing)); + } + + @Test + @DisplayName("Should update existing JPA entity with domain data") + void toJpaWithMerge_WithValidDomainEntity_UpdatesExistingJpaEntity() { + UUID id = UUID.randomUUID(); + UUID existingId = UUID.randomUUID(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("old-template").name("old-name").identifier("old-id") + .properties(new HashSet<>()).relations(new HashSet<>()).build(); + + Entity domain = new Entity(id, "new-template", "new-name", "new-id", List.of(), List.of()); + + long beforeCall = Instant.now().toEpochMilli(); + EntityJpaEntity result = mapper.toJpaWithMerge(domain, existing); + long afterCall = Instant.now().toEpochMilli(); + + assertEquals(existingId, result.getId()); + assertEquals("new-template", result.getTemplateIdentifier()); + assertEquals("new-name", result.getName()); + assertEquals("new-id", result.getIdentifier()); + assertTrue(result.getUpdatedAt() >= beforeCall && result.getUpdatedAt() <= afterCall); + } + + @Test + @DisplayName("Should clear existing properties when domain has no properties") + void toJpaWithMerge_WithNullProperties_ClearsExistingProperties() { + UUID existingId = UUID.randomUUID(); + PropertyJpaEntity existingProp = PropertyJpaEntity.builder().id(UUID.randomUUID()) + .name("prop1").value("value1").build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id") + .properties(new HashSet<>(Set.of(existingProp))).relations(new HashSet<>()).build(); + + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", null, List.of()); + + mapper.toJpaWithMerge(domain, existing); + + assertTrue(existing.getProperties().isEmpty()); + } + + @Test + @DisplayName("Should clear existing properties when domain has empty properties") + void toJpaWithMerge_WithEmptyProperties_ClearsExistingProperties() { + UUID existingId = UUID.randomUUID(); + PropertyJpaEntity existingProp = PropertyJpaEntity.builder().id(UUID.randomUUID()) + .name("prop1").value("value1").build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id") + .properties(new HashSet<>(Set.of(existingProp))).relations(new HashSet<>()).build(); + + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), List.of()); + + mapper.toJpaWithMerge(domain, existing); + + assertTrue(existing.getProperties().isEmpty()); + } + + @Test + @DisplayName("Should clear existing relations when domain has no relations") + void toJpaWithMerge_WithNullRelations_ClearsExistingRelations() { + UUID existingId = UUID.randomUUID(); + RelationJpaEntity existingRel = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(existingRel))).build(); + + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), null); + + mapper.toJpaWithMerge(domain, existing); + + assertTrue(existing.getRelations().isEmpty()); + } + + @Test + @DisplayName("Should clear existing relations when domain has empty relations") + void toJpaWithMerge_WithEmptyRelations_ClearsExistingRelations() { + UUID existingId = UUID.randomUUID(); + RelationJpaEntity existingRel = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(existingRel))).build(); + + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), List.of()); + + mapper.toJpaWithMerge(domain, existing); + + assertTrue(existing.getRelations().isEmpty()); + } + } + + // ========================================================================= + // Property Mapping Tests - mergeProperties() + // ========================================================================= + + @Nested + @DisplayName("Property Merge Tests") + class PropertyMergeTests { + + @Test + @DisplayName("Should remove properties not present in domain entity") + void mergeProperties_RemovesPropertiesNotInDomain() { + UUID existingId = UUID.randomUUID(); + PropertyJpaEntity prop1 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop1") + .value("value1").build(); + PropertyJpaEntity prop2 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop2") + .value("value2").build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id") + .properties(new HashSet<>(Set.of(prop1, prop2))).relations(new HashSet<>()).build(); + + Property domainProp1 = new Property(UUID.randomUUID(), "prop1", "updated-value1"); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(domainProp1), + List.of()); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getProperties().size()); + assertTrue(existing.getProperties().stream().anyMatch(p -> p.getName().equals("prop1"))); + } + + @Test + @DisplayName("Should update value of existing property") + void mergeProperties_UpdatesExistingProperty() { + UUID existingId = UUID.randomUUID(); + PropertyJpaEntity prop1 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop1") + .value("old-value").build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id") + .properties(new HashSet<>(Set.of(prop1))).relations(new HashSet<>()).build(); + + Property domainProp1 = new Property(UUID.randomUUID(), "prop1", "new-value"); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(domainProp1), + List.of()); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getProperties().size()); + assertEquals("new-value", existing.getProperties().iterator().next().getValue()); + } + + @Test + @DisplayName("Should add new property not present in existing") + void mergeProperties_AddsNewProperty() { + UUID existingId = UUID.randomUUID(); + PropertyJpaEntity prop1 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop1") + .value("value1").build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id") + .properties(new HashSet<>(Set.of(prop1))).relations(new HashSet<>()).build(); + + Property domainProp1 = new Property(UUID.randomUUID(), "prop1", "value1"); + Property domainProp2 = new Property(UUID.randomUUID(), "prop2", "value2"); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", + List.of(domainProp1, domainProp2), List.of()); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(2, existing.getProperties().size()); + assertTrue(existing.getProperties().stream().anyMatch(p -> p.getName().equals("prop2"))); + } + + @Test + @DisplayName("Should handle mixed merge: remove, update, and add properties") + void mergeProperties_HandlesMixedOperations() { + UUID existingId = UUID.randomUUID(); + PropertyJpaEntity prop1 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop1") + .value("value1").build(); + PropertyJpaEntity prop2 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop2") + .value("value2").build(); + PropertyJpaEntity prop3 = PropertyJpaEntity.builder().id(UUID.randomUUID()).name("prop3") + .value("value3").build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id") + .properties(new HashSet<>(Set.of(prop1, prop2, prop3))).relations(new HashSet<>()) + .build(); + + Property domainProp1 = new Property(UUID.randomUUID(), "prop1", "updated-value1"); + Property domainProp4 = new Property(UUID.randomUUID(), "prop4", "value4"); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", + List.of(domainProp1, domainProp4), List.of()); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(2, existing.getProperties().size()); + assertTrue(existing.getProperties().stream() + .anyMatch(p -> p.getName().equals("prop1") && p.getValue().equals("updated-value1"))); + assertTrue(existing.getProperties().stream().anyMatch(p -> p.getName().equals("prop4"))); + } + } + + // ========================================================================= + // Relation Mapping Tests - mergeRelations() + // ========================================================================= + + @Nested + @DisplayName("Relation Merge Tests") + class RelationMergeTests { + + @Test + @DisplayName("Should remove relations not present in domain entity") + void mergeRelations_RemovesRelationsNotInDomain() { + UUID existingId = UUID.randomUUID(); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + RelationJpaEntity rel2 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel2") + .targetTemplateIdentifier("tmpl2").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1, rel2))).build(); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of()); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + assertTrue(existing.getRelations().stream().anyMatch(r -> r.getName().equals("rel1"))); + } + + @Test + @DisplayName("Should add new relation not present in existing") + void mergeRelations_AddsNewRelation() { + UUID existingId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("tmpl1").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of()); + Relation domainRel2 = new Relation(UUID.randomUUID(), "rel2", "tmpl1", List.of("target-id")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1, domainRel2)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(2, existing.getRelations().size()); + assertTrue(existing.getRelations().stream().anyMatch(r -> r.getName().equals("rel2"))); + } + + @Test + @DisplayName("Should not update relation when no change detected") + void mergeRelations_DoesNotUpdateUnchangedRelation() { + UUID existingId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + RelationTargetJpaEntity target = new RelationTargetJpaEntity(targetId, "target-id"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target))).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("tmpl1").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of("target-id")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals("tmpl1", updatedRel.getTargetTemplateIdentifier()); + } + + @Test + @DisplayName("Should update relation when target template identifier changes") + void mergeRelations_UpdatesRelationWhenTemplateIdentifierChanges() { + UUID existingId = UUID.randomUUID(); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("old-tmpl").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "new-tmpl", List.of()); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals("new-tmpl", updatedRel.getTargetTemplateIdentifier()); + } + + @Test + @DisplayName("Should update relation when target entities change in count") + void mergeRelations_UpdatesRelationWhenTargetCountChanges() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + UUID targetId2 = UUID.randomUUID(); + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl1").identifier("target-id-1").build(); + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-1", "target-id-2"))).thenReturn(List.of(targetEntity1, targetEntity2)); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-1", "target-id-2")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals(2, updatedRel.getTargetEntities().size()); + } + + @Test + @DisplayName("Should update relation when target entities content changes") + void mergeRelations_UpdatesRelationWhenTargetEntitiesChange() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + UUID targetId2 = UUID.randomUUID(); + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-2"))).thenReturn(List.of(targetEntity2)); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-2")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals(1, updatedRel.getTargetEntities().size()); + assertTrue(updatedRel.getTargetEntities().stream() + .anyMatch(t -> t.getTargetEntityIdentifier().equals("target-id-2"))); + } + } + + // ========================================================================= + // Relation Change Detection Tests - hasRelationChanged() + // ========================================================================= + + @Nested + @DisplayName("Relation Change Detection Tests") + class RelationChangeDetectionTests { + + @Test + @DisplayName("Should detect change when target template identifier differs") + void hasRelationChanged_DifferentTemplateIdentifier_ReturnsTrue() { + RelationTargetJpaEntity target = new RelationTargetJpaEntity(UUID.randomUUID(), "target-id"); + RelationJpaEntity existing = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("old-tmpl").targetEntities(new HashSet<>(Set.of(target))) + .build(); + + Relation domain = new Relation(UUID.randomUUID(), "rel1", "new-tmpl", List.of("target-id")); + + boolean changed = mapper.hasRelationChanged(existing, domain); + + assertTrue(changed); + } + + @Test + @DisplayName("Should detect change when target entities count differs") + void hasRelationChanged_DifferentTargetCount_ReturnsTrue() { + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity existing = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl1").identifier("target-id-1").build(); + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-1", "target-id-2"))).thenReturn(List.of(targetEntity1, targetEntity2)); + + Relation domain = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-1", "target-id-2")); + + boolean changed = mapper.hasRelationChanged(existing, domain); + + assertTrue(changed); + } + + @Test + @DisplayName("Should detect change when target entities content differs") + void hasRelationChanged_DifferentTargetContent_ReturnsTrue() { + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity existing = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-2"))).thenReturn(List.of(targetEntity2)); + + Relation domain = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of("target-id-2")); + + boolean changed = mapper.hasRelationChanged(existing, domain); + + assertTrue(changed); + } + + @Test + @DisplayName("Should not detect change when relation is identical") + void hasRelationChanged_IdenticalRelation_ReturnsFalse() { + UUID targetId = UUID.randomUUID(); + RelationTargetJpaEntity target = new RelationTargetJpaEntity(targetId, "target-id"); + RelationJpaEntity existing = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target))).build(); + + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("tmpl1").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + Relation domain = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of("target-id")); + + boolean changed = mapper.hasRelationChanged(existing, domain); + + assertFalse(changed); + } + + @Test + @DisplayName("Should handle null target entity identifiers in domain") + void hasRelationChanged_NullTargetIdentifiers_HandlesProperly() { + RelationJpaEntity existing = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + + Relation domain = new Relation(UUID.randomUUID(), "rel1", "tmpl1", null); + + boolean changed = mapper.hasRelationChanged(existing, domain); + + assertFalse(changed); + } + } + + // ========================================================================= + // Property Mapping Tests - toDomain() and toJpa() + // ========================================================================= + + @Nested + @DisplayName("Property Mapping Tests") + class PropertyMappingTests { + + @Test + @DisplayName("Should return null when JPA property is null") + void propertyToDomain_WithNullJpaProperty_ReturnsNull() { + assertNull(mapper.toDomain((PropertyJpaEntity) null)); + } + + @Test + @DisplayName("Should convert JPA property to domain property") + void propertyToDomain_WithValidJpaProperty_ReturnsDomainProperty() { + UUID id = UUID.randomUUID(); + PropertyJpaEntity jpa = PropertyJpaEntity.builder().id(id).name("prop-name") + .value("prop-value").build(); + + Property domain = mapper.toDomain(jpa); + + assertNotNull(domain); + assertEquals(id, domain.id()); + assertEquals("prop-name", domain.name()); + assertEquals("prop-value", domain.value()); + } + + @Test + @DisplayName("Should return null when domain property is null") + void propertyToJpa_WithNullDomainProperty_ReturnsNull() { + assertNull(mapper.toJpa((Property) null)); + } + + @Test + @DisplayName("Should convert domain property to JPA property") + void propertyToJpa_WithValidDomainProperty_ReturnsJpaProperty() { + UUID id = UUID.randomUUID(); + Property domain = new Property(id, "prop-name", "prop-value"); + + PropertyJpaEntity jpa = mapper.toJpa(domain); + + assertNotNull(jpa); + assertEquals(id, jpa.getId()); + assertEquals("prop-name", jpa.getName()); + assertEquals("prop-value", jpa.getValue()); + } + + @Test + @DisplayName("Should handle null property value") + void propertyMapping_WithNullValue_HandlesCorrectly() { + UUID id = UUID.randomUUID(); + Property domain = new Property(id, "prop-name", null); + + PropertyJpaEntity jpa = mapper.toJpa(domain); + + assertNull(jpa.getValue()); + } + } + + // ========================================================================= + // Relation Mapping Tests - toDomain() and toJpa() + // ========================================================================= + + @Nested + @DisplayName("Relation Mapping Tests") + class RelationMappingTests { + + @Test + @DisplayName("Should return null when JPA relation is null") + void relationToDomain_WithNullJpaRelation_ReturnsNull() { + assertNull(mapper.toDomain((RelationJpaEntity) null)); + } + + @Test + @DisplayName("Should convert JPA relation to domain relation with sorted identifiers") + void relationToDomain_WithValidJpaRelation_ReturnsDomainRelation() { + UUID id = UUID.randomUUID(); + RelationTargetJpaEntity targetZ = new RelationTargetJpaEntity(UUID.randomUUID(), "z-target"); + RelationTargetJpaEntity targetA = new RelationTargetJpaEntity(UUID.randomUUID(), "a-target"); + RelationTargetJpaEntity targetM = new RelationTargetJpaEntity(UUID.randomUUID(), "m-target"); + RelationJpaEntity jpa = RelationJpaEntity.builder().id(id).name("rel-name") + .targetTemplateIdentifier("target-tmpl") + .targetEntities(new HashSet<>(Set.of(targetZ, targetA, targetM))).build(); + + Relation domain = mapper.toDomain(jpa); + + assertNotNull(domain); + assertEquals(id, domain.id()); + assertEquals("rel-name", domain.name()); + assertEquals("target-tmpl", domain.targetTemplateIdentifier()); + assertEquals(3, domain.targetEntityIdentifiers().size()); + assertEquals("a-target", domain.targetEntityIdentifiers().get(0)); + assertEquals("m-target", domain.targetEntityIdentifiers().get(1)); + assertEquals("z-target", domain.targetEntityIdentifiers().get(2)); + } + + @Test + @DisplayName("Should handle null target entities in JPA relation") + void relationToDomain_WithNullTargetEntities_ReturnsEmptyList() { + UUID id = UUID.randomUUID(); + RelationJpaEntity jpa = RelationJpaEntity.builder().id(id).name("rel-name") + .targetTemplateIdentifier("target-tmpl").targetEntities(null).build(); + + Relation domain = mapper.toDomain(jpa); + + assertNotNull(domain.targetEntityIdentifiers()); + assertTrue(domain.targetEntityIdentifiers().isEmpty()); + } + + @Test + @DisplayName("Should filter out null target entity identifiers") + void relationToDomain_WithNullIdentifiers_FiltersThemOut() { + UUID id = UUID.randomUUID(); + RelationTargetJpaEntity validTarget = new RelationTargetJpaEntity(UUID.randomUUID(), + "valid-id"); + RelationTargetJpaEntity nullTarget = new RelationTargetJpaEntity(UUID.randomUUID(), null); + RelationJpaEntity jpa = RelationJpaEntity.builder().id(id).name("rel-name") + .targetTemplateIdentifier("target-tmpl") + .targetEntities(new HashSet<>(Set.of(validTarget, nullTarget))).build(); + + Relation domain = mapper.toDomain(jpa); + + assertEquals(1, domain.targetEntityIdentifiers().size()); + assertEquals("valid-id", domain.targetEntityIdentifiers().get(0)); + } + + @Test + @DisplayName("Should return null when domain relation is null") + void relationToJpa_WithNullDomainRelation_ReturnsNull() { + assertNull(mapper.toJpa((Relation) null)); + } + + @Test + @DisplayName("Should convert domain relation to JPA relation with resolved targets") + void relationToJpa_WithValidDomainRelation_ReturnsJpaRelation() { + UUID id = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("target-tmpl").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("target-tmpl", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + Relation domain = new Relation(id, "rel-name", "target-tmpl", List.of("target-id")); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertNotNull(jpa); + assertEquals(id, jpa.getId()); + assertEquals("rel-name", jpa.getName()); + assertEquals("target-tmpl", jpa.getTargetTemplateIdentifier()); + assertEquals(1, jpa.getTargetEntities().size()); + } + + @Test + @DisplayName("Should handle null target entity identifiers in domain relation") + void relationToJpa_WithNullTargetIdentifiers_CreatesEmptySet() { + UUID id = UUID.randomUUID(); + Relation domain = new Relation(id, "rel-name", "target-tmpl", null); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertNotNull(jpa.getTargetEntities()); + assertTrue(jpa.getTargetEntities().isEmpty()); + } + + @Test + @DisplayName("Should handle empty target entity identifiers") + void relationToJpa_WithEmptyTargetIdentifiers_CreatesEmptySet() { + UUID id = UUID.randomUUID(); + Relation domain = new Relation(id, "rel-name", "target-tmpl", List.of()); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertNotNull(jpa.getTargetEntities()); + assertTrue(jpa.getTargetEntities().isEmpty()); + } + } + + // ========================================================================= + // Batch Resolution Tests - resolveBatchTargetEntities() + // ========================================================================= + + @Nested + @DisplayName("Batch Target Entity Resolution Tests") + class BatchResolutionTests { + + @Test + @DisplayName("Should resolve multiple target identifiers in single batch query") + void resolveBatchTargetEntities_WithMultipleIdentifiers_ReturnsBatchResolution() { + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl").identifier("id-1").build(); + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl").identifier("id-2").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl", + List.of("id-1", "id-2"))).thenReturn(List.of(targetEntity1, targetEntity2)); + + Relation domain = new Relation(UUID.randomUUID(), "rel", "tmpl", List.of("id-1", "id-2")); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertEquals(2, jpa.getTargetEntities().size()); + assertTrue(jpa.getTargetEntities().stream() + .anyMatch(t -> t.getTargetEntityUuid().equals(targetId1))); + assertTrue(jpa.getTargetEntities().stream() + .anyMatch(t -> t.getTargetEntityUuid().equals(targetId2))); + verify(entityRepository, times(1)).findAllByTemplateIdentifierAndIdentifierIn("tmpl", + List.of("id-1", "id-2")); + } + + @Test + @DisplayName("Should return empty set when target identifiers list is null") + void resolveBatchTargetEntities_WithNullIdentifiers_ReturnsEmptySet() { + Relation domain = new Relation(UUID.randomUUID(), "rel", "tmpl", null); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertTrue(jpa.getTargetEntities().isEmpty()); + verify(entityRepository, never()).findAllByTemplateIdentifierAndIdentifierIn(any(), any()); + } + + @Test + @DisplayName("Should return empty set when target identifiers list is empty") + void resolveBatchTargetEntities_WithEmptyIdentifiers_ReturnsEmptySet() { + Relation domain = new Relation(UUID.randomUUID(), "rel", "tmpl", List.of()); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertTrue(jpa.getTargetEntities().isEmpty()); + verify(entityRepository, never()).findAllByTemplateIdentifierAndIdentifierIn(any(), any()); + } + + @Test + @DisplayName("Should preserve only successfully resolved entities") + void resolveBatchTargetEntities_WithPartialResolution_PreservesResolvedOnly() { + UUID targetId1 = UUID.randomUUID(); + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl").identifier("id-1").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl", + List.of("id-1", "id-2"))).thenReturn(List.of(targetEntity1)); + + Relation domain = new Relation(UUID.randomUUID(), "rel", "tmpl", List.of("id-1", "id-2")); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertEquals(1, jpa.getTargetEntities().size()); + assertEquals("id-1", jpa.getTargetEntities().iterator().next().getTargetEntityIdentifier()); + } + + @Test + @DisplayName("Should return empty set when no entities are resolved") + void resolveBatchTargetEntities_WithNoResolution_ReturnsEmptySet() { + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl", + List.of("id-1", "id-2"))).thenReturn(List.of()); + + Relation domain = new Relation(UUID.randomUUID(), "rel", "tmpl", List.of("id-1", "id-2")); + + RelationJpaEntity jpa = mapper.toJpa(domain); + + assertTrue(jpa.getTargetEntities().isEmpty()); + } + + @Test + @DisplayName("Should batch resolve during merge operations") + void resolveBatchTargetEntities_DuringMerge_UsesBatchResolution() { + UUID existingId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("tmpl1").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of("target-id")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + verify(entityRepository, atLeastOnce()).findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id")); + } + } + + // ========================================================================= + // Integration Tests - Full Entity Lifecycle + // ========================================================================= + + @Nested + @DisplayName("Integration Tests - Full Entity Lifecycle") + class IntegrationTests { + + @Test + @DisplayName("Should perform complete roundtrip: Domain -> JPA -> Domain") + void integrationTest_RoundTrip_PreservesData() { + UUID targetId = UUID.randomUUID(); + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("target-tmpl").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("target-tmpl", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + Property domainProp = new Property(UUID.randomUUID(), "prop1", "value1"); + Relation domainRel = new Relation(UUID.randomUUID(), "rel1", "target-tmpl", + List.of("target-id")); + Entity originalDomain = new Entity(UUID.randomUUID(), "tmpl-id", "entity-name", "entity-id", + List.of(domainProp), List.of(domainRel)); + + EntityJpaEntity jpa = mapper.toJpa(originalDomain); + Entity resultDomain = mapper.toDomain(jpa); + + assertEquals(originalDomain.id(), resultDomain.id()); + assertEquals(originalDomain.templateIdentifier(), resultDomain.templateIdentifier()); + assertEquals(originalDomain.name(), resultDomain.name()); + assertEquals(originalDomain.identifier(), resultDomain.identifier()); + assertEquals(originalDomain.properties().size(), resultDomain.properties().size()); + assertEquals(originalDomain.relations().size(), resultDomain.relations().size()); + } + + @Test + @DisplayName("Should handle complete merge lifecycle with multiple changes") + void integrationTest_MergeLifecycle_HandlesComplexScenario() { + UUID existingId = UUID.randomUUID(); + PropertyJpaEntity existingProp1 = PropertyJpaEntity.builder().id(UUID.randomUUID()) + .name("prop1").value("old-value").build(); + PropertyJpaEntity existingProp2 = PropertyJpaEntity.builder().id(UUID.randomUUID()) + .name("prop2").value("value2").build(); + RelationJpaEntity existingRel1 = RelationJpaEntity.builder().id(UUID.randomUUID()) + .name("rel1").targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("old-name").identifier("old-id") + .properties(new HashSet<>(Set.of(existingProp1, existingProp2))) + .relations(new HashSet<>(Set.of(existingRel1))).build(); + + UUID targetId = UUID.randomUUID(); + EntityJpaEntity targetEntity = EntityJpaEntity.builder().id(targetId) + .templateIdentifier("tmpl1").identifier("target-id").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id"))).thenReturn(List.of(targetEntity)); + + Property newProp1 = new Property(UUID.randomUUID(), "prop1", "new-value"); + Property newProp3 = new Property(UUID.randomUUID(), "prop3", "value3"); + Relation newRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of("target-id")); + Entity domain = new Entity(UUID.randomUUID(), "new-template", "new-name", "new-id", + List.of(newProp1, newProp3), List.of(newRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(existingId, existing.getId()); + assertEquals("new-template", existing.getTemplateIdentifier()); + assertEquals("new-name", existing.getName()); + assertEquals("new-id", existing.getIdentifier()); + assertEquals(2, existing.getProperties().size()); + assertTrue(existing.getProperties().stream() + .anyMatch(p -> p.getName().equals("prop1") && p.getValue().equals("new-value"))); + assertTrue(existing.getProperties().stream().anyMatch(p -> p.getName().equals("prop3"))); + assertEquals(1, existing.getRelations().size()); + } + + @Test + @DisplayName("Should handle complex scenario with null collections") + void integrationTest_WithNullCollections_HandlesGracefully() { + UUID existingId = UUID.randomUUID(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>()).build(); + + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", null, null); + + EntityJpaEntity result = mapper.toJpaWithMerge(domain, existing); + + assertNotNull(result); + assertTrue(result.getProperties().isEmpty()); + assertTrue(result.getRelations().isEmpty()); + } + } + + // ========================================================================= + // Additional Relation Merge Tests for Full Coverage + // ========================================================================= + + @Nested + @DisplayName("Advanced Relation Merge Scenarios") + class AdvancedRelationMergeTests { + + @Test + @DisplayName("Should add target entities to existing relation without duplicates") + void mergeRelations_AddTargetEntities_AvoidsDuplicates() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + UUID targetId3 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationTargetJpaEntity target2 = new RelationTargetJpaEntity(targetId2, "target-id-2"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1, target2))) + .build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl1").identifier("target-id-1").build(); + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + EntityJpaEntity targetEntity3 = EntityJpaEntity.builder().id(targetId3) + .templateIdentifier("tmpl1").identifier("target-id-3").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-1", "target-id-2", "target-id-3"))) + .thenReturn(List.of(targetEntity1, targetEntity2, targetEntity3)); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-1", "target-id-2", "target-id-3")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals(3, updatedRel.getTargetEntities().size()); + } + + @Test + @DisplayName("Should remove target entities from existing relation") + void mergeRelations_RemoveTargetEntities_Success() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationTargetJpaEntity target2 = new RelationTargetJpaEntity(targetId2, "target-id-2"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1, target2))) + .build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl1").identifier("target-id-1").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-1"))).thenReturn(List.of(targetEntity1)); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-1")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals(1, updatedRel.getTargetEntities().size()); + assertTrue(updatedRel.getTargetEntities().stream() + .anyMatch(t -> t.getTargetEntityIdentifier().equals("target-id-1"))); + } + + @Test + @DisplayName("Should clear all target entities when domain relation has empty targets") + void mergeRelations_ClearAllTargetEntities_Success() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of()); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertTrue(updatedRel.getTargetEntities().isEmpty()); + } + + @Test + @DisplayName("Should replace all target entities when domain relation has different targets") + void mergeRelations_ReplaceAllTargetEntities_Success() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-2"))).thenReturn(List.of(targetEntity2)); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-2")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals(1, updatedRel.getTargetEntities().size()); + assertTrue(updatedRel.getTargetEntities().stream() + .anyMatch(t -> t.getTargetEntityIdentifier().equals("target-id-2"))); + assertFalse(updatedRel.getTargetEntities().stream() + .anyMatch(t -> t.getTargetEntityIdentifier().equals("target-id-1"))); + } + + @Test + @DisplayName("Should handle mixed operations on relations: add, update, remove") + void mergeRelations_MixedOperations_Success() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + RelationJpaEntity rel2 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel2") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1, rel2))).build(); + + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl1").identifier("target-id-1").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-2"))).thenReturn(List.of(targetEntity2)); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl2", + List.of("target-id-1"))).thenReturn(List.of(targetEntity1)); + + // rel1: update target entities (change from id-1 to id-2) + // rel2: remove (not in domain) + // rel3: add new + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-2")); + Relation domainRel3 = new Relation(UUID.randomUUID(), "rel3", "tmpl2", + List.of("target-id-1")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1, domainRel3)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(2, existing.getRelations().size()); + assertTrue(existing.getRelations().stream().anyMatch(r -> r.getName().equals("rel1"))); + assertTrue(existing.getRelations().stream().anyMatch(r -> r.getName().equals("rel3"))); + assertFalse(existing.getRelations().stream().anyMatch(r -> r.getName().equals("rel2"))); + } + + @Test + @DisplayName("Should not update relation when target entities are identical regardless of order") + void mergeRelations_IdenticalTargetsDifferentOrder_NoUpdate() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationTargetJpaEntity target2 = new RelationTargetJpaEntity(targetId2, "target-id-2"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1, target2))) + .build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl1").identifier("target-id-1").build(); + EntityJpaEntity targetEntity2 = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-2", "target-id-1"))).thenReturn(List.of(targetEntity1, targetEntity2)); + + // Same targets, different order + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-2", "target-id-1")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertEquals(2, updatedRel.getTargetEntities().size()); + assertTrue(updatedRel.getTargetEntities().contains(target1)); + assertTrue(updatedRel.getTargetEntities().contains(target2)); + } + + @Test + @DisplayName("Should handle relation with null target identifiers during merge") + void mergeRelations_WithNullTargetIdentifiers_ClearsTargets() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1))).build(); + + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", null); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(1, existing.getRelations().size()); + RelationJpaEntity updatedRel = existing.getRelations().iterator().next(); + assertTrue(updatedRel.getTargetEntities().isEmpty()); + } + + @Test + @DisplayName("Should detect no change when both existing and domain have empty targets") + void hasRelationChanged_BothHaveEmptyTargets_ReturnsFalse() { + RelationJpaEntity existing = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>()).build(); + + Relation domain = new Relation(UUID.randomUUID(), "rel1", "tmpl1", List.of()); + + boolean changed = mapper.hasRelationChanged(existing, domain); + + assertFalse(changed); + } + + @Test + @DisplayName("Should handle multiple relations with various target entity changes") + void mergeRelations_MultipleRelationsVariousChanges_Success() { + UUID existingId = UUID.randomUUID(); + UUID targetId1 = UUID.randomUUID(); + UUID targetId2 = UUID.randomUUID(); + UUID targetId3 = UUID.randomUUID(); + + RelationTargetJpaEntity target1 = new RelationTargetJpaEntity(targetId1, "target-id-1"); + RelationTargetJpaEntity target2 = new RelationTargetJpaEntity(targetId2, "target-id-2"); + + RelationJpaEntity rel1 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel1") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target1))).build(); + RelationJpaEntity rel2 = RelationJpaEntity.builder().id(UUID.randomUUID()).name("rel2") + .targetTemplateIdentifier("tmpl1").targetEntities(new HashSet<>(Set.of(target2))).build(); + + EntityJpaEntity existing = EntityJpaEntity.builder().id(existingId) + .templateIdentifier("template").name("name").identifier("id").properties(new HashSet<>()) + .relations(new HashSet<>(Set.of(rel1, rel2))).build(); + + EntityJpaEntity targetEntity1 = EntityJpaEntity.builder().id(targetId1) + .templateIdentifier("tmpl1").identifier("target-id-1").build(); + EntityJpaEntity targetEntity2Resolved = EntityJpaEntity.builder().id(targetId2) + .templateIdentifier("tmpl1").identifier("target-id-2").build(); + EntityJpaEntity targetEntity3 = EntityJpaEntity.builder().id(targetId3) + .templateIdentifier("tmpl1").identifier("target-id-3").build(); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-1", "target-id-3"))).thenReturn(List.of(targetEntity1, targetEntity3)); + when(entityRepository.findAllByTemplateIdentifierAndIdentifierIn("tmpl1", + List.of("target-id-2"))).thenReturn(List.of(targetEntity2Resolved)); + + // rel1: add target-id-3 to existing target-id-1 + // rel2: keep unchanged (target-id-2) + Relation domainRel1 = new Relation(UUID.randomUUID(), "rel1", "tmpl1", + List.of("target-id-1", "target-id-3")); + Relation domainRel2 = new Relation(UUID.randomUUID(), "rel2", "tmpl1", + List.of("target-id-2")); + Entity domain = new Entity(UUID.randomUUID(), "template", "name", "id", List.of(), + List.of(domainRel1, domainRel2)); + + mapper.toJpaWithMerge(domain, existing); + + assertEquals(2, existing.getRelations().size()); + + RelationJpaEntity updatedRel1 = existing.getRelations().stream() + .filter(r -> r.getName().equals("rel1")).findFirst().orElse(null); + assertNotNull(updatedRel1); + assertEquals(2, updatedRel1.getTargetEntities().size()); + + RelationJpaEntity updatedRel2 = existing.getRelations().stream() + .filter(r -> r.getName().equals("rel2")).findFirst().orElse(null); + assertNotNull(updatedRel2); + assertEquals(1, updatedRel2.getTargetEntities().size()); + } + } +} diff --git a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java index 27ff96d..a325f10 100644 --- a/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java +++ b/src/test/java/com/decathlon/idp_core/infrastructure/adapters/persistence/model/audit/UserIdentityProviderHolderTest.java @@ -149,7 +149,7 @@ void shouldHandleConcurrentInitializationSafely() throws InterruptedException { @Test @DisplayName("Should allow concurrent reads after initialization") - void shouldAllowConcurrentReadsAfterInitialization() { + void shouldAllowConcurrentReadsAfterInitialization() throws InterruptedException { // Given var provider = mock(UserIdentityProvider.class); var holder = new UserIdentityProviderHolder(provider); @@ -157,6 +157,7 @@ void shouldAllowConcurrentReadsAfterInitialization() { var readCount = 100; var barrier = new java.util.concurrent.CyclicBarrier(readCount); + var latch = new java.util.concurrent.CountDownLatch(readCount); // When - Read from multiple threads for (int i = 0; i < readCount; i++) { @@ -167,10 +168,14 @@ void shouldAllowConcurrentReadsAfterInitialization() { assertThat(result).isEqualTo(provider); } catch (Exception e) { throw new RuntimeException(e); + } finally { + latch.countDown(); } }).start(); } - // Then - Verify provider is still accessible + + latch.await(); + assertThat(UserIdentityProviderHolder.getUserIdentityProvider()).isEqualTo(provider); } } 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 dd9de87..236c87f 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 @@ -96,8 +96,8 @@ INSERT INTO relation_definition (id, name, target_template_identifier, required, ('550e8400-e29b-41d4-a716-446655440052', 'downstream_services', 'service', false, true), -- Data relationships -('550e8400-e29b-41d4-a716-446655440053', 'database', 'database', false, false), -('550e8400-e29b-41d4-a716-446655440054', 'cache', 'cache', false, false), +('550e8400-e29b-41d4-a716-446655440053', 'database', 'database-service', false, false), +('550e8400-e29b-41d4-a716-446655440054', 'cache', 'cache-service', false, false), ('550e8400-e29b-41d4-a716-446655440055', 'message_queue', 'queue', false, true), ('550e8400-e29b-41d4-a716-446655440056', 'search_engine', 'search', false, false), @@ -117,7 +117,12 @@ INSERT INTO relation_definition (id, name, target_template_identifier, required, ('550e8400-e29b-41d4-a716-446655440065', 'file_storage', 'storage', false, false), -- Test relationship with required constraint -('550e8400-e29b-41d4-a716-446655440066', 'required_team', 'team', true, false); +('550e8400-e29b-41d4-a716-446655440066', 'required_team', 'team', true, false), + +-- Web-service to microservice links +('550e8400-e29b-41d4-a716-446655440067', 'api-link', 'microservice', false, true), +('550e8400-e29b-41d4-a716-446655440068', 'uses', 'web-service', false, true), +('550e8400-e29b-41d4-a716-446655440069', 'monitors', 'web-service', false, true); -- Insert diverse entity templates INSERT INTO entity_template (id, identifier, name, description) VALUES @@ -155,7 +160,10 @@ INSERT INTO entity_template_relations_definitions (entity_template_id, relations ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440054'), -- cache ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440057'), -- networks ('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440059'), -- monitoring -('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440061'); -- secrets +('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440061'), -- secrets +('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440067'), -- api-link +('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440068'), -- uses +('550e8400-e29b-41d4-a716-446655440070', '550e8400-e29b-41d4-a716-446655440069'); -- monitors -- Link microservice template (lightweight service) INSERT INTO entity_template_properties_definitions (entity_template_id, properties_definitions_id) VALUES diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_not_defined_in_template.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_not_defined_in_template.json new file mode 100644 index 0000000..72172f7 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_not_defined_in_template.json @@ -0,0 +1,16 @@ +{ + "name": "web-service-extra-property", + "identifier": "web-service-extra-property", + "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", + "status": "deprecated" + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_rules_not_respected.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_rules_not_respected.json new file mode 100644 index 0000000..3e69496 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_rules_not_respected.json @@ -0,0 +1,15 @@ +{ + "name": "web-service-invalid-rules", + "identifier": "web-service-invalid-rules", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_type_mismatch.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_type_mismatch.json new file mode 100644 index 0000000..215010c --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_property_type_mismatch.json @@ -0,0 +1,15 @@ +{ + "name": "web-service-invalid-type", + "identifier": "web-service-invalid-type", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_target_entity_does_not_exist.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_target_entity_does_not_exist.json new file mode 100644 index 0000000..942a98c --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_relation_target_entity_does_not_exist.json @@ -0,0 +1,21 @@ +{ + "name": "web-service-missing-relation-target", + "identifier": "web-service-missing-relation-target", + "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": "database", + "target_entity_identifiers": ["missing-database"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/postEntity_400_required_properties_missing.json b/src/test/resources/integration_test/json/entity/v1/postEntity_400_required_properties_missing.json new file mode 100644 index 0000000..43ecfe7 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/postEntity_400_required_properties_missing.json @@ -0,0 +1,7 @@ +{ + "name": "web-service-missing-required", + "identifier": "web-service-missing-required", + "properties": { + "port": "8080" + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/putEntity_200_update_remove_all_relations.json b/src/test/resources/integration_test/json/entity/v1/putEntity_200_update_remove_all_relations.json new file mode 100644 index 0000000..48ca67b --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/putEntity_200_update_remove_all_relations.json @@ -0,0 +1,15 @@ +{ + "name": "Web API Updated", + "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": [] +} diff --git a/src/test/resources/integration_test/json/entity/v1/putEntity_200_update_with_add_relation.json b/src/test/resources/integration_test/json/entity/v1/putEntity_200_update_with_add_relation.json new file mode 100644 index 0000000..370af0f --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/putEntity_200_update_with_add_relation.json @@ -0,0 +1,20 @@ +{ + "name": "Web API Updated", + "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": "database", + "target_entity_identifiers": ["database-service-1"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/putEntity_200_valid_update.json b/src/test/resources/integration_test/json/entity/v1/putEntity_200_valid_update.json new file mode 100644 index 0000000..e411ef8 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/putEntity_200_valid_update.json @@ -0,0 +1,14 @@ +{ + "name": "Web API 2 Updated", + "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" + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_not_defined_and_relation_target_missing.json b/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_not_defined_and_relation_target_missing.json new file mode 100644 index 0000000..4b289be --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_not_defined_and_relation_target_missing.json @@ -0,0 +1,21 @@ +{ + "name": "Web API 2 Updated", + "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", + "status": "deprecated" + }, + "relations": [ + { + "name": "database", + "target_entity_identifiers": ["missing-database"] + } + ] +} diff --git a/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_rules_not_respected.json b/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_rules_not_respected.json new file mode 100644 index 0000000..4a62c1c --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_rules_not_respected.json @@ -0,0 +1,14 @@ +{ + "name": "Web API 2 Updated", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "invalid-email", + "port": "80", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "invalid-url", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_type_mismatch.json b/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_type_mismatch.json new file mode 100644 index 0000000..5ddc2c2 --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/putEntity_400_property_type_mismatch.json @@ -0,0 +1,14 @@ +{ + "name": "Web API 2 Updated", + "properties": { + "applicationName": "catalog-api", + "ownerEmail": "owner@example.com", + "port": "not-a-number", + "environment": "DEV", + "version": "1.2.3", + "teamName": "platform-team", + "baseUrl": "https://catalog.example.com", + "protocol": "HTTP", + "programmingLanguage": "JAVA" + } +} diff --git a/src/test/resources/integration_test/json/entity/v1/putEntity_400_required_properties_missing.json b/src/test/resources/integration_test/json/entity/v1/putEntity_400_required_properties_missing.json new file mode 100644 index 0000000..5986fbb --- /dev/null +++ b/src/test/resources/integration_test/json/entity/v1/putEntity_400_required_properties_missing.json @@ -0,0 +1,6 @@ +{ + "name": "Web API 2 Updated", + "properties": { + "port": "8080" + } +}