From 2119a9f5334d07eaba4de16f686d85f41762aada Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Thu, 13 Feb 2025 16:05:15 +0900 Subject: [PATCH 1/9] fix: JsonConverter to throw IllegalArgumentException --- .../database/converter/JsonConverter.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/converter/JsonConverter.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/converter/JsonConverter.kt index 63097b2..1ba5875 100644 --- a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/converter/JsonConverter.kt +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/converter/JsonConverter.kt @@ -7,11 +7,19 @@ import jakarta.persistence.Converter @Converter class JsonConverter : AttributeConverter { override fun convertToDatabaseColumn(attribute: Any?): String { - return objectMapper.writeValueAsString(attribute) + return try { + objectMapper.writeValueAsString(attribute) + } catch (e: Exception) { + throw IllegalArgumentException("Failed to convert object to JSON: ${e.message}", e) + } } override fun convertToEntityAttribute(dbData: String?): Any? { - return dbData?.let { objectMapper.readValue(it, Any::class.java) } + return try { + dbData?.let { objectMapper.readValue(it, Any::class.java) } + } catch (e: Exception) { + throw IllegalArgumentException("Failed to convert JSON to object: ${e.message}", e) + } } companion object { From 4f165951ba6763cb765a1940c0e20f88a10bf3e9 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Thu, 13 Feb 2025 16:07:09 +0900 Subject: [PATCH 2/9] fix: Refactor FeatureFlag entity - Replaced `defaultConditionId` with a `@OneToOne` relationship to `Condition` - Add a `description` field for additional metadata --- .../infrastructure/database/entity/FeatureFlag.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 fc386a6..244b828 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 @@ -1,12 +1,15 @@ package com.lightswitch.infrastructure.database.entity +import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType import jakarta.persistence.Id +import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToOne import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.LastModifiedBy @@ -15,9 +18,12 @@ class FeatureFlag( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null, - var defaultConditionId: Long? = null, + @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], optional = true) + @JoinColumn(name = "default_condition_id", referencedColumnName = "id", nullable = true) + var defaultCondition: Condition? = null, @Column(nullable = false) val name: String, + val description: String, @Column(nullable = false) val type: String, @Column(nullable = false) From ebd798bf848227903278bad26dbce0bc0f709883 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Thu, 13 Feb 2025 16:08:13 +0900 Subject: [PATCH 3/9] feat: Implement FeatureFlagService.create --- .../application/service/FeatureFlagService.kt | 54 +++++++ .../database/entity/FeatureFlag.kt | 10 +- .../repository/ConditionRepository.kt | 6 + .../repository/FeatureFlagRepository.kt | 8 + .../model/flag/CreateFeatureFlagRequest.kt | 9 +- .../service/FeatureFlagServiceTest.kt | 148 ++++++++++++++++++ 6 files changed, 228 insertions(+), 7 deletions(-) create mode 100644 backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt create mode 100644 backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/ConditionRepository.kt create mode 100644 backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/FeatureFlagRepository.kt create mode 100644 backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt diff --git a/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt new file mode 100644 index 0000000..a50dfc1 --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt @@ -0,0 +1,54 @@ +package com.lightswitch.application.service + +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 +import com.lightswitch.presentation.exception.BusinessException +import com.lightswitch.presentation.model.flag.CreateFeatureFlagRequest +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class FeatureFlagService( + private val conditionRepository: ConditionRepository, + private val featureFlagRepository: FeatureFlagRepository, +) { + @Transactional + fun create( + user: User, + request: CreateFeatureFlagRequest, + ): FeatureFlag { + featureFlagRepository.findByName(request.key)?.let { + throw BusinessException("FeatureFlag with key ${request.key} already exists") + } + + val flag = + featureFlagRepository.save( + FeatureFlag( + name = request.key, + type = request.type, + enabled = request.status, + description = request.description, + createdBy = user, + updatedBy = user, + ), + ) + + request.defaultValueAsPair() + .let { (key, value) -> Condition(flag = flag, key = key, value = value) } + .let { conditionRepository.save(it) } + .also { + flag.defaultCondition = it + flag.conditions.add(it) + } + + request.variantPairs() + ?.map { variant -> Condition(flag = flag, key = variant.first, value = variant.second) } + ?.let { conditionRepository.saveAll(it) } + ?.also { flag.conditions.addAll(it) } + + return featureFlagRepository.save(flag) + } +} 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 244b828..8170730 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 @@ -9,6 +9,7 @@ import jakarta.persistence.GenerationType import jakarta.persistence.Id import jakarta.persistence.JoinColumn import jakarta.persistence.ManyToOne +import jakarta.persistence.OneToMany import jakarta.persistence.OneToOne import org.springframework.data.annotation.CreatedBy import org.springframework.data.annotation.LastModifiedBy @@ -18,16 +19,19 @@ class FeatureFlag( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null, - @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], optional = true) - @JoinColumn(name = "default_condition_id", referencedColumnName = "id", nullable = true) - var defaultCondition: Condition? = null, @Column(nullable = false) val name: String, + @Column(nullable = false) val description: String, @Column(nullable = false) val type: String, @Column(nullable = false) var enabled: Boolean, + @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], optional = true) + @JoinColumn(name = "default_condition_id", referencedColumnName = "id", nullable = true) + var defaultCondition: Condition? = null, + @OneToMany(fetch = FetchType.LAZY, mappedBy = "flag", cascade = [CascadeType.ALL], orphanRemoval = true) + val conditions: MutableList = mutableListOf(), @CreatedBy @ManyToOne(fetch = FetchType.LAZY) var createdBy: User, 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 new file mode 100644 index 0000000..103aea8 --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/ConditionRepository.kt @@ -0,0 +1,6 @@ +package com.lightswitch.infrastructure.database.repository + +import com.lightswitch.infrastructure.database.entity.Condition +import org.springframework.data.jpa.repository.JpaRepository + +interface ConditionRepository : JpaRepository 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 new file mode 100644 index 0000000..79e2578 --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/repository/FeatureFlagRepository.kt @@ -0,0 +1,8 @@ +package com.lightswitch.infrastructure.database.repository + +import com.lightswitch.infrastructure.database.entity.FeatureFlag +import org.springframework.data.jpa.repository.JpaRepository + +interface FeatureFlagRepository : JpaRepository { + fun findByName(name: String): FeatureFlag? +} diff --git a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt index 617d969..27044cb 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt @@ -9,13 +9,14 @@ data class CreateFeatureFlagRequest( val key: String, @field:NotNull(message = "Status is required.") val status: Boolean, - @field:NotNull(message = "Type is required.") + @field:NotBlank(message = "Type is required.") val type: String, @field:NotEmpty(message = "Default value is required.") val defaultValue: Map, @field:NotBlank(message = "Description is required.") val description: String, val variants: List>? = null, - @field:NotBlank(message = "CreatedBy is required.") - val createdBy: String, -) +) { + fun defaultValueAsPair(): Pair = defaultValue.entries.first().toPair() + fun variantPairs(): List>? = variants?.map { it.entries.first().toPair() } +} diff --git a/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt new file mode 100644 index 0000000..4adf642 --- /dev/null +++ b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt @@ -0,0 +1,148 @@ +package com.lightswitch.application.service + +import com.lightswitch.infrastructue.database.repository.BaseRepositoryTest +import com.lightswitch.infrastructure.database.entity.User +import com.lightswitch.infrastructure.database.repository.ConditionRepository +import com.lightswitch.infrastructure.database.repository.FeatureFlagRepository +import com.lightswitch.infrastructure.database.repository.UserRepository +import com.lightswitch.presentation.exception.BusinessException +import com.lightswitch.presentation.model.flag.CreateFeatureFlagRequest +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +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.LocalDate + +class FeatureFlagServiceTest : BaseRepositoryTest() { + @Autowired + private lateinit var featureFlagRepository: FeatureFlagRepository + + @Autowired + private lateinit var conditionRepository: ConditionRepository + + @Autowired + private lateinit var userRepository: UserRepository + + private lateinit var featureFlagService: FeatureFlagService + + @BeforeEach + fun setUp() { + featureFlagService = FeatureFlagService(conditionRepository, featureFlagRepository) + conditionRepository.deleteAllInBatch() + featureFlagRepository.deleteAllInBatch() + userRepository.deleteAllInBatch() + } + + @Test + fun `create feature flag can save feature flag & conditions`() { + val request = + CreateFeatureFlagRequest( + key = "user-limit", + status = true, + type = "number", + defaultValue = mapOf("number" to 10), + description = "User Limit Flag", + variants = + listOf( + mapOf("free" to 10), + mapOf("pro" to 100), + mapOf("enterprise" to 1000), + ), + ) + val user = + userRepository.save( + User( + username = "username", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ), + ) + + val flag = featureFlagService.create(user, request) + + 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("flag", "key", "value") + .containsOnly(flag, "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 `create feature flag with only defaultValue`() { + val request = + CreateFeatureFlagRequest( + key = "user-limit", + status = true, + type = "number", + defaultValue = mapOf("number" to 10), + description = "User Limit Flag", + variants = null, + ) + val user = + userRepository.save( + User( + username = "username", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ), + ) + + val flag = featureFlagService.create(user, request) + + 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("flag", "key", "value") + .containsOnly(flag, "number", 10) + assertThat(flag.conditions) + .hasSize(1) + .extracting("key", "value") + .containsExactly(tuple("number", 10)) + } + + @Test + fun `create feature flag throw BusinessException when duplicate key exists`() { + val request = + CreateFeatureFlagRequest( + key = "duplicate-key", + status = true, + type = "number", + defaultValue = mapOf("number" to 10), + description = "Duplicate Key Test", + variants = listOf(mapOf("free" to 10)), + ) + val user = + userRepository.save( + User( + username = "username", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ), + ) + + featureFlagService.create(user, request) + + assertThatThrownBy { featureFlagService.create(user, request) } + .isInstanceOf(BusinessException::class.java) + .hasMessageContaining("FeatureFlag with key duplicate-key already exists") + } +} From da260f48fa6ea246f4b9e12e5c279f8d640f7b41 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Sat, 15 Feb 2025 19:33:01 +0900 Subject: [PATCH 4/9] feat: Connect FeatureFlagController with FeatureFlagService.create --- .../controller/FeatureFlagController.kt | 27 ++++++++++++++----- .../presentation/model/PayloadResponse.kt | 8 +++++- .../model/flag/FeatureFlagResponse.kt | 20 +++++++++++++- 3 files changed, 47 insertions(+), 8 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 d12fe9d..e84904e 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/controller/FeatureFlagController.kt @@ -1,11 +1,17 @@ package com.lightswitch.presentation.controller +import com.lightswitch.application.service.FeatureFlagService +import com.lightswitch.infrastructure.database.repository.UserRepository +import com.lightswitch.presentation.exception.BusinessException import com.lightswitch.presentation.model.PayloadResponse import com.lightswitch.presentation.model.StatusResponse import com.lightswitch.presentation.model.flag.CreateFeatureFlagRequest import com.lightswitch.presentation.model.flag.FeatureFlagResponse import com.lightswitch.presentation.model.flag.UpdateFeatureFlagRequest import io.swagger.v3.oas.annotations.Operation +import jakarta.validation.Valid +import org.springframework.data.repository.findByIdOrNull +import org.springframework.security.core.context.SecurityContextHolder import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -19,7 +25,10 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/v1/flags") -class FeatureFlagController { +class FeatureFlagController( + private val featureFlagService: FeatureFlagService, + private val userRepository: UserRepository, +) { @Operation( summary = "Retrieve all feature flags", @@ -52,12 +61,18 @@ class FeatureFlagController { ) @PostMapping fun createFlag( - @RequestBody request: CreateFeatureFlagRequest, + @RequestBody @Valid request: CreateFeatureFlagRequest, ): 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.create(user, request) + + return PayloadResponse.success( + message = "Created flag successfully", + data = FeatureFlagResponse.from(flag) ) } diff --git a/backend/src/main/kotlin/com/lightswitch/presentation/model/PayloadResponse.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/PayloadResponse.kt index efc5a5b..4d6317b 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/model/PayloadResponse.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/model/PayloadResponse.kt @@ -4,4 +4,10 @@ data class PayloadResponse( val status: String, val message: String, val data: T? = null, -) +) { + companion object { + fun success(message: String, data: T): PayloadResponse { + return PayloadResponse("Success", message, data) + } + } +} diff --git a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt index e2bdeb7..278bcca 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt @@ -1,5 +1,6 @@ package com.lightswitch.presentation.model.flag +import com.lightswitch.infrastructure.database.entity.FeatureFlag import java.time.Instant data class FeatureFlagResponse( @@ -13,4 +14,21 @@ data class FeatureFlagResponse( val updatedBy: String, val createdAt: Instant, val updatedAt: Instant, -) +) { + companion object { + fun from(flag: FeatureFlag): FeatureFlagResponse { + return FeatureFlagResponse( + key = flag.name, + status = flag.enabled, + type = flag.type, + defaultValue = mapOf(flag.defaultCondition!!.key to flag.defaultCondition!!.value), + description = flag.description, + variants = flag.conditions.map { mapOf(it.key to it.value) }, + createdBy = flag.createdBy.username, + updatedBy = flag.updatedBy.username, + createdAt = flag.createdAt, + updatedAt = flag.updatedAt, + ) + } + } +} From f585ed7b2e4195308041bab6206ac1d3c35992a4 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Sat, 15 Feb 2025 19:33:42 +0900 Subject: [PATCH 5/9] chore: Add configuration for @WebMvcTest --- backend/build.gradle.kts | 1 + .../main/kotlin/com/lightswitch/LightswitchApplication.kt | 2 -- .../com/lightswitch/infrastructure/database/JpaConfig.kt | 8 ++++++++ 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/kotlin/com/lightswitch/infrastructure/database/JpaConfig.kt diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 9e18cc4..d7e5496 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { implementation("ch.qos.logback:logback-classic:1.4.12") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") testImplementation("org.mockito.kotlin:mockito-kotlin:3.2.0") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/backend/src/main/kotlin/com/lightswitch/LightswitchApplication.kt b/backend/src/main/kotlin/com/lightswitch/LightswitchApplication.kt index 694e2c2..4088a4b 100644 --- a/backend/src/main/kotlin/com/lightswitch/LightswitchApplication.kt +++ b/backend/src/main/kotlin/com/lightswitch/LightswitchApplication.kt @@ -2,10 +2,8 @@ package com.lightswitch import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication -import org.springframework.data.jpa.repository.config.EnableJpaAuditing @SpringBootApplication -@EnableJpaAuditing class LightswitchApplication fun main(args: Array) { diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/JpaConfig.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/JpaConfig.kt new file mode 100644 index 0000000..b5e7c1a --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/JpaConfig.kt @@ -0,0 +1,8 @@ +package com.lightswitch.infrastructure.database + +import org.springframework.context.annotation.Configuration +import org.springframework.data.jpa.repository.config.EnableJpaAuditing + +@Configuration +@EnableJpaAuditing +class JpaConfig From e6a3edd2fed0b64b458ae858ed0a0167fd0722ca Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Sat, 15 Feb 2025 19:34:41 +0900 Subject: [PATCH 6/9] test: Add FeatureFlagControllerTest --- .../controller/FeatureFlagControllerTest.kt | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt diff --git a/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt b/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt new file mode 100644 index 0000000..95bc78c --- /dev/null +++ b/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt @@ -0,0 +1,173 @@ +package com.lightswitch.presentation.controller + +import com.fasterxml.jackson.databind.ObjectMapper +import com.lightswitch.application.service.FeatureFlagService +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.UserRepository +import com.lightswitch.infrastructure.security.JwtTokenProvider +import com.lightswitch.presentation.model.flag.CreateFeatureFlagRequest +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest +import org.springframework.http.MediaType +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.post +import org.springframework.test.web.servlet.result.MockMvcResultHandlers.print +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.* +import kotlin.test.Test + +@WebMvcTest(controllers = [FeatureFlagController::class]) +@ExtendWith(MockitoExtension::class) +class FeatureFlagControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @MockitoBean + private lateinit var featureFlagService: FeatureFlagService + + @MockitoBean + private lateinit var userRepository: UserRepository + + @MockitoBean + private lateinit var jwtTokenProvider: JwtTokenProvider + + @Test + @WithMockUser(username = "1") + fun `should create feature flag successfully`() { + val user = User(id = 1L, username = "username", passwordHash = "passwordHash") + val request = CreateFeatureFlagRequest( + key = "new-feature", + status = true, + type = "boolean", + defaultValue = mapOf("default" to true), + description = "Test feature flag", + ) + val flag = FeatureFlag( + id = 1L, + name = "new-feature", + description = "Test feature flag", + type = "boolean", + enabled = true, + createdBy = user, + updatedBy = user + ) + val condition = Condition(id = 1L, key = "default", value = true, flag = flag) + flag.defaultCondition = condition + + `when`(userRepository.findById(1L)).thenReturn(Optional.of(user)) + `when`(featureFlagService.create(user, request)).thenReturn(flag) + + mockMvc.perform( + post("/api/v1/flags") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.status").value("Success")) + .andExpect(jsonPath("$.message").value("Created flag successfully")) + .andExpect(jsonPath("$.data.key").value("new-feature")) + .andExpect(jsonPath("$.data.status").value(true)) + .andExpect(jsonPath("$.data.type").value("boolean")) + .andExpect(jsonPath("$.data.defaultValue.default").value(true)) + .andExpect(jsonPath("$.data.description").value("Test feature flag")) + .andExpect(jsonPath("$.data.createdBy").value("username")) + .andExpect(jsonPath("$.data.updatedBy").value("username")) + .andExpect(jsonPath("$.data.createdAt").exists()) + .andExpect(jsonPath("$.data.updatedAt").exists()) + .andDo(print()) + } + + @Test + @WithMockUser(username = "1") + fun `should return 400 when request validation fails due to empty key`() { + val request = CreateFeatureFlagRequest( + key = "", + status = true, + type = "boolean", + defaultValue = mapOf("default" to true), + description = "Test feature flag", + ) + + mockMvc.perform( + post("/api/v1/flags") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + } + + @Test + @WithMockUser(username = "1") + fun `should return 400 when request validation fails due to empty type`() { + val request = CreateFeatureFlagRequest( + key = "new-feature", + status = true, + type = "", + defaultValue = mapOf("default" to true), + description = "Test feature flag", + ) + + mockMvc.perform( + post("/api/v1/flags") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + } + + @Test + @WithMockUser(username = "1") + fun `should return 400 when request validation fails due to empty defaultValue`() { + val request = CreateFeatureFlagRequest( + key = "new-feature", + status = true, + type = "boolean", + defaultValue = emptyMap(), + description = "Test feature flag", + ) + + mockMvc.perform( + post("/api/v1/flags") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + } + + @Test + @WithMockUser(username = "1") + fun `should return 400 when request validation fails due to empty description`() { + val request = CreateFeatureFlagRequest( + key = "new-feature", + status = true, + type = "boolean", + defaultValue = mapOf("default" to true), + description = "", + ) + + mockMvc.perform( + post("/api/v1/flags") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + } +} From 038334817ff6c6706f8f78168b7bfdf3fa1f4926 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Sat, 15 Feb 2025 19:35:31 +0900 Subject: [PATCH 7/9] chore: Disable Open-in-View for JPA --- backend/src/main/resources/application.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index fccf2ff..852fa7a 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,13 +1,14 @@ spring: - application: - name: LightSwitch - jpa: - hibernate: - ddl-auto: create - database-platform: org.hibernate.community.dialect.SQLiteDialect - datasource: - url: jdbc:sqlite:lightswitch.sqlite - driver-class-name: org.sqlite.JDBC + application: + name: LightSwitch + jpa: + database-platform: org.hibernate.community.dialect.SQLiteDialect + hibernate: + ddl-auto: create + open-in-view: false + datasource: + driver-class-name: org.sqlite.JDBC + url: jdbc:sqlite:lightswitch.sqlite jwt: - secret: 64461f01e1af406da538b9c48d801ce59142452199ff112fb5404c8e7e98e3ff + secret: 64461f01e1af406da538b9c48d801ce59142452199ff112fb5404c8e7e98e3ff From d8d77f5d316ea4e0495aae4aa0b5343ba26ac99b Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Wed, 19 Feb 2025 17:09:38 +0900 Subject: [PATCH 8/9] fix: Add validation for 'type' field in CreateFeatureFlagRequest --- .../model/flag/CreateFeatureFlagRequest.kt | 2 ++ .../controller/FeatureFlagControllerTest.kt | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt index 27044cb..ff4fde2 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt @@ -3,6 +3,7 @@ package com.lightswitch.presentation.model.flag import jakarta.validation.constraints.NotBlank import jakarta.validation.constraints.NotEmpty import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Pattern data class CreateFeatureFlagRequest( @field:NotBlank(message = "Key is required.") @@ -10,6 +11,7 @@ data class CreateFeatureFlagRequest( @field:NotNull(message = "Status is required.") val status: Boolean, @field:NotBlank(message = "Type is required.") + @field:Pattern(regexp = "^(number|boolean|string)$", message = "Type must be one of: number, boolean, string") val type: String, @field:NotEmpty(message = "Default value is required.") val defaultValue: Map, 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 95bc78c..71d79a7 100644 --- a/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt @@ -170,4 +170,24 @@ class FeatureFlagControllerTest { ) .andExpect(status().isBadRequest) } + + @Test + @WithMockUser(username = "1") + fun `should return 400 when request validation fails due to invalid type`() { + val request = CreateFeatureFlagRequest( + key = "new-feature", + status = true, + type = "invalid-type", + defaultValue = mapOf("default" to true), + description = "", + ) + + mockMvc.perform( + post("/api/v1/flags") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isBadRequest) + } } From 6f3063a4371416cba05e25d80e0f74ec7d45a625 Mon Sep 17 00:00:00 2001 From: Nayeon Kim Date: Thu, 20 Feb 2025 17:18:44 +0900 Subject: [PATCH 9/9] feat: Define 'Type' enum --- .../application/service/FeatureFlagService.kt | 3 +- .../database/entity/FeatureFlag.kt | 6 +++- .../infrastructure/database/model/Type.kt | 18 +++++++++++ .../model/flag/CreateFeatureFlagRequest.kt | 2 +- .../model/flag/FeatureFlagResponse.kt | 2 +- .../service/FeatureFlagServiceTest.kt | 30 +++++++++++++++++-- .../controller/FeatureFlagControllerTest.kt | 7 +++-- 7 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 backend/src/main/kotlin/com/lightswitch/infrastructure/database/model/Type.kt 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..2051076 100644 --- a/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt +++ b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt @@ -3,6 +3,7 @@ package com.lightswitch.application.service 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.model.Type import com.lightswitch.infrastructure.database.repository.ConditionRepository import com.lightswitch.infrastructure.database.repository.FeatureFlagRepository import com.lightswitch.presentation.exception.BusinessException @@ -28,7 +29,7 @@ class FeatureFlagService( featureFlagRepository.save( FeatureFlag( name = request.key, - type = request.type, + type = Type.from(request.type), enabled = request.status, description = request.description, createdBy = 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..df3bf79 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 @@ -1,8 +1,11 @@ package com.lightswitch.infrastructure.database.entity +import com.lightswitch.infrastructure.database.model.Type import jakarta.persistence.CascadeType import jakarta.persistence.Column import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated import jakarta.persistence.FetchType import jakarta.persistence.GeneratedValue import jakarta.persistence.GenerationType @@ -24,7 +27,8 @@ class FeatureFlag( @Column(nullable = false) val description: String, @Column(nullable = false) - val type: String, + @Enumerated(value = EnumType.STRING) + val type: Type, @Column(nullable = false) var enabled: Boolean, @OneToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL], optional = true) diff --git a/backend/src/main/kotlin/com/lightswitch/infrastructure/database/model/Type.kt b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/model/Type.kt new file mode 100644 index 0000000..8fbb05c --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/infrastructure/database/model/Type.kt @@ -0,0 +1,18 @@ +package com.lightswitch.infrastructure.database.model + +enum class Type { + NUMBER, + BOOLEAN, + STRING; + + companion object { + fun from(type: String): Type { + return when (type.uppercase()) { + "NUMBER" -> NUMBER + "BOOLEAN" -> BOOLEAN + "STRING" -> STRING + else -> throw IllegalArgumentException("Unsupported type: $type") + } + } + } +} diff --git a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt index ff4fde2..867b11f 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt @@ -11,7 +11,7 @@ data class CreateFeatureFlagRequest( @field:NotNull(message = "Status is required.") val status: Boolean, @field:NotBlank(message = "Type is required.") - @field:Pattern(regexp = "^(number|boolean|string)$", message = "Type must be one of: number, boolean, string") + @field:Pattern(regexp = "(?i)^(number|boolean|string)$", message = "Type must be one of: number, boolean, string") val type: String, @field:NotEmpty(message = "Default value is required.") val defaultValue: Map, diff --git a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt index 278bcca..c05983a 100644 --- a/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt +++ b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt @@ -20,7 +20,7 @@ data class FeatureFlagResponse( return FeatureFlagResponse( key = flag.name, status = flag.enabled, - type = flag.type, + type = flag.type.name, defaultValue = mapOf(flag.defaultCondition!!.key to flag.defaultCondition!!.value), description = flag.description, variants = flag.conditions.map { mapOf(it.key to it.value) }, 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..74980e4 100644 --- a/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt @@ -2,6 +2,7 @@ package com.lightswitch.application.service import com.lightswitch.infrastructue.database.repository.BaseRepositoryTest import com.lightswitch.infrastructure.database.entity.User +import com.lightswitch.infrastructure.database.model.Type import com.lightswitch.infrastructure.database.repository.ConditionRepository import com.lightswitch.infrastructure.database.repository.FeatureFlagRepository import com.lightswitch.infrastructure.database.repository.UserRepository @@ -64,7 +65,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { 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) @@ -106,7 +107,7 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { 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) @@ -145,4 +146,29 @@ class FeatureFlagServiceTest : BaseRepositoryTest() { .isInstanceOf(BusinessException::class.java) .hasMessageContaining("FeatureFlag with key duplicate-key already exists") } + + @Test + fun `create feature flag throw IllegalArgumentException when type is not supported`() { + val request = + CreateFeatureFlagRequest( + key = "invalid-type", + status = true, + type = "json", + defaultValue = mapOf("json" to 10), + description = "Invalid Type Test", + variants = listOf(mapOf("free" to 10)), + ) + val user = + userRepository.save( + User( + username = "username", + passwordHash = "passwordHash", + lastLoginAt = LocalDate.of(2025, 1, 1).atStartOfDay(), + ), + ) + + assertThatThrownBy { featureFlagService.create(user, request) } + .isInstanceOf(IllegalArgumentException::class.java) + .hasMessageContaining("Unsupported type: json") + } } 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 71d79a7..7bb8f9a 100644 --- a/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt +++ b/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt @@ -5,6 +5,7 @@ import com.lightswitch.application.service.FeatureFlagService 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.model.Type import com.lightswitch.infrastructure.database.repository.UserRepository import com.lightswitch.infrastructure.security.JwtTokenProvider import com.lightswitch.presentation.model.flag.CreateFeatureFlagRequest @@ -51,7 +52,7 @@ class FeatureFlagControllerTest { val request = CreateFeatureFlagRequest( key = "new-feature", status = true, - type = "boolean", + type = "BOOLEAN", defaultValue = mapOf("default" to true), description = "Test feature flag", ) @@ -59,7 +60,7 @@ class FeatureFlagControllerTest { id = 1L, name = "new-feature", description = "Test feature flag", - type = "boolean", + type = Type.BOOLEAN, enabled = true, createdBy = user, updatedBy = user @@ -81,7 +82,7 @@ class FeatureFlagControllerTest { .andExpect(jsonPath("$.message").value("Created flag successfully")) .andExpect(jsonPath("$.data.key").value("new-feature")) .andExpect(jsonPath("$.data.status").value(true)) - .andExpect(jsonPath("$.data.type").value("boolean")) + .andExpect(jsonPath("$.data.type").value("BOOLEAN")) .andExpect(jsonPath("$.data.defaultValue.default").value(true)) .andExpect(jsonPath("$.data.description").value("Test feature flag")) .andExpect(jsonPath("$.data.createdBy").value("username"))