diff --git a/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt index cf6c26e..4dae797 100644 --- a/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt +++ b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt @@ -13,6 +13,7 @@ import com.lightswitch.presentation.model.flag.variantPairs import jakarta.persistence.EntityNotFoundException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.time.Instant @Service class FeatureFlagService( @@ -72,9 +73,11 @@ class FeatureFlagService( flag.description = request.description flag.updatedBy = user + flag.conditions.forEach { it.deletedAt = Instant.now() } + conditionRepository.saveAllAndFlush(flag.conditions) + flag.defaultCondition = null flag.conditions.clear() - conditionRepository.deleteByFlag(flag) request.defaultValueAsPair() .let { (key, value) -> flag.addDefaultCondition(key = key, value = value) } @@ -95,4 +98,20 @@ class FeatureFlagService( flag.updatedBy = user featureFlagRepository.save(flag) } + + @Transactional + fun delete( + user: User, + key: String, + deletedAt: Instant = Instant.now(), + ) { + val flag = getFlagOrThrow(key) + + flag.updatedBy = user + flag.deletedAt = deletedAt + flag.conditions.forEach { it.deletedAt = deletedAt } + + featureFlagRepository.save(flag) + conditionRepository.saveAll(flag.conditions) + } } diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/Condition.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/Condition.kt index 91bfe7e..63879af 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/Condition.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/Condition.kt @@ -10,8 +10,10 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import org.hibernate.annotations.SQLRestriction @Entity +@SQLRestriction("deleted_at is null") class Condition( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/ConditionRepository.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/ConditionRepository.kt index 7ae8935..103aea8 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/ConditionRepository.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/ConditionRepository.kt @@ -1,13 +1,6 @@ package com.lightswitch.infrastructure.database.repository import com.lightswitch.infrastructure.database.entity.Condition -import com.lightswitch.infrastructure.database.entity.FeatureFlag import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.jpa.repository.Modifying -import org.springframework.data.jpa.repository.Query -interface ConditionRepository : JpaRepository { - @Modifying(clearAutomatically = true) - @Query("DELETE FROM Condition c where c.flag = :flag") - fun deleteByFlag(flag: FeatureFlag) -} +interface ConditionRepository : JpaRepository diff --git a/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt b/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt index 83ae188..1ce2ab3 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt @@ -122,9 +122,13 @@ class FeatureFlagController( fun deleteFlag( @PathVariable key: String, ): StatusResponse { - return StatusResponse( - status = "status", - message = "message", - ) + // TODO: Improve the way finding the authenticated user. + val authentication = SecurityContextHolder.getContext().authentication + val userId = authentication.name + val user = userRepository.findByIdOrNull(userId.toLong()) ?: throw BusinessException("User not found") + + featureFlagService.delete(user, key) + + return StatusResponse.success("Successfully deleted the feature flag") } } diff --git a/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt index 5b14c59..10f02d1 100644 --- a/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import java.time.Instant import java.time.LocalDate +import java.time.ZoneOffset class FeatureFlagServiceTest : BaseRepositoryTest() { @Autowired @@ -315,7 +316,8 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { @Test fun `update feature flag can update feature flag`() { - val user = saveTestUser() + val user1 = saveTestUser("user1") + val user2 = saveTestUser("user2") val flag = featureFlagRepository.save( FeatureFlag( @@ -323,8 +325,8 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { description = "description", type = Type.BOOLEAN, enabled = true, - createdBy = user, - updatedBy = user, + createdBy = user1, + updatedBy = user1, ), ).apply { this.defaultCondition = Condition(flag = this, key = "boolean", value = true) @@ -346,9 +348,10 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { ), ) - val updated = featureFlagService.update(user, "user-limit", request) + val updated = featureFlagService.update(user2, "user-limit", request) - assertThat(flag.id).isEqualTo(updated.id) + assertThat(conditionRepository.findAll()).hasSize(3) + assertThat(updated.id).isEqualTo(flag.id) assertThat(updated.name).isEqualTo("user-limit-updated") assertThat(updated.description).isEqualTo("Updated description") assertThat(updated.type).isEqualTo(Type.NUMBER) @@ -363,7 +366,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { tuple("free", 10), tuple("pro", 100), ) - assertThat(updated.updatedBy.id).isEqualTo(user.id) + assertThat(updated.updatedBy).isEqualTo(user2) } @Test @@ -456,7 +459,8 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { @Test fun `update should update feature flag status`() { - val user = saveTestUser() + val user1 = saveTestUser("user1") + val user2 = saveTestUser("user2") val flag = featureFlagRepository.save( FeatureFlag( @@ -464,17 +468,17 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { description = "description", type = Type.BOOLEAN, enabled = false, - createdBy = user, - updatedBy = user, + createdBy = user1, + updatedBy = user1, ), ) - featureFlagService.update(user, "test-flag", true) + featureFlagService.update(user2, "test-flag", true) val updatedFlag = featureFlagRepository.findById(flag.id!!.toInt()).orElseThrow() assertThat(updatedFlag.enabled).isTrue() - assertThat(updatedFlag.updatedBy).isEqualTo(user) + assertThat(updatedFlag.updatedBy).isEqualTo(user2) } @Test @@ -488,10 +492,52 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { .hasMessageContaining("Feature flag flag-one does not exist") } - private fun saveTestUser() = + @Test + fun `delete should remove feature flag successfully`() { + val deletedAt = LocalDate.of(2025, 1, 1).atStartOfDay().toInstant(ZoneOffset.UTC) + val user1 = saveTestUser("user1") + val user2 = saveTestUser("user2") + val flag = + featureFlagRepository.save( + FeatureFlag( + name = "test-flag", + description = "Test flag for deletion", + type = Type.BOOLEAN, + enabled = true, + createdBy = user1, + updatedBy = user1, + ), + ).apply { + this.defaultCondition = Condition(flag = this, key = "boolean", value = true) + this.conditions.add(this.defaultCondition!!) + this.conditions.add(Condition(flag = this, key = "free", value = true)) + }.let { + featureFlagRepository.save(it) + } + + featureFlagService.delete(user2, "test-flag", deletedAt) + + val deleted = featureFlagRepository.findById(flag.id!!.toInt()).get() + assertThat(deleted.updatedBy).isEqualTo(user2) + assertThat(deleted.deletedAt).isEqualTo(deletedAt) + assertThat(deleted.conditions).hasSize(2).allMatch { it.deletedAt == deletedAt } + assertThat(featureFlagRepository.findAll()).isEmpty() + assertThat(conditionRepository.findAll()).isEmpty() + } + + @Test + fun `delete should throw EntityNotFoundException when flag does not exist`() { + val user = saveTestUser() + + assertThatThrownBy { featureFlagService.delete(user, "non-existent-flag") } + .isInstanceOf(EntityNotFoundException::class.java) + .hasMessageContaining("Feature flag non-existent-flag does not exist") + } + + private fun saveTestUser(username: String = "test-user") = userRepository.save( User( - username = "test-user", + username = username, passwordHash = "test-pass", lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), ), diff --git a/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt b/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt index 535c359..9a65c31 100644 --- a/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt @@ -16,6 +16,7 @@ import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mockito.doNothing import org.mockito.Mockito.`when` import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest import org.springframework.http.MediaType @@ -23,6 +24,7 @@ import org.springframework.security.test.context.support.WithMockUser import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @@ -488,4 +490,37 @@ class FeatureFlagControllerTest { .andExpect(jsonPath("$.status").value("BAD_REQUEST")) .andExpect(jsonPath("$.message").value("Entity not found")) } + + @Test + @WithMockUser(username = "1") + fun `DELETE flags should delete a feature flag successfully`() { + `when`(userRepository.findById(1L)).thenReturn(Optional.of(user)) + doNothing().`when`(featureFlagService).delete(user, "test-flag") + + mockMvc.perform( + delete("/api/v1/flags/test-flag") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.status").value("Success")) + .andExpect(jsonPath("$.message").value("Successfully deleted the feature flag")) + .andDo(print()) + } + + @Test + @WithMockUser(username = "1") + fun `DELETE flags return 400 when flag does not exist`() { + `when`(userRepository.findById(1L)).thenReturn(Optional.of(user)) + `when`(featureFlagService.delete(any(), any(), any())).thenThrow(EntityNotFoundException()) + + mockMvc.perform( + delete("/api/v1/flags/test-flag") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON), + ) + .andExpect(status().isBadRequest) + .andExpect(jsonPath("$.status").value("BAD_REQUEST")) + .andExpect(jsonPath("$.message").value("Entity not found")) + } }