feat: Enhance GameResource with game state validation and add comprehensive tests

This commit is contained in:
2026-04-20 19:08:45 +02:00
committed by Janis
parent f0bde2df92
commit aea9f1a1ca
7 changed files with 379 additions and 34 deletions
+1
View File
@@ -74,6 +74,7 @@ dependencies {
testImplementation("io.rest-assured:rest-assured")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
}
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
@@ -19,4 +19,4 @@ class GameRegistryImpl extends GameRegistry:
def generateId(): String =
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
Iterator.continually(Random.nextInt(chars.length)).map(chars).take(8).mkString
Iterator.continually(Random.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
@@ -125,6 +125,9 @@ class GameResource:
private def ok(body: AnyRef): Response = Response.ok(body).build()
private def created(body: AnyRef): Response = Response.status(Response.Status.CREATED).entity(body).build()
private def assertGameNotOver(entry: GameEntry): Unit =
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
// ── endpoints ────────────────────────────────────────────────────────────
// scalafix:off DisableSyntax.throw
@@ -171,7 +174,7 @@ class GameResource:
@Produces(Array(MediaType.APPLICATION_JSON))
def resignGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
assertGameNotOver(entry)
entry.engine.resign()
registry.update(entry.copy(resigned = true))
ok(OkResponseDto())
@@ -181,7 +184,7 @@ class GameResource:
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
assertGameNotOver(entry)
val (from, to, promoOpt) = Parser
.parseMove(uci)
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
@@ -236,7 +239,7 @@ class GameResource:
@PathParam("action") action: String,
): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if entry.engine.context.result.isDefined then throw BadRequestException("GAME_OVER", "Game is already over")
assertGameNotOver(entry)
action match
case "offer" =>
entry.engine.offerDraw(entry.engine.context.turn)
@@ -0,0 +1,245 @@
package de.nowchess.chess.resource
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.*
import de.nowchess.chess.exception.BadRequestException
import de.nowchess.chess.registry.GameRegistry
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import scala.compiletime.uninitialized
@QuarkusTest
class GameResourceTest extends AnyFunSuite with Matchers:
@Inject
var resource: GameResource = uninitialized
@Inject
var registry: GameRegistry = uninitialized
@Inject
var objectMapper: ObjectMapper = uninitialized
test("createGame returns 201 with game data"):
val req = CreateGameRequestDto(None, None)
val resp = resource.createGame(req)
resp.getStatus shouldBe 201
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.gameId should not be null
dto.white.displayName shouldBe "Player 1"
dto.black.displayName shouldBe "Player 2"
dto.state.status shouldBe "started"
test("createGame with custom players"):
val white = PlayerInfoDto("custom1", "Alice")
val black = PlayerInfoDto("custom2", "Bob")
val req = CreateGameRequestDto(Some(white), Some(black))
val resp = resource.createGame(req)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.white.displayName shouldBe "Alice"
dto.black.displayName shouldBe "Bob"
test("getGame returns 200 with game state"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val getResp = resource.getGame(gameId)
getResp.getStatus shouldBe 200
val dto = getResp.getEntity.asInstanceOf[GameFullDto]
dto.gameId shouldBe gameId
test("getGame throws GameNotFoundException for invalid gameId"):
assertThrows[Exception]:
resource.getGame("invalid-id")
test("makeMove on new game advances position"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val moveResp = resource.makeMove(gameId, "e2e4")
moveResp.getStatus shouldBe 200
val state = moveResp.getEntity.asInstanceOf[GameStateDto]
state.turn shouldBe "black"
test("makeMove with invalid UCI throws BadRequestException"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.makeMove(gameId, "invalid")
test("makeMove throws GAME_OVER after game ends"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "f2f3")
resource.makeMove(gameId, "e7e6")
resource.makeMove(gameId, "g2g4")
resource.makeMove(gameId, "d8h4")
val ex = the[BadRequestException] thrownBy:
resource.makeMove(gameId, "a2a3")
ex.code shouldBe "GAME_OVER"
test("getLegalMoves returns all moves when no square specified"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "")
movesResp.getStatus shouldBe 200
val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto]
dto.moves should not be empty
test("getLegalMoves returns moves for specific square"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "e2")
movesResp.getStatus shouldBe 200
val dto = movesResp.getEntity.asInstanceOf[LegalMovesResponseDto]
dto.moves.map(_.from).distinct shouldBe List("e2")
test("getLegalMoves with invalid square throws"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.getLegalMoves(gameId, "invalid")
test("resignGame updates game state"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resignResp = resource.resignGame(gameId)
resignResp.getStatus shouldBe 200
val getResp = resource.getGame(gameId)
val dto = getResp.getEntity.asInstanceOf[GameFullDto]
dto.state.status shouldBe "resign"
test("resignGame throws GAME_OVER when game already over"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "f2f3")
resource.makeMove(gameId, "e7e6")
resource.makeMove(gameId, "g2g4")
resource.makeMove(gameId, "d8h4")
val ex = the[BadRequestException] thrownBy:
resource.resignGame(gameId)
ex.code shouldBe "GAME_OVER"
test("undoMove reverts last move"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val undoResp = resource.undoMove(gameId)
undoResp.getStatus shouldBe 200
val state = undoResp.getEntity.asInstanceOf[GameStateDto]
state.turn shouldBe "white"
test("undoMove throws NO_UNDO when no moves to undo"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.undoMove(gameId)
test("redoMove restores undone move"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
resource.undoMove(gameId)
val redoResp = resource.redoMove(gameId)
redoResp.getStatus shouldBe 200
val state = redoResp.getEntity.asInstanceOf[GameStateDto]
state.turn shouldBe "black"
test("redoMove throws NO_REDO when no moves to redo"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.redoMove(gameId)
test("drawAction offer returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "offer")
resp.getStatus shouldBe 200
test("drawAction accept returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "accept")
resp.getStatus shouldBe 200
test("drawAction decline returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "decline")
resp.getStatus shouldBe 200
test("drawAction claim returns 200"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "claim")
resp.getStatus shouldBe 200
test("drawAction with invalid action throws"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows[BadRequestException]:
resource.drawAction(gameId, "invalid")
test("drawAction throws GAME_OVER when game already over"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "f2f3")
resource.makeMove(gameId, "e7e6")
resource.makeMove(gameId, "g2g4")
resource.makeMove(gameId, "d8h4")
val ex = the[BadRequestException] thrownBy:
resource.drawAction(gameId, "offer")
ex.code shouldBe "GAME_OVER"
test("importFen creates game from FEN"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val req = ImportFenRequestDto(fen, None, None)
val resp = resource.importFen(req)
resp.getStatus shouldBe 201
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.state.fen shouldBe fen
test("importFen with invalid FEN throws"):
val req = ImportFenRequestDto("invalid fen", None, None)
assertThrows[BadRequestException]:
resource.importFen(req)
test("importPgn creates game from PGN"):
val pgn = "1. e4 c5"
val req = ImportPgnRequestDto(pgn)
val resp = resource.importPgn(req)
resp.getStatus shouldBe 201
val dto = resp.getEntity.asInstanceOf[GameFullDto]
dto.state.moves.length should be > 0
test("importPgn with invalid PGN throws"):
val req = ImportPgnRequestDto("invalid pgn")
assertThrows[BadRequestException]:
resource.importPgn(req)
test("exportFen returns FEN string"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.exportFen(gameId)
resp.getStatus shouldBe 200
resp.getEntity.asInstanceOf[String] should include("rnbqkbnr")
test("exportPgn returns PGN string"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val resp = resource.exportPgn(gameId)
resp.getStatus shouldBe 200
resp.getEntity.asInstanceOf[String] should include("1.")
test("streamGame emits initial game state"):
val createResp = resource.createGame(CreateGameRequestDto(None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val multi = resource.streamGame(gameId)
val events = multi.collect().asList().await.indefinitely()
events should not be empty
events.get(0) should include("GameFullEventDto")