API still in progress: first unit tests

This commit is contained in:
Claude Brisson
2023-05-16 07:02:42 +02:00
parent 30a9bad259
commit e347183b56
9 changed files with 212 additions and 45 deletions

View File

@@ -41,6 +41,7 @@
<maven.surefire.plugin.version>3.0.0</maven.surefire.plugin.version> <maven.surefire.plugin.version>3.0.0</maven.surefire.plugin.version>
<maven.war.plugin.version>3.3.2</maven.war.plugin.version> <maven.war.plugin.version>3.3.2</maven.war.plugin.version>
<license.plugin.version>4.2</license.plugin.version> <license.plugin.version>4.2</license.plugin.version>
<junit.jupiter.version>5.9.3</junit.jupiter.version>
<servlet.api.version>4.0.4</servlet.api.version> <servlet.api.version>4.0.4</servlet.api.version>
<jetty.version>10.0.15</jetty.version> <jetty.version>10.0.15</jetty.version>

View File

@@ -94,8 +94,23 @@
</execution> </execution>
</executions> </executions>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
</plugin>
</plugins> </plugins>
</build> </build>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.9.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies> <dependencies>
<!-- main dependencies --> <!-- main dependencies -->
<dependency> <dependency>
@@ -104,12 +119,6 @@
<version>${kotlin.version}</version> <version>${kotlin.version}</version>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.6.0</version>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.jetbrains.kotlin</groupId> <groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib-jdk8</artifactId> <artifactId>kotlin-stdlib-jdk8</artifactId>
@@ -123,7 +132,7 @@
<dependency> <dependency>
<groupId>org.jetbrains.kotlinx</groupId> <groupId>org.jetbrains.kotlinx</groupId>
<artifactId>kotlinx-datetime-jvm</artifactId> <artifactId>kotlinx-datetime-jvm</artifactId>
<version>0.3.3</version> <version>0.4.0</version>
</dependency> </dependency>
<!-- servlets and mail APIs --> <!-- servlets and mail APIs -->
<dependency> <dependency>
@@ -135,7 +144,7 @@
<dependency> <dependency>
<groupId>com.sun.mail</groupId> <groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId> <artifactId>jakarta.mail</artifactId>
<version>1.6.5</version> <version>1.6.7</version>
</dependency> </dependency>
<!-- auth --> <!-- auth -->
<dependency> <dependency>
@@ -147,7 +156,7 @@
<dependency> <dependency>
<groupId>io.github.microutils</groupId> <groupId>io.github.microutils</groupId>
<artifactId>kotlin-logging-jvm</artifactId> <artifactId>kotlin-logging-jvm</artifactId>
<version>2.1.23</version> <version>3.0.5</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.slf4j</groupId> <groupId>org.slf4j</groupId>
@@ -231,6 +240,19 @@
<artifactId>jeasse-servlet3</artifactId> <artifactId>jeasse-servlet3</artifactId>
<version>1.2</version> <version>1.2</version>
</dependency> </dependency>
<!-- tests -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito.kotlin</groupId>
<artifactId>mockito-kotlin</artifactId>
<version>4.1.0</version>
<scope>test</scope>
</dependency>
<!-- test emails --> <!-- test emails -->
<dependency> <dependency>
<groupId>com.icegreen</groupId> <groupId>com.icegreen</groupId>

View File

@@ -42,13 +42,13 @@ interface ApiHandler {
} }
fun getObjectPayload(request: HttpServletRequest): Json.Object { 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") if (!json.isObject) badRequest("expecting a json object")
return json.asObject() return json.asObject()
} }
fun getArrayPayload(request: HttpServletRequest): Json.Array { 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") if (!json.isArray) badRequest("expecting a json array")
return json.asArray() return json.asArray()
} }

View File

@@ -21,7 +21,6 @@ object PlayerHandler: PairgothApiHandler {
val payload = getObjectPayload(request) val payload = getObjectPayload(request)
// player parsing (CB TODO - team handling, based on tournament type) // player parsing (CB TODO - team handling, based on tournament type)
val player = Player.fromJson(payload) val player = Player.fromJson(payload)
// CB TODO - handle concurrency
tournament.pairables[player.id] = player tournament.pairables[player.id] = player
// CB TODO - handle event broadcasting // CB TODO - handle event broadcasting
return Json.Object("success" to true, "id" to player.id) return Json.Object("success" to true, "id" to player.id)

View File

@@ -2,9 +2,10 @@ package org.jeudego.pairgoth.model
import com.republicate.kson.Json import com.republicate.kson.Json
import org.jeudego.pairgoth.api.ApiHandler import org.jeudego.pairgoth.api.ApiHandler
import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest
import org.jeudego.pairgoth.model.TimeSystem.TimeSystemType.*
data class TimeSystem(
sealed class TimeSystem(
val type: TimeSystemType, val type: TimeSystemType,
val mainTime: Int, val mainTime: Int,
val increment: Int, val increment: Int,
@@ -14,12 +15,12 @@ sealed class TimeSystem(
val stones: Int val stones: Int
) { ) {
companion object {} 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( TimeSystem(
type = TimeSystemType.CANADIAN, type = CANADIAN,
mainTime = mainTime, mainTime = mainTime,
increment = 0, increment = 0,
byoyomi = byoyomi, byoyomi = byoyomi,
@@ -27,9 +28,9 @@ class CanadianByoyomi(mainTime: Int, byoyomi: Int, stones: Int):
stones = stones stones = stones
) )
class StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int): fun StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int) =
TimeSystem( TimeSystem(
type = TimeSystemType.STANDARD, type = STANDARD,
mainTime = mainTime, mainTime = mainTime,
increment = 0, increment = 0,
byoyomi = byoyomi, byoyomi = byoyomi,
@@ -37,9 +38,9 @@ class StandardByoyomi(mainTime: Int, byoyomi: Int, periods: Int):
stones = 1 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( TimeSystem(
type = TimeSystemType.FISHER, type = FISCHER,
mainTime = mainTime, mainTime = mainTime,
increment = increment, increment = increment,
maxTime = maxTime, maxTime = maxTime,
@@ -48,9 +49,9 @@ class FisherTime(mainTime: Int, increment: Int, maxTime: Int = Int.MAX_VALUE):
stones = 0 stones = 0
) )
class SuddenDeath(mainTime: Int): fun SuddenDeath(mainTime: Int) =
TimeSystem( TimeSystem(
type = TimeSystemType.SUDDEN_DEATH, type = SUDDEN_DEATH,
mainTime = mainTime, mainTime = mainTime,
increment = 0, increment = 0,
byoyomi = 0, byoyomi = 0,
@@ -61,32 +62,33 @@ class SuddenDeath(mainTime: Int):
// Serialization // Serialization
fun TimeSystem.Companion.fromJson(json: Json.Object) = 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( "CANADIAN" -> CanadianByoyomi(
mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), mainTime = json.getInt("mainTime") ?: badRequest("missing timeSystem mainTime"),
byoyomi = json.getInt("byoyomi") ?: ApiHandler.badRequest("missing timeSystem byoyomi"), byoyomi = json.getInt("byoyomi") ?: badRequest("missing timeSystem byoyomi"),
stones = json.getInt("stones") ?: ApiHandler.badRequest("missing timeSystem stones") stones = json.getInt("stones") ?: badRequest("missing timeSystem stones")
) )
"STANDARD" -> StandardByoyomi( "STANDARD" -> StandardByoyomi(
mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), mainTime = json.getInt("mainTime") ?: badRequest("missing timeSystem mainTime"),
byoyomi = json.getInt("byoyomi") ?: ApiHandler.badRequest("missing timeSystem byoyomi"), byoyomi = json.getInt("byoyomi") ?: badRequest("missing timeSystem byoyomi"),
periods = json.getInt("periods") ?: ApiHandler.badRequest("missing timeSystem periods") periods = json.getInt("periods") ?: badRequest("missing timeSystem periods")
) )
"FISHER" -> FisherTime( "FISCHER" -> FischerTime(
mainTime = json.getInt("mainTime") ?: ApiHandler.badRequest("missing timeSystem mainTime"), mainTime = json.getInt("mainTime") ?: badRequest("missing timeSystem mainTime"),
increment = json.getInt("increment") ?: ApiHandler.badRequest("missing timeSystem increment"), increment = json.getInt("increment") ?: badRequest("missing timeSystem increment"),
maxTime = json.getInt("increment") ?: Integer.MAX_VALUE maxTime = json.getInt("maxTime") ?: Integer.MAX_VALUE
) )
"SUDDEN_DEATH" -> SuddenDeath( "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) { fun TimeSystem.toJson() = when (type) {
TimeSystem.TimeSystemType.CANADIAN -> Json.Object("mainTime" to mainTime, "byoyomi" to byoyomi, "stones" to stones) TimeSystem.TimeSystemType.CANADIAN -> Json.Object("type" to type.name, "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.STANDARD -> Json.Object("type" to type.name, "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.FISCHER ->
TimeSystem.TimeSystemType.SUDDEN_DEATH -> Json.Object("mainTime" to mainTime) 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)
} }

View File

@@ -5,10 +5,6 @@ import org.jeudego.pairgoth.model.Tournament
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.E import kotlin.math.E
// CB TODO - handle concurrency:
// - either with concurrent maps
// - or with a thread isolation (better, covers more operations)
object Store { object Store {
private val _nextTournamentId = AtomicInteger() private val _nextTournamentId = AtomicInteger()
private val _nextPlayerId = AtomicInteger() private val _nextPlayerId = AtomicInteger()

View File

@@ -12,6 +12,8 @@ import org.jeudego.pairgoth.util.toString
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.io.IOException import java.io.IOException
import java.util.* import java.util.*
import java.util.concurrent.locks.ReadWriteLock
import java.util.concurrent.locks.ReentrantReadWriteLock
import javax.servlet.http.HttpServlet import javax.servlet.http.HttpServlet
import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse import javax.servlet.http.HttpServletResponse
@@ -34,7 +36,18 @@ class ApiServlet : HttpServlet() {
doRequest(request, response) doRequest(request, response)
} }
private val lock: ReadWriteLock = ReentrantReadWriteLock()
private fun doRequest(request: HttpServletRequest, response: HttpServletResponse) { 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 val uri = request.requestURI
logger.logRequest(request, !uri.contains(".") && uri.length > 1) logger.logRequest(request, !uri.contains(".") && uri.length > 1)

View File

@@ -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")
}
}

View File

@@ -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<String>()
val subSelector = argumentCaptor<String>()
val reqPayload = argumentCaptor<String>()
val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) }
val req = mock<HttpServletRequest> {
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<Int>()
val errMessage = argumentCaptor<String>()
val resp = mock<HttpServletResponse> {
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() {
}