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()))
}