diff --git a/pom.xml b/pom.xml index dcb85de..3d17b69 100644 --- a/pom.xml +++ b/pom.xml @@ -41,6 +41,7 @@ 3.0.0 3.3.2 4.2 + 5.9.3 4.0.4 10.0.15 diff --git a/webapp/pom.xml b/webapp/pom.xml index e4aa80b..0508430 100644 --- a/webapp/pom.xml +++ b/webapp/pom.xml @@ -94,8 +94,23 @@ + + org.apache.maven.plugins + maven-surefire-plugin + + + + + org.junit + junit-bom + 5.9.3 + pom + import + + + @@ -104,12 +119,6 @@ ${kotlin.version} test - - org.junit.jupiter - junit-jupiter-engine - 5.6.0 - test - org.jetbrains.kotlin kotlin-stdlib-jdk8 @@ -123,7 +132,7 @@ org.jetbrains.kotlinx kotlinx-datetime-jvm - 0.3.3 + 0.4.0 @@ -135,7 +144,7 @@ com.sun.mail jakarta.mail - 1.6.5 + 1.6.7 @@ -147,7 +156,7 @@ io.github.microutils kotlin-logging-jvm - 2.1.23 + 3.0.5 org.slf4j @@ -231,6 +240,19 @@ jeasse-servlet3 1.2 + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + org.mockito.kotlin + mockito-kotlin + 4.1.0 + test + com.icegreen diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt index 17eb25e..b11395f 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/ApiHandler.kt @@ -42,13 +42,13 @@ interface ApiHandler { } fun getObjectPayload(request: HttpServletRequest): Json.Object { - val json = request.getAttribute(PAYLOAD_KEY) as Json? ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no payload") + val json = getPayload(request) if (!json.isObject) badRequest("expecting a json object") return json.asObject() } fun getArrayPayload(request: HttpServletRequest): Json.Array { - val json = request.getAttribute(PAYLOAD_KEY) as Json? ?: throw ApiException(HttpServletResponse.SC_BAD_REQUEST, "no payload") + val json = getPayload(request) if (!json.isArray) badRequest("expecting a json array") return json.asArray() } diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt index 5dc9d2d..f92370d 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/api/PlayerHandler.kt @@ -21,7 +21,6 @@ object PlayerHandler: PairgothApiHandler { val payload = getObjectPayload(request) // player parsing (CB TODO - team handling, based on tournament type) val player = Player.fromJson(payload) - // CB TODO - handle concurrency tournament.pairables[player.id] = player // CB TODO - handle event broadcasting return Json.Object("success" to true, "id" to player.id) diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt index 4508708..9d3e252 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/model/TimeSystem.kt @@ -2,9 +2,10 @@ package org.jeudego.pairgoth.model import com.republicate.kson.Json import org.jeudego.pairgoth.api.ApiHandler +import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest +import org.jeudego.pairgoth.model.TimeSystem.TimeSystemType.* - -sealed class TimeSystem( +data class TimeSystem( val type: TimeSystemType, val mainTime: Int, val increment: Int, @@ -14,12 +15,12 @@ sealed class TimeSystem( val stones: Int ) { companion object {} - enum class TimeSystemType { CANADIAN, STANDARD, FISHER, SUDDEN_DEATH } + enum class TimeSystemType { CANADIAN, STANDARD, FISCHER, SUDDEN_DEATH } } -class CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int): +fun CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int) = TimeSystem( - type = TimeSystemType.CANADIAN, + type = CANADIAN, mainTime = mainTime, increment = 0, byoyomi = byoyomi, @@ -27,9 +28,9 @@ class CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int): stones = stones ) -class StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int): +fun StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int) = TimeSystem( - type = TimeSystemType.STANDARD, + type = STANDARD, mainTime = mainTime, increment = 0, byoyomi = byoyomi, @@ -37,9 +38,9 @@ class StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int): stones = 1 ) -class FisherTime(mainTime: Int, increment: Int, maxTime: Int = Int.MAX_VALUE): +fun FischerTime(mainTime: Int, increment: Int, maxTime: Int = Int.MAX_VALUE) = TimeSystem( - type = TimeSystemType.FISHER, + type = FISCHER, mainTime = mainTime, increment = increment, maxTime = maxTime, @@ -48,9 +49,9 @@ class FisherTime(mainTime: Int, increment: Int, maxTime: Int = Int.MAX_VALUE): stones = 0 ) -class SuddenDeath(mainTime: Int): +fun SuddenDeath(mainTime: Int) = TimeSystem( - type = TimeSystemType.SUDDEN_DEATH, + type = SUDDEN_DEATH, mainTime = mainTime, increment = 0, byoyomi = 0, @@ -61,32 +62,33 @@ class SuddenDeath(mainTime: Int): // Serialization fun TimeSystem.Companion.fromJson(json: Json.Object) = - when (json.getString("type")?.uppercase() ?: ApiHandler.badRequest("missing timeSystem type")) { + when (json.getString("type")?.uppercase() ?: badRequest("missing timeSystem type")) { "CANADIAN" -> CanadianByoyomi( - mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), - byoyomi = json.getInt("byoyomi") ?: ApiHandler.badRequest("missing timeSystem byoyomi"), - stones = json.getInt("stones") ?: ApiHandler.badRequest("missing timeSystem stones") + mainTime = json.getInt("mainTime") ?: badRequest("missing timeSystem mainTime"), + byoyomi = json.getInt("byoyomi") ?: badRequest("missing timeSystem byoyomi"), + stones = json.getInt("stones") ?: badRequest("missing timeSystem stones") ) "STANDARD" -> StandardByoyomi( - mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), - byoyomi = json.getInt("byoyomi") ?: ApiHandler.badRequest("missing timeSystem byoyomi"), - periods = json.getInt("periods") ?: ApiHandler.badRequest("missing timeSystem periods") + mainTime = json.getInt("mainTime") ?: badRequest("missing timeSystem mainTime"), + byoyomi = json.getInt("byoyomi") ?: badRequest("missing timeSystem byoyomi"), + periods = json.getInt("periods") ?: badRequest("missing timeSystem periods") ) - "FISHER" -> FisherTime( - mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), - increment = json.getInt("increment") ?: ApiHandler.badRequest("missing timeSystem increment"), - maxTime = json.getInt("increment") ?: Integer.MAX_VALUE + "FISCHER" -> FischerTime( + mainTime = json.getInt("mainTime") ?: badRequest("missing timeSystem mainTime"), + increment = json.getInt("increment") ?: badRequest("missing timeSystem increment"), + maxTime = json.getInt("maxTime") ?: Integer.MAX_VALUE ) "SUDDEN_DEATH" -> SuddenDeath( - mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), + mainTime = json.getInt("mainTime") ?: badRequest("missing timeSystem mainTime"), ) - else -> ApiHandler.badRequest("invalid or missing timeSystem type") + else -> badRequest("invalid or missing timeSystem type") } fun TimeSystem.toJson() = when (type) { - TimeSystem.TimeSystemType.CANADIAN -> Json.Object("mainTime" to mainTime, "byoyomi" to byoyomi, "stones" to stones) - TimeSystem.TimeSystemType.STANDARD -> Json.Object("mainTime" to mainTime, "byoyomi" to byoyomi, "periods" to periods) - TimeSystem.TimeSystemType.FISHER -> Json.Object("mainTime" to mainTime, "increment" to increment, "maxTime" to maxTime) - TimeSystem.TimeSystemType.SUDDEN_DEATH -> Json.Object("mainTime" to mainTime) + TimeSystem.TimeSystemType.CANADIAN -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "stones" to stones) + TimeSystem.TimeSystemType.STANDARD -> Json.Object("type" to type.name, "mainTime" to mainTime, "byoyomi" to byoyomi, "periods" to periods) + TimeSystem.TimeSystemType.FISCHER -> + if (maxTime == Int.MAX_VALUE) Json.Object("type" to type.name, "mainTime" to mainTime, "increment" to increment) + else Json.Object("type" to type.name, "mainTime" to mainTime, "increment" to increment, "maxTime" to maxTime) + TimeSystem.TimeSystemType.SUDDEN_DEATH -> Json.Object("type" to type.name, "mainTime" to mainTime) } - diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/store/Store.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/store/Store.kt index 826a613..846c4c2 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/store/Store.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/store/Store.kt @@ -5,10 +5,6 @@ import org.jeudego.pairgoth.model.Tournament import java.util.concurrent.atomic.AtomicInteger import kotlin.math.E -// CB TODO - handle concurrency: -// - either with concurrent maps -// - or with a thread isolation (better, covers more operations) - object Store { private val _nextTournamentId = AtomicInteger() private val _nextPlayerId = AtomicInteger() diff --git a/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt b/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt index 64e0063..31ae69a 100644 --- a/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt +++ b/webapp/src/main/kotlin/org/jeudego/pairgoth/web/ApiServlet.kt @@ -12,6 +12,8 @@ import org.jeudego.pairgoth.util.toString import org.slf4j.LoggerFactory import java.io.IOException import java.util.* +import java.util.concurrent.locks.ReadWriteLock +import java.util.concurrent.locks.ReentrantReadWriteLock import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse @@ -34,7 +36,18 @@ class ApiServlet : HttpServlet() { doRequest(request, response) } + private val lock: ReadWriteLock = ReentrantReadWriteLock() private fun doRequest(request: HttpServletRequest, response: HttpServletResponse) { + val requestLock = if (request.method == "GET") lock.readLock() else lock.writeLock() + try { + requestLock.lock() + doProtectedRequest(request, response) + } finally { + requestLock.unlock() + } + } + + private fun doProtectedRequest(request: HttpServletRequest, response: HttpServletResponse) { val uri = request.requestURI logger.logRequest(request, !uri.contains(".") && uri.length > 1) diff --git a/webapp/src/test/kotlin/BasicTests.kt b/webapp/src/test/kotlin/BasicTests.kt new file mode 100644 index 0000000..e10e0a9 --- /dev/null +++ b/webapp/src/test/kotlin/BasicTests.kt @@ -0,0 +1,51 @@ +package org.jeudego.pairgoth.test + +import com.republicate.kson.Json +import org.junit.jupiter.api.MethodOrderer.Alphanumeric +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.TestMethodOrder +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +@TestMethodOrder(Alphanumeric::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class BasicTests() { + + val aTournament = Json.Object( + "type" to "INDIVIDUAL", + "name" to "Mon Tournoi", + "shortName" to "mon-tournoi", + "startDate" to "2023-05-10", + "endDate" to "2023-05-12", + "country" to "FR", + "location" to "Marseille", + "online" to false, + "timeSystem" to Json.Object( + "type" to "FISCHER", + "mainTime" to 1200, + "increment" to 10 + ), + "pairing" to Json.Object( + "type" to "ROUNDROBIN" + ) + ) + + @Test + fun `001 create tournament`() { + val resp = TestAPI.post("/api/tour", aTournament) + assertTrue(resp.isObject, "Json object expected") + assertTrue(resp.asObject().getBoolean("success") == true, "expecting success") + } + + @Test + fun `002 get tournament`() { + val resp = TestAPI.get("/api/tour/1") + assertTrue(resp.isObject, "Json object expected") + assertEquals(1, resp.asObject().getInt("id"), "First tournament should have id #1") + // filter out "id", and also "komi", "rules" and "gobanSize" which were provided by default + val cmp = Json.Object(*resp.asObject().entries.filter { it.key !in listOf("id", "komi", "rules", "gobanSize") }.map { Pair(it.key, it.value) }.toTypedArray()) + assertEquals(aTournament.toString(), cmp.toString(), "tournament differs") + } +} diff --git a/webapp/src/test/kotlin/TestUtils.kt b/webapp/src/test/kotlin/TestUtils.kt new file mode 100644 index 0000000..4436b5e --- /dev/null +++ b/webapp/src/test/kotlin/TestUtils.kt @@ -0,0 +1,83 @@ +package org.jeudego.pairgoth.test + +import com.republicate.kson.Json +import org.jeudego.pairgoth.api.ApiHandler +import org.jeudego.pairgoth.web.ApiServlet +import org.jeudego.pairgoth.web.WebappManager +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doNothing +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import java.io.BufferedReader +import java.io.PrintWriter +import java.io.StringReader +import java.io.StringWriter +import java.util.* +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +object TestAPI { + + fun Any?.toUnit() = Unit + + private val apiServlet = ApiServlet() + + private fun testRequest(reqMethod: String, uri: String, payload: Json? = null): Json { + + WebappManager.properties["webapp.env"] = "test" + + // mock request + val myHeaderNames = if (reqMethod == "GET") emptyList() else listOf("Content-Type") + val selector = argumentCaptor() + val subSelector = argumentCaptor() + val reqPayload = argumentCaptor() + val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) } + val req = mock { + on { method } doReturn reqMethod + on { requestURI } doReturn uri + on { setAttribute(eq(ApiHandler.SELECTOR_KEY), selector.capture()) } doAnswer {} + on { setAttribute(eq(ApiHandler.SUBSELECTOR_KEY), subSelector.capture()) } doAnswer {} + on { setAttribute(eq(ApiHandler.PAYLOAD_KEY), reqPayload.capture()) } doAnswer {} + on { getAttribute(ApiHandler.SELECTOR_KEY) } doAnswer { selector.lastValue } + on { getAttribute(ApiHandler.SUBSELECTOR_KEY) } doAnswer { subSelector.lastValue } + on { getAttribute(ApiHandler.PAYLOAD_KEY) } doAnswer { reqPayload.lastValue } + on { reader } doReturn myReader + on { scheme } doReturn "http" + on { localName } doReturn "pairgoth" + on { localPort } doReturn 80 + on { contextPath } doReturn "" + on { contentType } doReturn if (reqMethod == "GET") null else "application/json; charset=UTF-8" + on { headerNames } doReturn Collections.enumeration(myHeaderNames) + on { getHeader(eq("Accept")) } doReturn "application/json" + } + + // mock response + val buffer = StringWriter() + val errCode = argumentCaptor() + val errMessage = argumentCaptor() + val resp = mock { + on { writer } doAnswer { PrintWriter(buffer) } + on { sendError(errCode.capture(), errMessage.capture()) } doAnswer { throw Error("${errCode.lastValue} ${errMessage.lastValue}") } + } + + when (reqMethod) { + "GET" -> apiServlet.doGet(req, resp) + "POST" -> apiServlet.doPost(req, resp) + "PUT" -> apiServlet.doPut(req, resp) + "DELETE" -> apiServlet.doDelete(req, resp) + } + + return Json.parse(buffer.toString()) ?: throw Error("no response payload") + } + + fun get(uri: String) = testRequest("GET", uri) + fun post(uri: String, payload: Json) = testRequest("POST", uri, payload) + fun put(uri: String, payload: Json) = testRequest("PUT", uri, payload) + fun delete(uri: String, payload: Json) = testRequest("DELETE", uri, payload) +} + +fun expectSuccess() { + +} \ No newline at end of file