diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/AttributeService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/AttributeService.kt index 02aed8145..c2d1720b6 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/AttributeService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/AttributeService.kt @@ -46,6 +46,7 @@ class AttributeService( JOIN temporal_entity_attribute ON entity_payload.entity_id = temporal_entity_attribute.entity_id AND temporal_entity_attribute.deleted_at IS NULL + WHERE entity_payload.deleted_at IS NULL ORDER BY attribute_name """.trimIndent() ).allToMappedList { rowToAttributeDetails(it) }.flatten().groupBy({ it.second }, { it.first }).toList() diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/EntityTypeService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/EntityTypeService.kt index 765e3c558..7a4d796c6 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/EntityTypeService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/discovery/service/EntityTypeService.kt @@ -45,7 +45,7 @@ class EntityTypeService( JOIN temporal_entity_attribute ON entity_payload.entity_id = temporal_entity_attribute.entity_id AND temporal_entity_attribute.deleted_at IS NULL - WHERE temporal_entity_attribute.deleted_at IS NULL + WHERE entity_payload.deleted_at IS NULL ORDER BY type """.trimIndent() ).allToMappedList { rowToEntityType(it) }.groupBy({ it.first }, { it.second }).toList() diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt index 8cb659d7c..57c88b15a 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityAttributeService.kt @@ -108,7 +108,7 @@ class EntityAttributeService( DO UPDATE SET deleted_at = null, attribute_type = :attribute_type, attribute_value_type = :attribute_value_type, - created_at = :created_at, + modified_at = :created_at, payload = :payload """.trimIndent() ) @@ -122,31 +122,6 @@ class EntityAttributeService( .bind("payload", attribute.payload) .execute() - @Transactional - suspend fun updateOnReplace( - attributeUUID: UUID, - attributeMetadata: AttributeMetadata, - modifiedAt: ZonedDateTime, - payload: String - ): Either = - databaseClient.sql( - """ - UPDATE temporal_entity_attribute - SET - attribute_type = :attribute_type, - attribute_value_type = :attribute_value_type, - modified_at = :modified_at, - payload = :payload - WHERE id = :id - """.trimIndent() - ) - .bind("id", attributeUUID) - .bind("attribute_type", attributeMetadata.type.toString()) - .bind("attribute_value_type", attributeMetadata.valueType.toString()) - .bind("modified_at", modifiedAt) - .bind("payload", Json.of(payload)) - .execute() - @Transactional suspend fun updateOnUpdate( attributeUUID: UUID, @@ -279,32 +254,15 @@ class EntityAttributeService( attributeMetadata.datasetId, attribute.entityId ) - updateOnReplace( - attribute.id, + deleteAttribute(attribute.entityId, attribute.attributeName, attribute.datasetId, false, createdAt).bind() + addAttribute( + attribute.entityId, + attribute.attributeName, attributeMetadata, createdAt, - serializeObject(attributePayload) + attributePayload, + sub ).bind() - - val attributeInstance = AttributeInstance( - attributeUuid = attribute.id, - timeProperty = AttributeInstance.TemporalProperty.MODIFIED_AT, - time = createdAt, - attributeMetadata = attributeMetadata, - payload = attributePayload, - sub = sub - ) - attributeInstanceService.create(attributeInstance).bind() - - if (attributeMetadata.observedAt != null) { - val attributeObservedAtInstance = AttributeInstance( - attributeUuid = attribute.id, - time = attributeMetadata.observedAt, - attributeMetadata = attributeMetadata, - payload = attributePayload - ) - attributeInstanceService.create(attributeObservedAtInstance).bind() - } } @Transactional @@ -437,6 +395,25 @@ class EntityAttributeService( attributeInstanceService.deleteInstancesOfAttribute(entityId, attributeName, datasetId).bind() } + @Transactional + suspend fun permanentlyDeleteAttributes( + entityId: URI, + ): Either = either { + logger.debug("Permanently deleting all attributes from entity {}", entityId) + + val deletedTeas = databaseClient.sql( + """ + DELETE FROM temporal_entity_attribute + WHERE entity_id = :entity_id + RETURNING id + """.trimIndent() + ) + .bind("entity_id", entityId) + .allToMappedList { toUuid(it["id"]) } + + attributeInstanceService.deleteInstancesOfEntity(deletedTeas).bind() + } + suspend fun getForEntities( entitiesIds: List, entitiesQuery: EntitiesQuery @@ -708,12 +685,10 @@ class EntityAttributeService( ngsiLdAttributeInstance.datasetId )!! if (currentAttribute != null) { - replaceAttribute( - currentAttribute, - ngsiLdAttribute, - attributeMetadata, + partialUpdateAttribute( + entityUri, + Pair(ngsiLdAttribute.name, listOf(attributePayload)), createdAt, - attributePayload, sub ).map { UpdateAttributeResult( diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt index 34b4907a3..006513443 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityQueryService.kt @@ -10,6 +10,7 @@ import com.egm.stellio.search.authorization.service.AuthorizationService import com.egm.stellio.search.common.util.allToMappedList import com.egm.stellio.search.common.util.deserializeAsMap import com.egm.stellio.search.common.util.oneToResult +import com.egm.stellio.search.common.util.toOptionalZonedDateTime import com.egm.stellio.search.common.util.toUri import com.egm.stellio.search.common.util.wrapToAndClause import com.egm.stellio.search.entity.model.EntitiesQuery @@ -18,18 +19,17 @@ import com.egm.stellio.search.entity.model.EntitiesQueryFromPost import com.egm.stellio.search.entity.model.Entity import com.egm.stellio.search.entity.util.rowToEntity import com.egm.stellio.shared.model.APIException -import com.egm.stellio.shared.model.AlreadyExistsException import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.ResourceNotFoundException import com.egm.stellio.shared.util.Sub import com.egm.stellio.shared.util.buildQQuery import com.egm.stellio.shared.util.buildScopeQQuery import com.egm.stellio.shared.util.buildTypeQuery -import com.egm.stellio.shared.util.entityAlreadyExistsMessage import com.egm.stellio.shared.util.entityNotFoundMessage import org.springframework.r2dbc.core.DatabaseClient import org.springframework.stereotype.Service import java.net.URI +import java.time.ZonedDateTime @Service class EntityQueryService( @@ -83,6 +83,7 @@ class EntityQueryService( LEFT JOIN temporal_entity_attribute tea ON tea.entity_id = entity_payload.entity_id AND tea.deleted_at is null WHERE $filterQuery + AND entity_payload.deleted_at is null ORDER BY entity_id LIMIT :limit OFFSET :offset @@ -108,6 +109,7 @@ class EntityQueryService( LEFT JOIN temporal_entity_attribute tea ON tea.entity_id = entity_payload.entity_id AND tea.deleted_at is null WHERE $filterQuery + AND entity_payload.deleted_at is null """.trimIndent() return databaseClient @@ -232,10 +234,7 @@ class EntityQueryService( .bind("entities_ids", entitiesIds) .allToMappedList { it.rowToEntity() } - suspend fun checkEntityExistence( - entityId: URI, - inverse: Boolean = false - ): Either { + suspend fun checkEntityExistence(entityId: URI): Either { val selectQuery = """ select @@ -243,6 +242,7 @@ class EntityQueryService( select 1 from entity_payload where entity_id = :entity_id + and deleted_at is null ) as entityExists; """.trimIndent() @@ -251,15 +251,34 @@ class EntityQueryService( .bind("entity_id", entityId) .oneToResult { it["entityExists"] as Boolean } .flatMap { - if (it && !inverse || !it && inverse) + if (it) Unit.right() - else if (it) - AlreadyExistsException(entityAlreadyExistsMessage(entityId.toString())).left() else ResourceNotFoundException(entityNotFoundMessage(entityId.toString())).left() } } + /** + * Used for checks before creating a (temporal) entity. Allows to know if the entity does not exist, + * or, if it exists, whether it is currently deleted (in which case, it may be possible to create it again + * if authorized) + */ + suspend fun getEntityState( + entityId: URI + ): Either> { + val selectQuery = + """ + select entity_id, deleted_at + from entity_payload + where entity_id = :entity_id + """.trimIndent() + + return databaseClient + .sql(selectQuery) + .bind("entity_id", entityId) + .oneToResult { Pair(toUri(it["entity_id"]), toOptionalZonedDateTime(it["deleted_at"])) } + } + suspend fun filterExistingEntitiesAsIds(entitiesIds: List): List { if (entitiesIds.isEmpty()) { return emptyList() @@ -270,6 +289,7 @@ class EntityQueryService( select entity_id from entity_payload where entity_id in (:entities_ids) + and deleted_at is null """.trimIndent() return databaseClient diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt index 98b4bba91..d97914c84 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/entity/service/EntityService.kt @@ -1,6 +1,9 @@ package com.egm.stellio.search.entity.service import arrow.core.Either +import arrow.core.Either.Left +import arrow.core.Either.Right +import arrow.core.left import arrow.core.raise.either import arrow.core.right import arrow.core.toOption @@ -24,7 +27,9 @@ import com.egm.stellio.search.entity.model.updateResultFromDetailedResult import com.egm.stellio.search.entity.util.prepareAttributes import com.egm.stellio.search.entity.util.rowToEntity import com.egm.stellio.search.scope.ScopeService +import com.egm.stellio.search.temporal.model.AttributeInstance.TemporalProperty import com.egm.stellio.shared.model.APIException +import com.egm.stellio.shared.model.AlreadyExistsException import com.egm.stellio.shared.model.ExpandedAttribute import com.egm.stellio.shared.model.ExpandedAttributeInstances import com.egm.stellio.shared.model.ExpandedAttributes @@ -39,6 +44,7 @@ import com.egm.stellio.shared.util.JsonLdUtils.JSONLD_TYPE import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_SCOPE_PROPERTY import com.egm.stellio.shared.util.JsonUtils.serializeObject import com.egm.stellio.shared.util.Sub +import com.egm.stellio.shared.util.entityAlreadyExistsMessage import com.egm.stellio.shared.util.getSpecificAccessPolicy import com.egm.stellio.shared.util.ngsiLdDateTime import io.r2dbc.postgresql.codec.Json @@ -68,8 +74,16 @@ class EntityService( expandedEntity: ExpandedEntity, sub: Sub? = null ): Either = either { - authorizationService.userCanCreateEntities(sub.toOption()).bind() - entityQueryService.checkEntityExistence(ngsiLdEntity.id, true).bind() + entityQueryService.getEntityState(ngsiLdEntity.id).let { + when (it) { + is Left -> authorizationService.userCanCreateEntities(sub.toOption()).bind() + is Right -> + if (it.value.second == null) + AlreadyExistsException(entityAlreadyExistsMessage(ngsiLdEntity.id.toString())).left().bind() + else + authorizationService.userCanAdminEntity(ngsiLdEntity.id, sub.toOption()).bind() + } + } val createdAt = ngsiLdDateTime() val attributesMetadata = ngsiLdEntity.prepareAttributes().bind() @@ -104,6 +118,13 @@ class EntityService( """ INSERT INTO entity_payload (entity_id, types, scopes, created_at, payload, specific_access_policy) VALUES (:entity_id, :types, :scopes, :created_at, :payload, :specific_access_policy) + ON CONFLICT (entity_id) + DO UPDATE SET types = :types, + scopes = :scopes, + modified_at = :created_at, + deleted_at = null, + payload = :payload, + specific_access_policy = :specific_access_policy """.trimIndent() ) .bind("entity_id", ngsiLdEntity.id) @@ -545,27 +566,55 @@ class EntityService( } @Transactional - suspend fun upsertEntityPayload(entityId: URI, payload: String): Either = - databaseClient.sql( + suspend fun deleteEntity(entityId: URI, sub: Sub? = null): Either = either { + entityQueryService.checkEntityExistence(entityId).bind() + authorizationService.userCanAdminEntity(entityId, sub.toOption()).bind() + + val deletedAt = ngsiLdDateTime() + val entity = deleteEntityPayload(entityId, deletedAt).bind() + entityAttributeService.deleteAttributes(entityId, deletedAt).bind() + scopeService.addHistoryEntry(entityId, emptyList(), TemporalProperty.DELETED_AT, deletedAt, sub).bind() + + entityEventService.publishEntityDeleteEvent(sub, entity) + } + + @Transactional + suspend fun deleteEntityPayload(entityId: URI, deletedAt: ZonedDateTime): Either = either { + val entity = databaseClient.sql( """ - INSERT INTO entity_payload (entity_id, payload) - VALUES (:entity_id, :payload) - ON CONFLICT (entity_id) - DO UPDATE SET payload = :payload + WITH entity_before_delete AS ( + SELECT * + FROM entity_payload + WHERE entity_id = :entity_id + ), + update_entity AS ( + UPDATE entity_payload + SET deleted_at = :deleted_at, + payload = null, + scopes = null, + specific_access_policy = null, + types = '{}' + WHERE entity_id = :entity_id + ) + SELECT * FROM entity_before_delete """.trimIndent() ) - .bind("payload", Json.of(payload)) .bind("entity_id", entityId) - .execute() + .bind("deleted_at", deletedAt) + .oneToResult { + it.rowToEntity() + } + .bind() + entity + } @Transactional - suspend fun deleteEntity(entityId: URI, sub: Sub? = null): Either = either { + suspend fun permanentlyDeleteEntity(entityId: URI, sub: Sub? = null): Either = either { entityQueryService.checkEntityExistence(entityId).bind() authorizationService.userCanAdminEntity(entityId, sub.toOption()).bind() - val entity = deleteEntityPayload(entityId).bind() - - entityAttributeService.deleteAttributes(entityId, ngsiLdDateTime()).bind() + val entity = permanentyDeleteEntityPayload(entityId).bind() + entityAttributeService.permanentlyDeleteAttributes(entityId).bind() scopeService.deleteHistory(entityId).bind() authorizationService.removeRightsOnEntity(entityId).bind() @@ -573,7 +622,7 @@ class EntityService( } @Transactional - suspend fun deleteEntityPayload(entityId: URI): Either = either { + suspend fun permanentyDeleteEntityPayload(entityId: URI): Either = either { val entity = databaseClient.sql( """ DELETE FROM entity_payload diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalService.kt b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalService.kt index 67a2bf7ba..f760a2af0 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalService.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/service/TemporalService.kt @@ -1,6 +1,8 @@ package com.egm.stellio.search.temporal.service import arrow.core.Either +import arrow.core.Either.Left +import arrow.core.Either.Right import arrow.core.raise.either import arrow.core.toOption import com.egm.stellio.search.authorization.service.AuthorizationService @@ -11,11 +13,13 @@ import com.egm.stellio.shared.model.ExpandedAttribute import com.egm.stellio.shared.model.ExpandedAttributes import com.egm.stellio.shared.model.ExpandedEntity import com.egm.stellio.shared.model.ExpandedTerm +import com.egm.stellio.shared.model.NgsiLdEntity import com.egm.stellio.shared.model.addCoreMembers import com.egm.stellio.shared.model.getMemberValueAsDateTime import com.egm.stellio.shared.model.toNgsiLdEntity import com.egm.stellio.shared.util.JsonLdUtils.NGSILD_OBSERVED_AT_PROPERTY import com.egm.stellio.shared.util.Sub +import com.egm.stellio.shared.util.ngsiLdDateTime import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.net.URI @@ -35,25 +39,28 @@ class TemporalService( jsonLdTemporalEntity: ExpandedEntity, sub: Sub? = null ): Either = either { - val entityDoesNotExist = entityQueryService.checkEntityExistence(entityId, true).isRight() - - if (entityDoesNotExist) { - createTemporalEntity( - entityId, - jsonLdTemporalEntity, - jsonLdTemporalEntity.getAttributes().sorted(), - sub - ).bind() - - CreateOrUpdateResult.CREATED - } else { - upsertTemporalEntity( - entityId, - jsonLdTemporalEntity.getAttributes().sorted(), - sub - ).bind() - - CreateOrUpdateResult.UPSERTED + entityQueryService.getEntityState(entityId).let { + when (it) { + is Left -> { + createTemporalEntity( + entityId, + jsonLdTemporalEntity, + jsonLdTemporalEntity.getAttributes().sorted(), + sub + ).bind() + CreateOrUpdateResult.CREATED + } + is Right -> { + upsertTemporalEntity( + entityId, + jsonLdTemporalEntity, + jsonLdTemporalEntity.getAttributes().sorted(), + it.value.second != null, + sub + ).bind() + CreateOrUpdateResult.UPSERTED + } + } } } @@ -65,14 +72,7 @@ class TemporalService( ): Either = either { authorizationService.userCanCreateEntities(sub.toOption()).bind() - // create a view of the entity containing only the most recent instance of each attribute - val expandedEntity = ExpandedEntity( - sortedJsonLdInstances - .keepFirstInstances() - .addCoreMembers(jsonLdTemporalEntity.id, jsonLdTemporalEntity.types) - ) - val ngsiLdEntity = expandedEntity.toNgsiLdEntity().bind() - + val (expandedEntity, ngsiLdEntity) = parseExpandedInstances(sortedJsonLdInstances, jsonLdTemporalEntity).bind() entityService.createEntity(ngsiLdEntity, expandedEntity, sub).bind() entityService.upsertAttributes( entityId, @@ -84,10 +84,17 @@ class TemporalService( internal suspend fun upsertTemporalEntity( entityId: URI, + jsonLdTemporalEntity: ExpandedEntity, sortedJsonLdInstances: ExpandedAttributes, + isDeleted: Boolean, sub: Sub? = null ): Either = either { authorizationService.userCanUpdateEntity(entityId, sub.toOption()).bind() + if (isDeleted) { + val (expandedEntity, ngsiLdEntity) = + parseExpandedInstances(sortedJsonLdInstances, jsonLdTemporalEntity).bind() + entityService.createEntityPayload(ngsiLdEntity, expandedEntity, ngsiLdDateTime(), sub).bind() + } entityService.upsertAttributes( entityId, sortedJsonLdInstances, @@ -95,6 +102,21 @@ class TemporalService( ).bind() } + private suspend fun parseExpandedInstances( + sortedJsonLdInstances: ExpandedAttributes, + jsonLdTemporalEntity: ExpandedEntity + ): Either> = either { + // create a view of the entity containing only the most recent instance of each attribute + val expandedEntity = ExpandedEntity( + sortedJsonLdInstances + .keepFirstInstances() + .addCoreMembers(jsonLdTemporalEntity.id, jsonLdTemporalEntity.types) + ) + val ngsiLdEntity = expandedEntity.toNgsiLdEntity().bind() + + Pair(expandedEntity, ngsiLdEntity) + } + private fun ExpandedAttributes.keepFirstInstances(): ExpandedAttributes = this.mapValues { listOf(it.value.first()) } @@ -125,6 +147,14 @@ class TemporalService( ).bind() } + @Transactional + suspend fun deleteEntity( + entityId: URI, + sub: Sub? = null + ): Either = either { + entityService.permanentlyDeleteEntity(entityId, sub).bind() + } + @Transactional suspend fun deleteAttribute( entityId: URI, diff --git a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandler.kt b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandler.kt index 22a9a9d06..2b176ef34 100644 --- a/search-service/src/main/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandler.kt +++ b/search-service/src/main/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandler.kt @@ -4,7 +4,6 @@ import arrow.core.Either import arrow.core.left import arrow.core.raise.either import arrow.core.right -import com.egm.stellio.search.entity.service.EntityService import com.egm.stellio.search.temporal.service.TemporalQueryService import com.egm.stellio.search.temporal.service.TemporalService import com.egm.stellio.search.temporal.util.composeTemporalEntitiesQueryFromGet @@ -64,7 +63,6 @@ import java.net.URI class TemporalEntityHandler( private val temporalService: TemporalService, private val temporalQueryService: TemporalQueryService, - private val entityService: EntityService, private val applicationProperties: ApplicationProperties ) : BaseHandler() { @@ -277,7 +275,7 @@ class TemporalEntityHandler( ): ResponseEntity<*> = either { val sub = getSubFromSecurityContext() - entityService.deleteEntity(entityId, sub.getOrNull()).bind() + temporalService.deleteEntity(entityId, sub.getOrNull()).bind() ResponseEntity.status(HttpStatus.NO_CONTENT).build() }.fold( diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt index 61a4416c9..92d3a76a9 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityAttributeServiceTests.kt @@ -261,6 +261,7 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer { val rawEntity = loadSampleData() coEvery { attributeInstanceService.create(any()) } returns Unit.right() + coEvery { attributeInstanceService.addDeletedAttributeInstance(any(), any(), any()) } returns Unit.right() entityAttributeService.createAttributes(rawEntity, APIC_COMPOUND_CONTEXTS) .shouldSucceed() @@ -522,6 +523,7 @@ class EntityAttributeServiceTests : WithTimescaleContainer, WithKafkaContainer { val rawEntity = loadSampleData() coEvery { attributeInstanceService.create(any()) } returns Unit.right() + coEvery { attributeInstanceService.addDeletedAttributeInstance(any(), any(), any()) } returns Unit.right() entityAttributeService.createAttributes(rawEntity, APIC_COMPOUND_CONTEXTS) .shouldSucceed() diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityQueryServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityQueryServiceTests.kt index df9932e5a..65d4789de 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityQueryServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityQueryServiceTests.kt @@ -7,7 +7,6 @@ import com.egm.stellio.search.entity.model.Entity import com.egm.stellio.search.support.WithKafkaContainer import com.egm.stellio.search.support.WithTimescaleContainer import com.egm.stellio.search.support.buildDefaultQueryParams -import com.egm.stellio.shared.model.AlreadyExistsException import com.egm.stellio.shared.model.ResourceNotFoundException import com.egm.stellio.shared.util.AUTHZ_TEST_COMPOUND_CONTEXTS import com.egm.stellio.shared.util.AuthContextModel.SpecificAccessPolicy.AUTH_READ @@ -29,6 +28,7 @@ import kotlinx.coroutines.test.runTest import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -211,7 +211,7 @@ class EntityQueryServiceTests : WithTimescaleContainer, WithKafkaContainer { } @Test - fun `it should check the existence or non-existence of an entity`() = runTest { + fun `it should check the existence of an entity`() = runTest { loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)) .sampleDataToNgsiLdEntity() .map { @@ -225,8 +225,26 @@ class EntityQueryServiceTests : WithTimescaleContainer, WithKafkaContainer { entityQueryService.checkEntityExistence(entity01Uri).shouldSucceed() entityQueryService.checkEntityExistence(entity02Uri) .shouldFail { assert(it is ResourceNotFoundException) } - entityQueryService.checkEntityExistence(entity01Uri, true) - .shouldFail { assert(it is AlreadyExistsException) } - entityQueryService.checkEntityExistence(entity02Uri, true).shouldSucceed() + } + + @Test + fun `it should check the state of an entity`() = runTest { + loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)) + .sampleDataToNgsiLdEntity() + .map { + entityService.createEntityPayload( + it.second, + it.first, + now + ) + } + + entityQueryService.getEntityState(entity01Uri) + .shouldSucceedWith { + assertEquals(entity01Uri, it.first) + assertNull(it.second) + } + entityQueryService.getEntityState(entity02Uri) + .shouldFail { assert(it is ResourceNotFoundException) } } } diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt index 9375f692d..0b4ad3e07 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/entity/service/EntityServiceTests.kt @@ -1,5 +1,6 @@ package com.egm.stellio.search.entity.service +import arrow.core.left import arrow.core.right import arrow.core.toOption import com.egm.stellio.search.authorization.service.AuthorizationService @@ -9,9 +10,10 @@ import com.egm.stellio.search.entity.model.Entity import com.egm.stellio.search.entity.model.UpdateOperationResult import com.egm.stellio.search.entity.model.UpdateResult import com.egm.stellio.search.entity.model.UpdatedDetails -import com.egm.stellio.search.support.EMPTY_PAYLOAD import com.egm.stellio.search.support.WithKafkaContainer import com.egm.stellio.search.support.WithTimescaleContainer +import com.egm.stellio.shared.model.AccessDeniedException +import com.egm.stellio.shared.model.AlreadyExistsException import com.egm.stellio.shared.util.APIARY_TYPE import com.egm.stellio.shared.util.APIC_COMPOUND_CONTEXTS import com.egm.stellio.shared.util.BEEHIVE_TYPE @@ -27,6 +29,7 @@ import com.egm.stellio.shared.util.loadMinimalEntity import com.egm.stellio.shared.util.loadSampleData import com.egm.stellio.shared.util.ngsiLdDateTime import com.egm.stellio.shared.util.sampleDataToNgsiLdEntity +import com.egm.stellio.shared.util.shouldFail import com.egm.stellio.shared.util.shouldSucceed import com.egm.stellio.shared.util.shouldSucceedAndResult import com.egm.stellio.shared.util.shouldSucceedWith @@ -38,14 +41,13 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest -import org.springframework.dao.DataIntegrityViolationException import org.springframework.data.r2dbc.core.R2dbcEntityTemplate import org.springframework.data.r2dbc.core.delete import org.springframework.test.context.ActiveProfiles @@ -145,7 +147,7 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer { } @Test - fun `it should not create an entity payload if one already existed`() = runTest { + fun `it should not create an entity payload if one already exists`() = runTest { val (jsonLdEntity, ngsiLdEntity) = loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)).sampleDataToNgsiLdEntity().shouldSucceedAndResult() entityService.createEntityPayload( @@ -154,13 +156,11 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer { now, ) - assertThrows { - entityService.createEntityPayload( - ngsiLdEntity, - jsonLdEntity, - now, - ) - } + entityService.createEntity( + ngsiLdEntity, + jsonLdEntity, + sub, + ).shouldFail { assertInstanceOf(AlreadyExistsException::class.java, it) } } @Test @@ -459,51 +459,57 @@ class EntityServiceTests : WithTimescaleContainer, WithKafkaContainer { } @Test - fun `it should upsert an entity payload if one already existed`() = runTest { - loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)) - .sampleDataToNgsiLdEntity() - .map { - entityService.createEntityPayload( - it.second, - it.first, - now - ) + fun `it should allow to create an entity over a deleted one if authorized`() = runTest { + coEvery { entityAttributeService.deleteAttributes(any(), any()) } returns Unit.right() + coEvery { authorizationService.userCanAdminEntity(any(), any()) } returns Unit.right() + + val (expandedEntity, ngsiLdEntity) = + loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)) + .sampleDataToNgsiLdEntity() + .shouldSucceedAndResult() + + entityService.createEntityPayload( + ngsiLdEntity, + expandedEntity, + now + ) + + entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime()) + .shouldSucceedWith { + assertEquals(entity01Uri, it.entityId) + assertNotNull(it.payload) } - entityService.upsertEntityPayload(entity01Uri, EMPTY_PAYLOAD) + entityService.createEntity(ngsiLdEntity, expandedEntity, null) .shouldSucceed() } @Test - fun `it should delete an entity payload`() = runTest { + fun `it should not allow to create an entity over a deleted one if not authorized`() = runTest { coEvery { entityAttributeService.deleteAttributes(any(), any()) } returns Unit.right() + coEvery { + authorizationService.userCanAdminEntity(any(), any()) + } returns AccessDeniedException("Unauthorized").left() - loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)) - .sampleDataToNgsiLdEntity() - .map { - entityService.createEntityPayload( - it.second, - it.first, - now - ) - } + val (expandedEntity, ngsiLdEntity) = + loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)) + .sampleDataToNgsiLdEntity() + .shouldSucceedAndResult() - entityService.deleteEntityPayload(entity01Uri) + entityService.createEntityPayload( + ngsiLdEntity, + expandedEntity, + now + ) + + entityService.deleteEntityPayload(entity01Uri, ngsiLdDateTime()) .shouldSucceedWith { assertEquals(entity01Uri, it.entityId) assertNotNull(it.payload) } - // if correctly deleted, we should be able to create a new one - loadMinimalEntity(entity01Uri, setOf(BEEHIVE_TYPE)) - .sampleDataToNgsiLdEntity() - .map { - entityService.createEntityPayload( - it.second, - it.first, - now - ).shouldSucceed() - } + entityService.createEntity(ngsiLdEntity, expandedEntity, null) + .shouldFail { assertInstanceOf(AccessDeniedException::class.java, it) } } @Test diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTestCommon.kt b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTestCommon.kt index 0102fd19a..f2462beff 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTestCommon.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTestCommon.kt @@ -1,7 +1,6 @@ package com.egm.stellio.search.temporal.web import com.egm.stellio.search.common.config.SearchProperties -import com.egm.stellio.search.entity.service.EntityService import com.egm.stellio.search.temporal.service.TemporalQueryService import com.egm.stellio.search.temporal.service.TemporalService import com.egm.stellio.shared.config.ApplicationProperties @@ -30,9 +29,6 @@ open class TemporalEntityHandlerTestCommon { @MockkBean(relaxed = true) protected lateinit var temporalQueryService: TemporalQueryService - @MockkBean - protected lateinit var entityService: EntityService - @BeforeAll fun configureWebClientDefaults() { webClient = webClient.mutate() diff --git a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTests.kt b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTests.kt index ee9758f66..73d2f7541 100644 --- a/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTests.kt +++ b/search-service/src/test/kotlin/com/egm/stellio/search/temporal/web/TemporalEntityHandlerTests.kt @@ -972,7 +972,7 @@ class TemporalEntityHandlerTests : TemporalEntityHandlerTestCommon() { @Test fun `delete temporal entity should return a 204 if an entity has been successfully deleted`() { - coEvery { entityService.deleteEntity(any(), any()) } returns Unit.right() + coEvery { temporalService.deleteEntity(any(), any()) } returns Unit.right() webClient.delete() .uri("/ngsi-ld/v1/temporal/entities/$entityUri") @@ -981,14 +981,14 @@ class TemporalEntityHandlerTests : TemporalEntityHandlerTestCommon() { .expectBody().isEmpty coVerify { - entityService.deleteEntity(eq(entityUri), eq(sub.value)) + temporalService.deleteEntity(eq(entityUri), eq(sub.value)) } } @Test fun `delete temporal entity should return a 404 if entity to be deleted has not been found`() { coEvery { - entityService.deleteEntity(entityUri, sub.getOrNull()) + temporalService.deleteEntity(entityUri, sub.getOrNull()) } returns ResourceNotFoundException(entityNotFoundMessage(entityUri.toString())).left() webClient.delete() @@ -1034,7 +1034,7 @@ class TemporalEntityHandlerTests : TemporalEntityHandlerTestCommon() { @Test fun `delete temporal entity should return a 500 if entity could not be deleted`() { coEvery { - entityService.deleteEntity(any(), any()) + temporalService.deleteEntity(any(), any()) } throws RuntimeException("Unexpected server error") webClient.delete() @@ -1055,7 +1055,7 @@ class TemporalEntityHandlerTests : TemporalEntityHandlerTestCommon() { @Test fun `delete temporal entity should return a 403 is user is not authorized to delete an entity`() { coEvery { - entityService.deleteEntity(any(), any()) + temporalService.deleteEntity(any(), any()) } returns AccessDeniedException("User forbidden admin access to entity $entityUri").left() webClient.delete()