From 864b3c2dcb1aa09876a6798b18a75476654da7b2 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Mon, 17 Feb 2025 09:09:24 +0900 Subject: [PATCH 1/5] feat: Implement FeatureFlagService.getFlagOrThrow --- .../application/service/FeatureFlagService.kt | 4 + .../database/entity/FeatureFlag.kt | 2 + .../repository/FeatureFlagRepository.kt | 5 + .../service/FeatureFlagServiceTest.kt | 99 ++++++++++++++++++- 4 files changed, 107 insertions(+), 3 deletions(-) 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 a50dfc1..3a61cee 100644 --- a/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt +++ b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt @@ -15,6 +15,10 @@ class FeatureFlagService( private val conditionRepository: ConditionRepository, private val featureFlagRepository: FeatureFlagRepository, ) { + fun getFlagOrThrow(key: String): FeatureFlag { + return featureFlagRepository.findByName(key) ?: throw BusinessException("Feature flag $key does not exist") + } + @Transactional fun create( user: User, diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/FeatureFlag.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/FeatureFlag.kt index 8170730..4eb1d7a 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/FeatureFlag.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/entity/FeatureFlag.kt @@ -11,10 +11,12 @@ import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne import jakarta.persistence.OneToMany import jakarta.persistence.OneToOne +import org.hibernate.annotations.SQLRestriction import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.LastModifiedBy @Entity +@SQLRestriction("deleted_at is null") class FeatureFlag( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/FeatureFlagRepository.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/FeatureFlagRepository.kt index 79e2578..35192ec 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/FeatureFlagRepository.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/FeatureFlagRepository.kt @@ -1,8 +1,13 @@ package com.lightswitch.infrastructure.database.repository import com.lightswitch.infrastructure.database.entity.FeatureFlag +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.JpaRepository interface FeatureFlagRepository : JpaRepository { + @EntityGraph(attributePaths = ["createdBy", "updatedBy", "defaultCondition", "conditions"]) fun findByName(name: String): FeatureFlag? + + @EntityGraph(attributePaths = ["createdBy", "updatedBy", "defaultCondition", "conditions"]) + override fun findAll(): List } 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 4adf642..a5cceed 100644 --- a/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt @@ -1,6 +1,8 @@ package com.lightswitch.application.service import com.lightswitch.infrastructue.database.repository.BaseRepositoryTest +import com.lightswitch.infrastructure.database.entity.Condition +import com.lightswitch.infrastructure.database.entity.FeatureFlag import com.lightswitch.infrastructure.database.entity.User import com.lightswitch.infrastructure.database.repository.ConditionRepository import com.lightswitch.infrastructure.database.repository.FeatureFlagRepository @@ -13,6 +15,7 @@ import org.assertj.core.api.Assertions.tuple import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired +import java.time.Instant import java.time.LocalDate class FeatureFlagServiceTest : BaseRepositoryTest() { @@ -30,9 +33,99 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { @BeforeEach fun setUp() { featureFlagService = FeatureFlagService(conditionRepository, featureFlagRepository) - conditionRepository.deleteAllInBatch() - featureFlagRepository.deleteAllInBatch() - userRepository.deleteAllInBatch() + conditionRepository.deleteAll() + featureFlagRepository.deleteAll() + userRepository.deleteAll() + } + + @Test + fun `getFlagOrThrow should return feature flag when key exists`() { + val user = userRepository.save( + User( + username = "test-user", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ), + ) + val savedFlag = featureFlagRepository.save( + FeatureFlag( + name = "user-limit", + description = "User Limit Flag", + type = "number", + enabled = true, + createdBy = user, + updatedBy = user + ) + ) + val defaultCondition = Condition(flag = savedFlag, key = "number", value = 10) + val conditions = listOf( + Condition(flag = savedFlag, key = "free", value = 10), + Condition(flag = savedFlag, key = "pro", value = 100), + Condition(flag = savedFlag, key = "enterprise", value = 1000), + ) + featureFlagRepository.save(savedFlag.apply { + this.defaultCondition = defaultCondition + this.conditions.addAll(conditions) + this.conditions.add(defaultCondition) + }) + + val flag = featureFlagService.getFlagOrThrow("user-limit") + + assertThat(flag.id).isNotNull() + assertThat(flag.name).isEqualTo("user-limit") + assertThat(flag.description).isEqualTo("User Limit Flag") + assertThat(flag.type).isEqualTo("number") + assertThat(flag.enabled).isTrue() + assertThat(flag.createdBy).isEqualTo(user) + assertThat(flag.updatedBy).isEqualTo(user) + assertThat(flag.defaultCondition) + .extracting("key", "value") + .containsOnly("number", 10) + assertThat(flag.conditions) + .hasSize(4) + .extracting("key", "value") + .containsExactlyInAnyOrder( + tuple("number", 10), + tuple("free", 10), + tuple("pro", 100), + tuple("enterprise", 1000), + ) + } + + @Test + fun `getFlagOrThrow should throw BusinessException when key does not exist`() { + val nonExistentKey = "non-existent-key" + + assertThatThrownBy { featureFlagService.getFlagOrThrow(nonExistentKey) } + .isInstanceOf(BusinessException::class.java) + .hasMessageContaining("Feature flag $nonExistentKey does not exist") + } + + + @Test + fun `getFlagOrThrow should not return when feature flag is deleted`() { + val user = userRepository.save( + User( + username = "test-user", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ), + ) + val flag = FeatureFlag( + name = "user-limit", + description = "User Limit Flag", + type = "number", + enabled = true, + createdBy = user, + updatedBy = user, + ).apply { + this.deletedAt = Instant.now() + } + featureFlagRepository.save(flag) + + assertThatThrownBy { featureFlagService.getFlagOrThrow("user-limit") } + .isInstanceOf(BusinessException::class.java) + .hasMessageContaining("Feature flag user-limit does not exist") } @Test From 44ee223ab678b38ebd8d96d644c24fb0d51ee91c Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Mon, 17 Feb 2025 09:11:29 +0900 Subject: [PATCH 2/5] feat: Connect FeatureFlagController to FeatureFlagService.getFlagOrThrow --- .../controller/FeatureFlagController.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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 e84904e..02ce2dc 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt @@ -49,10 +49,16 @@ class FeatureFlagController( fun getFlag( @PathVariable key: String, ): PayloadResponse { - return PayloadResponse( - status = "status", - message = "message", - data = null + // 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") + + val flag = featureFlagService.getFlagOrThrow(key) + + return PayloadResponse.success( + message = "Fetched a flag successfully", + data = FeatureFlagResponse.from(flag) ) } From f8a22ef4b4ee2e08a3b95d5bd38c0a3e3c01355b Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Mon, 17 Feb 2025 09:23:21 +0900 Subject: [PATCH 3/5] feat: Implement FeatureFlagService.getFlags --- .../application/service/FeatureFlagService.kt | 4 + .../service/FeatureFlagServiceTest.kt | 91 +++++++++++++++++++ 2 files changed, 95 insertions(+) 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 3a61cee..edd7e6f 100644 --- a/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt +++ b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt @@ -15,6 +15,10 @@ class FeatureFlagService( private val conditionRepository: ConditionRepository, private val featureFlagRepository: FeatureFlagRepository, ) { + fun getFlags(): List { + return featureFlagRepository.findAll() + } + fun getFlagOrThrow(key: String): FeatureFlag { return featureFlagRepository.findByName(key) ?: throw BusinessException("Feature flag $key does not exist") } 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 a5cceed..1500f3d 100644 --- a/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt @@ -38,6 +38,97 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { userRepository.deleteAll() } + @Test + fun `getFlags should return all feature flags`() { + val user = userRepository.save( + User( + username = "test-user", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ) + ) + val flag1 = featureFlagRepository.save( + FeatureFlag( + name = "feature-1", + description = "Feature Flag 1", + type = "boolean", + enabled = true, + createdBy = user, + updatedBy = user + ) + ) + val flag2 = featureFlagRepository.save( + FeatureFlag( + name = "feature-2", + description = "Feature Flag 2", + type = "number", + enabled = false, + createdBy = user, + updatedBy = user + ) + ) + val flag3 = featureFlagRepository.save( + FeatureFlag( + name = "feature-3", + description = "Feature Flag 3", + type = "string", + enabled = true, + createdBy = user, + updatedBy = user + ) + ) + flag1.defaultCondition = Condition(flag = flag1, key = "boolean", value = true) + flag2.defaultCondition = Condition(flag = flag2, key = "number", value = 10) + flag3.defaultCondition = Condition(flag = flag3, key = "string", value = "value") + + val flags = featureFlagService.getFlags() + + assertThat(flags).hasSize(3) + assertThat(flags) + .extracting("name", "description", "type", "enabled", "createdBy", "updatedBy") + .containsExactly( + tuple("feature-1", "Feature Flag 1", "boolean", true, user, user), + tuple("feature-2", "Feature Flag 2", "number", false, user, user), + tuple("feature-3", "Feature Flag 3", "string", true, user, user), + ) + assertThat(flags) + .extracting("defaultCondition.key", "defaultCondition.value") + .containsExactlyInAnyOrder( + tuple("boolean", true), + tuple("number", 10), + tuple("string", "value"), + ) + } + + @Test + fun `getFlags should return empty list when feature flag not exists`() { + assertThat(featureFlagService.getFlags()).isEmpty() + } + + @Test + fun `getFlags should return empty list when all feature flags are deleted`() { + val user = userRepository.save( + User( + username = "test-user", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ), + ) + val flag = FeatureFlag( + name = "user-limit", + description = "User Limit Flag", + type = "number", + enabled = true, + createdBy = user, + updatedBy = user, + ).apply { + this.deletedAt = Instant.now() + } + featureFlagRepository.save(flag) + + assertThat(featureFlagService.getFlags()).isEmpty() + } + @Test fun `getFlagOrThrow should return feature flag when key exists`() { val user = userRepository.save( From 1030bc75f30cd6ef472dcf427cd3971249069b40 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Mon, 17 Feb 2025 09:24:46 +0900 Subject: [PATCH 4/5] feat: Connect FeatureFlagController to FeatureFlagService.getFlags --- .../controller/FeatureFlagController.kt | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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 02ce2dc..26f7f29 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt @@ -35,10 +35,11 @@ class FeatureFlagController( ) @GetMapping fun getFlags(): PayloadResponse> { - return PayloadResponse>( - status = "status", - message = "message", - data = listOf() + val flags = featureFlagService.getFlags() + + return PayloadResponse.success( + message = "Fetched all feature flags successfully", + data = flags.map { FeatureFlagResponse.from(it) }, ) } @@ -49,11 +50,6 @@ class FeatureFlagController( fun getFlag( @PathVariable key: String, ): PayloadResponse { - // 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") - val flag = featureFlagService.getFlagOrThrow(key) return PayloadResponse.success( From 95c5ee2a7aab89ea53d628a4723ff424c7537036 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Mon, 24 Feb 2025 15:15:52 +0900 Subject: [PATCH 5/5] test: Apply changes from flag-creating --- .../service/FeatureFlagServiceTest.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 79ac792..6e89ff2 100644 --- a/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt @@ -52,7 +52,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { FeatureFlag( name = "feature-1", description = "Feature Flag 1", - type = "boolean", + type = Type.BOOLEAN, enabled = true, createdBy = user, updatedBy = user @@ -62,7 +62,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { FeatureFlag( name = "feature-2", description = "Feature Flag 2", - type = "number", + type = Type.NUMBER, enabled = false, createdBy = user, updatedBy = user @@ -72,7 +72,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { FeatureFlag( name = "feature-3", description = "Feature Flag 3", - type = "string", + type = Type.STRING, enabled = true, createdBy = user, updatedBy = user @@ -88,9 +88,9 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { assertThat(flags) .extracting("name", "description", "type", "enabled", "createdBy", "updatedBy") .containsExactly( - tuple("feature-1", "Feature Flag 1", "boolean", true, user, user), - tuple("feature-2", "Feature Flag 2", "number", false, user, user), - tuple("feature-3", "Feature Flag 3", "string", true, user, user), + tuple("feature-1", "Feature Flag 1", Type.BOOLEAN, true, user, user), + tuple("feature-2", "Feature Flag 2", Type.NUMBER, false, user, user), + tuple("feature-3", "Feature Flag 3", Type.STRING, true, user, user), ) assertThat(flags) .extracting("defaultCondition.key", "defaultCondition.value") @@ -118,7 +118,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { val flag = FeatureFlag( name = "user-limit", description = "User Limit Flag", - type = "number", + type = Type.NUMBER, enabled = true, createdBy = user, updatedBy = user, @@ -143,7 +143,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { FeatureFlag( name = "user-limit", description = "User Limit Flag", - type = "number", + type = Type.NUMBER, enabled = true, createdBy = user, updatedBy = user @@ -166,7 +166,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { assertThat(flag.id).isNotNull() assertThat(flag.name).isEqualTo("user-limit") assertThat(flag.description).isEqualTo("User Limit Flag") - assertThat(flag.type).isEqualTo("number") + assertThat(flag.type).isEqualTo(Type.NUMBER) assertThat(flag.enabled).isTrue() assertThat(flag.createdBy).isEqualTo(user) assertThat(flag.updatedBy).isEqualTo(user) @@ -206,7 +206,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { val flag = FeatureFlag( name = "user-limit", description = "User Limit Flag", - type = "number", + type = Type.NUMBER, enabled = true, createdBy = user, updatedBy = user,