diff --git a/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt index 3505d5bb6..39ee5e3a1 100644 --- a/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt +++ b/core/src/main/kotlin/io/specmatic/stub/stateful/StubCache.kt @@ -2,6 +2,8 @@ package io.specmatic.stub.stateful import io.specmatic.core.value.JSONArrayValue import io.specmatic.core.value.JSONObjectValue +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock data class CachedResponse( val path: String, @@ -10,8 +12,9 @@ data class CachedResponse( class StubCache { private val cachedResponses = mutableListOf() + private val lock = ReentrantLock() - fun addResponse(path: String, responseBody: JSONObjectValue) { + fun addResponse(path: String, responseBody: JSONObjectValue) = lock.withLock { cachedResponses.add( CachedResponse(path, responseBody) ) @@ -22,12 +25,12 @@ class StubCache { responseBody: JSONObjectValue, idKey: String, idValue: String - ) { + ) = lock.withLock { deleteResponse(path, idKey, idValue) addResponse(path, responseBody) } - fun findResponseFor(path: String, idKey: String, idValue: String): CachedResponse? { + fun findResponseFor(path: String, idKey: String, idValue: String): CachedResponse? = lock.withLock { return cachedResponses.filter { it.path == path }.firstOrNull { @@ -36,7 +39,7 @@ class StubCache { } } - fun findAllResponsesFor(path: String, attributeSelectionKeys: Set): JSONArrayValue { + fun findAllResponsesFor(path: String, attributeSelectionKeys: Set): JSONArrayValue = lock.withLock { val responseBodies = cachedResponses.filter { it.path == path }.map { it.responseBody.removeKeysNotPresentIn(attributeSelectionKeys) } @@ -45,6 +48,8 @@ class StubCache { fun deleteResponse(path: String, idKey: String, idValue: String) { val existingResponse = findResponseFor(path, idKey, idValue) ?: return - cachedResponses.remove(existingResponse) + lock.withLock { + cachedResponses.remove(existingResponse) + } } } \ No newline at end of file diff --git a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt index a40c96eba..9561ddf61 100644 --- a/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt +++ b/core/src/test/kotlin/io/specmatic/stub/stateful/StatefulHttpStubTest.kt @@ -7,8 +7,6 @@ import io.specmatic.core.utilities.ContractPathData import io.specmatic.core.value.* import io.specmatic.stub.ContractStub import io.specmatic.stub.loadContractStubsFromImplicitPaths -import io.specmatic.stub.stateful.StatefulHttpStubTest.Companion -import io.specmatic.stub.stateful.StatefulHttpStubTest.Companion.resourceId import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.AfterAll import org.junit.jupiter.api.BeforeAll @@ -16,6 +14,8 @@ import org.junit.jupiter.api.MethodOrderer import org.junit.jupiter.api.Order import org.junit.jupiter.api.Test import org.junit.jupiter.api.TestMethodOrder +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors @TestMethodOrder(MethodOrderer.OrderAnnotation::class) class StatefulHttpStubTest { @@ -401,6 +401,87 @@ class StatefulHttpStubSeedDataFromExamplesTest { } +class StatefulHttpStubConcurrencyTest { + companion object { + private lateinit var httpStub: ContractStub + private const val SPEC_DIR_PATH = "src/test/resources/openapi/spec_with_strictly_restful_apis" + private var resourceId = "" + + @JvmStatic + @BeforeAll + fun setup() { + httpStub = StatefulHttpStub( + specmaticConfigPath = "$SPEC_DIR_PATH/specmatic.yaml", + features = listOf( + OpenApiSpecification.fromFile( + "$SPEC_DIR_PATH/spec_with_strictly_restful_apis.yaml" + ).toFeature() + ) + ) + } + + @JvmStatic + @AfterAll + fun tearDown() { + httpStub.close() + } + } + + @Test + fun `should handle concurrent additions and updates without corruption`() { + val numberOfThreads = 10 + val executor = Executors.newFixedThreadPool(numberOfThreads) + val latch = CountDownLatch(numberOfThreads) + + repeat(numberOfThreads) { threadIndex -> + executor.submit { + try { + val path = "/products" + + httpStub.client.execute( + HttpRequest( + method = "POST", + path = path, + body = parsedJSONObject( + """ + { + "name": "Product $threadIndex", + "price": ${threadIndex * 10} + } + """.trimIndent() + ) + ) + ) + } finally { + latch.countDown() + } + } + } + + latch.await() + executor.shutdown() + + // Verify all products were added + val response = httpStub.client.execute( + HttpRequest( + method = "GET", + path = "/products" + ) + ) + + assertThat(response.status).isEqualTo(200) + assertThat(response.body).isInstanceOf(JSONArrayValue::class.java) + + val products = (response.body as JSONArrayValue).list + assertThat(products.size).isEqualTo(numberOfThreads) + products.sortedBy { (it as JSONObjectValue).getStringValue("name") }.forEachIndexed { index, product -> + val productObject = product as JSONObjectValue + assertThat(productObject.getStringValue("name")).isEqualTo("Product $index") + assertThat(productObject.getStringValue("price")).isEqualTo("${index * 10}") + } + } +} + private fun JSONObjectValue.getStringValue(key: String): String? { return this.jsonObject[key]?.toStringLiteral() }