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/application/service/FeatureFlagService.kt b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt new file mode 100644 index 0000000..2051076 --- /dev/null +++ b/backend/src/main/kotlin/com/lightswitch/application/service/FeatureFlagService.kt @@ -0,0 +1,55 @@ +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 +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 = Type.from(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/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 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 { 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..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,12 +1,19 @@ 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 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 @@ -15,13 +22,20 @@ class FeatureFlag( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) var id: Long? = null, - var defaultConditionId: Long? = null, @Column(nullable = false) val name: String, @Column(nullable = false) - val type: String, + val description: String, + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) + val type: Type, @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/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/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/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/CreateFeatureFlagRequest.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/CreateFeatureFlagRequest.kt index 617d969..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 @@ -3,19 +3,22 @@ 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.") val key: String, @field:NotNull(message = "Status is required.") val status: Boolean, - @field:NotNull(message = "Type is required.") + @field:NotBlank(message = "Type is required.") + @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, @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/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt b/backend/src/main/kotlin/com/lightswitch/presentation/model/flag/FeatureFlagResponse.kt index e2bdeb7..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 @@ -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.name, + 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, + ) + } + } +} 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 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..74980e4 --- /dev/null +++ b/backend/src/test/kotlin/com/lightswitch/application/service/FeatureFlagServiceTest.kt @@ -0,0 +1,174 @@ +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 +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(Type.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(Type.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") + } + + @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 new file mode 100644 index 0000000..7bb8f9a --- /dev/null +++ b/backend/src/test/kotlin/com/lightswitch/presentation/controller/FeatureFlagControllerTest.kt @@ -0,0 +1,194 @@ +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.model.Type +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 = 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) + } + + @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) + } +}