diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8b137891..91b0d268 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1 +1,19 @@ - +{ + "permissions": { + "allow": [ + "Bash(./gradlew compileKotlin:*)", + "Bash(./gradlew test:*)", + "Bash(./src/kotlin/gradlew:*)", + "Bash(docker compose build:*)", + "Bash(docker compose up:*)", + "Bash(docker compose run:*)", + "Bash(./gradlew tasks:*)", + "Bash(docker compose config:*)", + "Bash(curl:*)", + "Bash(pip3 install:*)", + "Bash(schemathesis run:*)", + "Bash(python3:*)", + "Bash(pkill:*)" + ] + } +} diff --git a/src/kotlin/build.gradle.kts b/src/kotlin/build.gradle.kts index b66fbac3..f8e86891 100644 --- a/src/kotlin/build.gradle.kts +++ b/src/kotlin/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { testImplementation("io.ktor:ktor-server-test-host") testImplementation("org.junit.jupiter:junit-jupiter:5.9.3") testImplementation("io.ktor:ktor-client-content-negotiation") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0") } tasks.test { diff --git a/src/kotlin/build/reports/jacoco/test/jacocoTestReport.xml b/src/kotlin/build/reports/jacoco/test/jacocoTestReport.xml index 66e76b94..401d680e 100644 --- a/src/kotlin/build/reports/jacoco/test/jacocoTestReport.xml +++ b/src/kotlin/build/reports/jacoco/test/jacocoTestReport.xml @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt index dd661527..29c923c6 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/api/apis/DefaultApi.kt @@ -30,6 +30,7 @@ import com.lampcontrol.api.models.Lamp import com.lampcontrol.api.models.LampCreate import com.lampcontrol.api.models.LampUpdate import com.lampcontrol.api.models.ListLamps200Response +import com.lampcontrol.api.models.Error import com.lampcontrol.service.LampService fun Route.DefaultApi(lampService: LampService) { @@ -65,14 +66,33 @@ fun Route.DefaultApi(lampService: LampService) { } get { - val lamps = lampService.getAllLamps() - // Construct response object matching OpenAPI schema: { data: [...], hasMore: boolean, nextCursor: string? } - val response = ListLamps200Response( - data = lamps, - hasMore = false, - nextCursor = null - ) - call.respond(HttpStatusCode.OK, response) + try { + // Validate pageSize parameter if provided + val pageSize = it.pageSize + if (pageSize != null) { + if (pageSize < 1 || pageSize > 100) { + call.respond( + HttpStatusCode.BadRequest, + Error(error = "Invalid numeric parameter") + ) + return@get + } + } + + val lamps = lampService.getAllLamps() + // Construct response object matching OpenAPI schema: { data: [...], hasMore: boolean, nextCursor: string? } + val response = ListLamps200Response( + data = lamps, + hasMore = false, + nextCursor = null + ) + call.respond(HttpStatusCode.OK, response) + } catch (e: Exception) { + call.respond( + HttpStatusCode.BadRequest, + Error(error = "Invalid numeric parameter") + ) + } } put { diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt index 3e125d4c..1ec9e64e 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/plugins/StatusPages.kt @@ -1,5 +1,6 @@ package com.lampcontrol.plugins +import com.lampcontrol.api.models.Error import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.plugins.statuspages.* @@ -12,25 +13,17 @@ fun Application.configureStatusPages() { // Map numeric parse errors to 400 so malformed numeric query params (e.g. pageSize=null) // don't surface as 500 Internal Server Error. This lets the client know the input // was invalid while preserving `pageSize: kotlin.Int?` in generated `Paths`. - // Use respondText with explicit JSON and Content-Type so a body is always sent even - // when serialization/content-negotiation isn't available at the time the handler runs. - fun jsonError(error: String, message: String?): String { - val safeMessage = message?.replace("\"", "\\\"") ?: "" - return "{\"error\":\"$error\",\"message\":\"$safeMessage\"}" - } - exception { call, cause -> - call.respondText( - jsonError("Invalid numeric parameter", cause.message), - ContentType.Application.Json, - HttpStatusCode.BadRequest + call.respond( + HttpStatusCode.BadRequest, + Error(error = "Invalid numeric parameter") ) } + exception { call, cause -> - call.respondText( - jsonError("Invalid JSON format", cause.message), - ContentType.Application.Json, - HttpStatusCode.BadRequest + call.respond( + HttpStatusCode.BadRequest, + Error(error = "Invalid JSON format") ) } @@ -40,10 +33,9 @@ fun Application.configureStatusPages() { exception { call, cause -> val nf = cause.cause if (nf is NumberFormatException) { - call.respondText( - jsonError("Invalid numeric parameter", nf.message), - ContentType.Application.Json, - HttpStatusCode.BadRequest + call.respond( + HttpStatusCode.BadRequest, + Error(error = "Invalid numeric parameter") ) } else { // Fallback to the generic handler below by rethrowing so it is caught by the @@ -51,20 +43,18 @@ fun Application.configureStatusPages() { throw cause } } - + exception { call, cause -> - call.respondText( - jsonError("Invalid argument", cause.message), - ContentType.Application.Json, - HttpStatusCode.BadRequest + call.respond( + HttpStatusCode.BadRequest, + Error(error = "Invalid argument") ) } exception { call, cause -> - call.respondText( - jsonError("Internal server error", "An unexpected error occurred"), - ContentType.Application.Json, - HttpStatusCode.InternalServerError + call.respond( + HttpStatusCode.InternalServerError, + Error(error = "Internal server error") ) } } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt index 5d382390..ff1f475f 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/repository/LampRepository.kt @@ -9,10 +9,10 @@ import java.util.* * Works with domain entities to maintain separation from API models. */ interface LampRepository { - fun getAllLamps(): List - fun getLampById(id: UUID): LampEntity? - fun createLamp(entity: LampEntity): LampEntity - fun updateLamp(entity: LampEntity): LampEntity? - fun deleteLamp(id: UUID): Boolean - fun lampExists(id: UUID): Boolean + suspend fun getAllLamps(): List + suspend fun getLampById(id: UUID): LampEntity? + suspend fun createLamp(entity: LampEntity): LampEntity + suspend fun updateLamp(entity: LampEntity): LampEntity? + suspend fun deleteLamp(id: UUID): Boolean + suspend fun lampExists(id: UUID): Boolean } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt index 14a5c740..4fc69d92 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/service/InMemoryLampRepository.kt @@ -16,45 +16,45 @@ class InMemoryLampRepository : LampRepository { /** * Get all lamps */ - override fun getAllLamps(): List { + override suspend fun getAllLamps(): List { return lamps.values.toList() } - + /** * Get a lamp by ID */ - override fun getLampById(id: UUID): LampEntity? { + override suspend fun getLampById(id: UUID): LampEntity? { return lamps[id] } - + /** * Create a new lamp */ - override fun createLamp(entity: LampEntity): LampEntity { + override suspend fun createLamp(entity: LampEntity): LampEntity { lamps[entity.id] = entity return entity } - + /** * Update an existing lamp */ - override fun updateLamp(entity: LampEntity): LampEntity? { + override suspend fun updateLamp(entity: LampEntity): LampEntity? { val existingLamp = lamps[entity.id] ?: return null lamps[entity.id] = entity return entity } - + /** * Delete a lamp by ID */ - override fun deleteLamp(id: UUID): Boolean { + override suspend fun deleteLamp(id: UUID): Boolean { return lamps.remove(id) != null } - + /** * Check if a lamp exists */ - override fun lampExists(id: UUID): Boolean { + override suspend fun lampExists(id: UUID): Boolean { return lamps.containsKey(id) } } diff --git a/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt b/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt index 8a768a1c..dc834051 100644 --- a/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt +++ b/src/kotlin/src/main/kotlin/com/lampcontrol/service/LampService.kt @@ -16,77 +16,77 @@ class LampService( private val lampRepository: LampRepository, private val lampMapper: LampMapper ) { - + /** * Get all lamps as API models */ - fun getAllLamps(): List { + suspend fun getAllLamps(): List { return lampRepository.getAllLamps() .map { lampMapper.toApiModel(it) } } - + /** * Get a lamp by string ID and return as API model */ - fun getLampById(lampId: String): Lamp? { + suspend fun getLampById(lampId: String): Lamp? { val uuid = try { UUID.fromString(lampId) } catch (e: IllegalArgumentException) { return null } - + return lampRepository.getLampById(uuid) ?.let { lampMapper.toApiModel(it) } } - + /** * Create a new lamp from API model */ - fun createLamp(lampCreate: LampCreate): Lamp { + suspend fun createLamp(lampCreate: LampCreate): Lamp { val domainEntity = lampMapper.toDomainEntityCreate(lampCreate) val savedEntity = lampRepository.createLamp(domainEntity) return lampMapper.toApiModel(savedEntity) } - + /** * Update a lamp by string ID with API update model */ - fun updateLamp(lampId: String, lampUpdate: LampUpdate): Lamp? { + suspend fun updateLamp(lampId: String, lampUpdate: LampUpdate): Lamp? { val uuid = try { UUID.fromString(lampId) } catch (e: IllegalArgumentException) { return null } - + val existingEntity = lampRepository.getLampById(uuid) ?: return null val updatedEntity = lampMapper.updateDomainEntity(existingEntity, lampUpdate) val savedEntity = lampRepository.updateLamp(updatedEntity) ?: return null return lampMapper.toApiModel(savedEntity) } - + /** * Delete a lamp by string ID */ - fun deleteLamp(lampId: String): Boolean { + suspend fun deleteLamp(lampId: String): Boolean { val uuid = try { UUID.fromString(lampId) } catch (e: IllegalArgumentException) { return false } - + return lampRepository.deleteLamp(uuid) } - + /** * Check if a lamp exists by string ID */ - fun lampExists(lampId: String): Boolean { + suspend fun lampExists(lampId: String): Boolean { val uuid = try { UUID.fromString(lampId) } catch (e: IllegalArgumentException) { return false } - + return lampRepository.lampExists(uuid) } } \ No newline at end of file diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt index ba0fc3f9..86ddef46 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/service/InMemoryLampRepositoryTest.kt @@ -1,6 +1,7 @@ package com.lampcontrol.service import com.lampcontrol.entity.LampEntity +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Test import kotlin.test.* @@ -8,7 +9,7 @@ class InMemoryLampRepositoryTest { private val repo = InMemoryLampRepository() @Test - fun `create, retrieve, update and delete lamp lifecycle`() { + fun `create, retrieve, update and delete lamp lifecycle`() = runTest { val entity = LampEntity.create(true) val created = repo.createLamp(entity) diff --git a/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt b/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt index 34b896ac..bbc1f5db 100644 --- a/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt +++ b/src/kotlin/src/test/kotlin/com/lampcontrol/service/LampServiceTest.kt @@ -4,6 +4,7 @@ import com.lampcontrol.api.models.LampCreate import com.lampcontrol.api.models.LampUpdate import com.lampcontrol.mapper.LampMapper import com.lampcontrol.repository.LampRepository +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -20,26 +21,26 @@ class LampServiceTest { } @Test - fun `createLamp should create a new lamp with generated UUID`() { + fun `createLamp should create a new lamp with generated UUID`() = runTest { val lampCreate = LampCreate(status = true) val createdLamp = lampService.createLamp(lampCreate) - + assertNotNull(createdLamp.id) assertEquals(true, createdLamp.status) assertTrue(lampService.lampExists(createdLamp.id.toString())) } @Test - fun `getAllLamps should return empty list initially`() { + fun `getAllLamps should return empty list initially`() = runTest { val lamps = lampService.getAllLamps() assertTrue(lamps.isEmpty()) } @Test - fun `getAllLamps should return all created lamps`() { + fun `getAllLamps should return all created lamps`() = runTest { val lamp1 = lampService.createLamp(LampCreate(status = true)) val lamp2 = lampService.createLamp(LampCreate(status = false)) - + val lamps = lampService.getAllLamps() assertEquals(2, lamps.size) assertTrue(lamps.contains(lamp1)) @@ -47,50 +48,50 @@ class LampServiceTest { } @Test - fun `getLampById should return null for non-existent lamp`() { + fun `getLampById should return null for non-existent lamp`() = runTest { val lamp = lampService.getLampById("non-existent-id") assertNull(lamp) } @Test - fun `getLampById should return existing lamp`() { + fun `getLampById should return existing lamp`() = runTest { val createdLamp = lampService.createLamp(LampCreate(status = true)) val retrievedLamp = lampService.getLampById(createdLamp.id.toString()) - + assertNotNull(retrievedLamp) assertEquals(createdLamp, retrievedLamp) } @Test - fun `updateLamp should return null for non-existent lamp`() { + fun `updateLamp should return null for non-existent lamp`() = runTest { val lampUpdate = LampUpdate(status = false) val updatedLamp = lampService.updateLamp("non-existent-id", lampUpdate) - + assertNull(updatedLamp) } @Test - fun `updateLamp should update existing lamp status`() { + fun `updateLamp should update existing lamp status`() = runTest { val createdLamp = lampService.createLamp(LampCreate(status = true)) val lampUpdate = LampUpdate(status = false) val updatedLamp = lampService.updateLamp(createdLamp.id.toString(), lampUpdate) - + assertNotNull(updatedLamp) assertEquals(createdLamp.id, updatedLamp!!.id) assertEquals(false, updatedLamp.status) } @Test - fun `deleteLamp should return false for non-existent lamp`() { + fun `deleteLamp should return false for non-existent lamp`() = runTest { val deleted = lampService.deleteLamp("non-existent-id") assertFalse(deleted) } @Test - fun `deleteLamp should delete existing lamp`() { + fun `deleteLamp should delete existing lamp`() = runTest { val createdLamp = lampService.createLamp(LampCreate(status = true)) val lampId = createdLamp.id.toString() - + assertTrue(lampService.lampExists(lampId)) assertTrue(lampService.deleteLamp(lampId)) assertFalse(lampService.lampExists(lampId)) @@ -98,12 +99,12 @@ class LampServiceTest { } @Test - fun `lampExists should return false for non-existent lamp`() { + fun `lampExists should return false for non-existent lamp`() = runTest { assertFalse(lampService.lampExists("non-existent-id")) } @Test - fun `lampExists should return true for existing lamp`() { + fun `lampExists should return true for existing lamp`() = runTest { val createdLamp = lampService.createLamp(LampCreate(status = true)) assertTrue(lampService.lampExists(createdLamp.id.toString())) }