feat: NCS-37 import FEN/PGN, export endpoints, NDJSON stream snapshot

This commit is contained in:
LQ63
2026-04-13 16:05:19 +02:00
parent 33dd63a9b6
commit acddd58ad3
2 changed files with 192 additions and 0 deletions
@@ -0,0 +1,29 @@
package de.nowchess.backcore.resource
import de.nowchess.backcore.dto.{ApiErrorResponse, ImportFenRequest, ImportPgnRequest}
import de.nowchess.backcore.game.{GameMapper, GameStore}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
@Path("/api/board/game/import")
@Produces(Array(MediaType.APPLICATION_JSON))
@Consumes(Array(MediaType.APPLICATION_JSON))
@ApplicationScoped
class ImportResource @Inject() (store: GameStore):
@POST
@Path("/fen")
def importFen(req: ImportFenRequest): Response =
store.importFen(Option(req).getOrElse(ImportFenRequest())) match
case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build()
case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_FEN", err)).build()
@POST
@Path("/pgn")
def importPgn(req: ImportPgnRequest): Response =
val body = Option(req).getOrElse(ImportPgnRequest())
store.importPgn(body.pgn, None, None) match
case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build()
case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_PGN", err)).build()
@@ -0,0 +1,163 @@
package de.nowchess.backcore.resource
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
import org.hamcrest.Matchers.{containsString, equalTo, matchesPattern, notNullValue}
import org.junit.jupiter.api.Test
@QuarkusTest
class ImportExportTest:
private val startFen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
// ─── Import FEN ────────────────────────────────────────────────
@Test
def importFenReturns201WithCorrectPosition(): Unit =
RestAssured.`given`()
.contentType("application/json")
.body(s"""{"fen":"$startFen"}""")
.when()
.post("/api/board/game/import/fen")
.`then`()
.statusCode(201)
.body("gameId", matchesPattern("[A-Za-z0-9]{8}"))
.body("state.fen", equalTo(startFen))
.body("state.turn", equalTo("white"))
@Test
def importFenWithCustomPositionWorks(): Unit =
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
RestAssured.`given`()
.contentType("application/json")
.body(s"""{"fen":"$fen"}""")
.when()
.post("/api/board/game/import/fen")
.`then`()
.statusCode(201)
.body("state.fen", equalTo(fen))
.body("state.turn", equalTo("black"))
@Test
def importFenWithInvalidFenReturns400(): Unit =
RestAssured.`given`()
.contentType("application/json")
.body("""{"fen":"not-a-fen"}""")
.when()
.post("/api/board/game/import/fen")
.`then`()
.statusCode(400)
// ─── Import PGN ────────────────────────────────────────────────
@Test
def importPgnReturns201(): Unit =
RestAssured.`given`()
.contentType("application/json")
.body("""{"pgn":"1. e4 e5 2. Nf3 Nc6 *"}""")
.when()
.post("/api/board/game/import/pgn")
.`then`()
.statusCode(201)
.body("gameId", matchesPattern("[A-Za-z0-9]{8}"))
.body("state.turn", equalTo("white"))
@Test
def importPgnWithInvalidPgnReturns400(): Unit =
RestAssured.`given`()
.contentType("application/json")
.body("""{"pgn":"1. z9 *"}""")
.when()
.post("/api/board/game/import/pgn")
.`then`()
.statusCode(400)
// ─── Export FEN ────────────────────────────────────────────────
@Test
def exportFenReturnsStartingFen(): Unit =
val gameId = RestAssured.`given`()
.contentType("application/json")
.body("{}")
.when()
.post("/api/board/game")
.`then`()
.statusCode(201)
.extract()
.path[String]("gameId")
RestAssured.`given`()
.when()
.get(s"/api/board/game/$gameId/export/fen")
.`then`()
.statusCode(200)
.body(equalTo(startFen))
@Test
def exportFenOnUnknownGameReturns404(): Unit =
RestAssured.`given`()
.when()
.get("/api/board/game/XXXXXXXX/export/fen")
.`then`()
.statusCode(404)
// ─── Export PGN ────────────────────────────────────────────────
@Test
def exportPgnReturnsText(): Unit =
val gameId = RestAssured.`given`()
.contentType("application/json")
.body("{}")
.when()
.post("/api/board/game")
.`then`()
.statusCode(201)
.extract()
.path[String]("gameId")
RestAssured.`given`()
.when()
.post(s"/api/board/game/$gameId/move/e2e4")
.`then`()
.statusCode(200)
RestAssured.`given`()
.when()
.get(s"/api/board/game/$gameId/export/pgn")
.`then`()
.statusCode(200)
.body(containsString("e4"))
// ─── Stream ────────────────────────────────────────────────────
@Test
def streamReturnsNdjsonSnapshot(): Unit =
val gameId = RestAssured.`given`()
.contentType("application/json")
.body("{}")
.when()
.post("/api/board/game")
.`then`()
.statusCode(201)
.extract()
.path[String]("gameId")
val body = RestAssured.`given`()
.when()
.get(s"/api/board/game/$gameId/stream")
.`then`()
.statusCode(200)
.contentType("application/x-ndjson")
.extract()
.body()
.asString()
assert(body.trim.startsWith("""{"type":"gameFull""""), s"Expected gameFull event, got: $body")
@Test
def streamOnUnknownGameReturns404(): Unit =
RestAssured.`given`()
.when()
.get("/api/board/game/XXXXXXXX/stream")
.`then`()
.statusCode(404)