diff --git a/docker-compose.yml b/docker-compose.yml index 62960875..44d70a2e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,4 +3,6 @@ services: image: ghcr.io/ss26-se2-codenames/backend:latest restart: unless-stopped ports: - - "53213:8080" \ No newline at end of file + - "53213:8080" + volumes: + - ./data:/app/data \ No newline at end of file diff --git a/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java b/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java index b9ec4969..59d41e9b 100644 --- a/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java +++ b/src/main/java/com/codenames/codenames/backend/game/controller/GameSocketController.java @@ -6,6 +6,7 @@ import com.codenames.codenames.backend.game.dto.RevealCardMessage; import com.codenames.codenames.backend.game.dto.StartGameMessage; import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; @@ -22,6 +23,7 @@ public class GameSocketController { private final GameService gameService; private final SimpMessagingTemplate messagingTemplate; + private final SystemStatePersistenceService persistenceService; private static final String GAME_TOPIC_PREFIX = "/topic/game/"; @@ -30,11 +32,16 @@ public class GameSocketController { * * @param gameService service responsible for gameplay logic * @param messagingTemplate template used for broadcasting websocket messages + * @param persistenceService service used to persist current backend state */ - public GameSocketController(GameService gameService, SimpMessagingTemplate messagingTemplate) { + public GameSocketController( + GameService gameService, + SimpMessagingTemplate messagingTemplate, + SystemStatePersistenceService persistenceService) { this.gameService = gameService; this.messagingTemplate = messagingTemplate; + this.persistenceService = persistenceService; } /** @@ -61,6 +68,7 @@ public void startGame(StartGameMessage message) { public void revealCard(RevealCardMessage message) { gameService.flipCard(message.getLobbyCode(), message.getPosition(), message.getCurrentTurn()); + persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), @@ -81,6 +89,7 @@ public void submitClue(ClueMessage message) { message.getLobbyCode(), new Clue(message.getWord(), message.getGuessAmount()), message.getCurrentTurn()); + persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), @@ -96,6 +105,7 @@ public void submitClue(ClueMessage message) { public void passTurn(PassTurnMessage message) { gameService.passTurn(message.getLobbyCode(), message.getCurrentTurn()); + persistenceService.persistCurrentState(); messagingTemplate.convertAndSend( GAME_TOPIC_PREFIX + message.getLobbyCode(), diff --git a/src/main/java/com/codenames/codenames/backend/game/dto/GameStateDto.java b/src/main/java/com/codenames/codenames/backend/game/dto/GameStateDto.java deleted file mode 100644 index 010b8ef3..00000000 --- a/src/main/java/com/codenames/codenames/backend/game/dto/GameStateDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.codenames.codenames.backend.game.dto; - -import com.codenames.codenames.backend.clue.Clue; -import com.codenames.codenames.backend.playingfield.Card; -import com.codenames.codenames.backend.utility.Role; -import com.codenames.codenames.backend.utility.Team; -import java.util.List; - -/** - * DTO representing the current game state. - */ -public record GameStateDto( - List cards, - Clue currentClue, - int remainingGuesses, - Team winner, - Team currentTurn, - Role currentPhase -) {} diff --git a/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java b/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java index 2f73b52f..4415ca4e 100644 --- a/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java +++ b/src/main/java/com/codenames/codenames/backend/lobby/controller/LobbyController.java @@ -3,6 +3,7 @@ import com.codenames.codenames.backend.lobby.dto.LobbyResponse; import com.codenames.codenames.backend.lobby.dto.PlayerDto; import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -19,21 +20,23 @@ *

Provides endpoints for creating, joining, and leaving lobbies. Delegates business logic to * {@link LobbyService}. */ - @RestController @RequestMapping("/lobby") public class LobbyController { private final LobbyService service; + private final SystemStatePersistenceService persistenceService; private static final String LOBBY_NOT_FOUND = "Could not find lobby."; /** * Creates a new {@code LobbyController}. * * @param service the lobby service used to handle business logic + * @param persistenceService service used to persist current backend state */ - public LobbyController(LobbyService service) { + public LobbyController(LobbyService service, SystemStatePersistenceService persistenceService) { this.service = service; + this.persistenceService = persistenceService; } /** @@ -42,73 +45,68 @@ public LobbyController(LobbyService service) { * @param username the username of the requesting user * @return a response containing the result and the generated lobby code */ - @GetMapping("/create") public ResponseEntity createLobby(@RequestParam String username) { String lobbyCode = service.createLobby(username); if (lobbyCode == null || lobbyCode.isBlank()) { return ResponseEntity.internalServerError() - .body(new LobbyResponse("Error while creating lobby.", "", null, false)); + .body(new LobbyResponse("Error while creating lobby.", "", null, false)); } else { + List players = service.getPlayersDto(lobbyCode); return ResponseEntity.ok( - new LobbyResponse("Successfully created Lobby.", lobbyCode, players, false) - ); + new LobbyResponse("Successfully created Lobby.", lobbyCode, players, false)); } } /** * Handles a request to join an existing lobby. * - * @param username the username of the player + * @param username the username of the player * @param lobbyCode the lobby code identifying the lobby * @return a response indicating whether the join was successful */ @GetMapping("/{lobbyCode}/join") public ResponseEntity joinLobby( - @RequestParam String username, @PathVariable String lobbyCode) { + @RequestParam String username, @PathVariable String lobbyCode) { boolean joined = service.joinLobby(username, lobbyCode); if (joined) { + return ResponseEntity.ok( - new LobbyResponse( - "Joined Lobby successfully.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + new LobbyResponse( + "Joined Lobby successfully.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } else { return ResponseEntity.badRequest() - .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); + .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); } } /** * Handles a request to leave a lobby. * - * @param username the username of the player + * @param username the username of the player * @param lobbyCode the lobby code identifying the lobby * @return a response indicating whether the operation was successful */ @GetMapping("/{lobbyCode}/leave") public ResponseEntity leaveLobby( - @PathVariable String lobbyCode, - @RequestParam String username) { + @PathVariable String lobbyCode, @RequestParam String username) { + boolean wasStarted = service.getIsStarted(lobbyCode); boolean left = service.leaveLobby(username, lobbyCode); + if (left) { - ResponseEntity response = ResponseEntity.ok( - new LobbyResponse( - "Left lobby successfully.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); service.checkLobbyStillHasPlayers(lobbyCode); - return response; + + if (wasStarted) { + persistenceService.persistCurrentState(); + } + + return ResponseEntity.ok( + new LobbyResponse( + "Left lobby successfully.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } else { return ResponseEntity.badRequest() - .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); + .body(new LobbyResponse(LOBBY_NOT_FOUND, lobbyCode, null, false)); } } @@ -116,19 +114,15 @@ public ResponseEntity leaveLobby( * An endpoint for retrieving all lobby-specific info used during polling in lobby-state. * * @param lobbyCode unique lobby code - * @return a response entity with the http code 200 for ok and - * 400 for bad request, if an error occurred + * @return a response entity with the http code 200 for ok and 400 for bad request, if an error + * occurred */ - @GetMapping("/{lobbyCode}") - public ResponseEntity getLobbyInfo( - @PathVariable String lobbyCode - ) { + public ResponseEntity getLobbyInfo(@PathVariable String lobbyCode) { List players = service.getPlayersDto(lobbyCode); boolean isStarted = service.getIsStarted(lobbyCode); return ResponseEntity.ok( - new LobbyResponse("Lobby info retrieved successfully.", lobbyCode, players, isStarted) - ); + new LobbyResponse("Lobby info retrieved successfully.", lobbyCode, players, isStarted)); } /** @@ -139,33 +133,26 @@ public ResponseEntity getLobbyInfo( */ @PostMapping("/{lobbyCode}/select-position") public ResponseEntity selectPosition( - @PathVariable String lobbyCode, @RequestBody PlayerDto request - ) { - boolean updated = service.selectPosition( - request.username(), - lobbyCode, - request.team(), - request.role() - ); + @PathVariable String lobbyCode, @RequestBody PlayerDto request) { + boolean updated = + service.selectPosition(request.username(), lobbyCode, request.team(), request.role()); if (updated) { + return ResponseEntity.ok( - new LobbyResponse( - "Position selected successfully.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + new LobbyResponse( + "Position selected successfully.", + lobbyCode, + service.getPlayersDto(lobbyCode), + false)); } else { - return ResponseEntity.badRequest().body( + return ResponseEntity.badRequest() + .body( new LobbyResponse( - "Could not assign selected team/role.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + "Could not assign selected team/role.", + lobbyCode, + service.getPlayersDto(lobbyCode), + false)); } } @@ -175,32 +162,22 @@ public ResponseEntity selectPosition( * @param lobbyCode the unique lobby code * @param username the name of the requesting user * @return a response entity of a lobby response, with isStarted @code true or @code false, - * whether the starting was successful or not + * whether the starting was successful or not */ - @GetMapping("/{lobbyCode}/start-game") public ResponseEntity startGame( - @PathVariable String lobbyCode, @RequestParam String username - ) { + @PathVariable String lobbyCode, @RequestParam String username) { boolean isStarted = service.startGame(lobbyCode, username); if (isStarted) { + persistenceService.persistCurrentState(); return ResponseEntity.ok( - new LobbyResponse( - "Game is starting now.", - lobbyCode, - service.getPlayersDto(lobbyCode), - true - ) - ); + new LobbyResponse( + "Game is starting now.", lobbyCode, service.getPlayersDto(lobbyCode), true)); } - return ResponseEntity.badRequest().body( + return ResponseEntity.badRequest() + .body( new LobbyResponse( - "Could not start the game.", - lobbyCode, - service.getPlayersDto(lobbyCode), - false - ) - ); + "Could not start the game.", lobbyCode, service.getPlayersDto(lobbyCode), false)); } } diff --git a/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java b/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java index 42c88f17..91b542db 100644 --- a/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java +++ b/src/main/java/com/codenames/codenames/backend/lobby/services/LobbyService.java @@ -90,6 +90,16 @@ public boolean joinLobby(String username, String lobbyCode) { return false; } + /** + * Registers a recovered lobby into in-memory lobby storage. + * + * @param lobbyCode lobby identifier + * @param lobby recovered lobby instance + */ + public void restoreLobby(String lobbyCode, Lobby lobby) { + lobbyList.put(lobbyCode, lobby); + } + /** * Removes a player from a lobby. * @@ -256,17 +266,16 @@ public Role getPlayerRole(String username, String lobbyCode) { } /** - * The service method for starting a game. This creates a game manager object for the lobby - * and checks if the requesting user is liable to start the game. + * The service method for starting a game. This creates a game manager object for the lobby and + * checks if the requesting user is liable to start the game. * * @param lobbyCode the unique lobby code * @param username the name of the requesting user * @return if starting was successful */ - public boolean startGame(String lobbyCode, String username) { - boolean isStarted = !lobbyCode.isBlank() && !username.isBlank() - && Objects.equals(getHost(lobbyCode), username); + boolean isStarted = + !lobbyCode.isBlank() && !username.isBlank() && Objects.equals(getHost(lobbyCode), username); Lobby lobby = lobbyList.get(lobbyCode); addGameManagerForLobby(lobby, lobbyCode); @@ -280,7 +289,6 @@ public boolean startGame(String lobbyCode, String username) { * @param lobbyCode the unique lobby code * @return the username of the host */ - public String getHost(String lobbyCode) { if (lobbyCode == null || lobbyCode.isBlank()) { return ""; @@ -298,14 +306,22 @@ public String getHost(String lobbyCode) { } /** - * Checks if the game is started by looking after an existing - * game manager object. + * Checks if the game is started by looking after an existing game manager object. * * @param lobbyCode the unique lobby code * @return whether a game manager exists (@code true or @code false) */ - public boolean getIsStarted(String lobbyCode) { return gameService.isGameStarted(lobbyCode); } + + /** + * Returns all lobbies as serializable snapshots. + * + * @return map of lobby codes to player dto lists + */ + public Map> getLobbySnapshots() { + return lobbyList.keySet().stream() + .collect(java.util.stream.Collectors.toMap(lobbyCode -> lobbyCode, this::getPlayersDto)); + } } diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/Board.java b/src/main/java/com/codenames/codenames/backend/playingfield/Board.java index ad38df1c..4e9cefc6 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/Board.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/Board.java @@ -1,6 +1,7 @@ package com.codenames.codenames.backend.playingfield; import com.codenames.codenames.backend.utility.Color; +import java.util.ArrayList; import java.util.List; import lombok.Getter; @@ -23,6 +24,15 @@ public Board( this.cardList = cardGenerator.generateCards(totalWords, red, blue, white, black); } + /** + * Constructs a board from an already existing card list (recovery path). + * + * @param cards recovered cards + */ + public Board(List cards) { + this.cardList = new ArrayList<>(cards); + } + /** * Returns the card object at the position passed. * diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java index 6a35b5fa..ee000c4b 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManager.java @@ -2,6 +2,8 @@ import com.codenames.codenames.backend.clue.Clue; import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Color; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; @@ -64,6 +66,65 @@ public GameManager( new Board(cardGenerator, TOTAL_CARDS, redCards, blueCards, WHITE_CARDS, BLACK_CARDS); } + /** + * Constructor used by recovery logic to rebuild an already running game state. + * + * @param state bundled recovery state + * @param clueValidationService clue validation service + */ + public GameManager( + GameStateDataTransferObject state, ClueValidationService clueValidationService) { + if (state.cardList() == null || state.cardList().isEmpty()) { + throw new IllegalArgumentException("cards cannot be null or empty"); + } + if (state.currentTurn() == null || state.currentPhase() == null) { + throw new IllegalArgumentException("current turn and phase cannot be null"); + } + + this.currentTurn = state.currentTurn(); + this.currentPhase = state.currentPhase(); + this.winner = state.winner(); + this.remainingGuesses = state.remainingGuesses(); + this.currentClue = + state.currentClue() == null + ? null + : new Clue(state.currentClue().word(), state.currentClue().guessAmount()); + this.clueValidationService = clueValidationService; + + List cards = state.cardList().stream().map(this::toCard).toList(); + + this.currentRedFound = countGuessedByColor(cards, Color.RED); + this.currentBlueFound = countGuessedByColor(cards, Color.BLUE); + + this.redCards = countCardsByColor(cards, Color.RED); + this.blueCards = countCardsByColor(cards, Color.BLUE); + this.board = new Board(cards); + } + + private Card toCard(CardDataTransferObject cardDto) { + Card card = new Card(cardDto.word(), cardDto.color()); + if (cardDto.isGuessed()) { + card.setIsGuessedTrue(); + } + return card; + } + + private int countGuessedByColor(List cards, Color color) { + return (int) + cards.stream().filter(card -> card.isGuessed() && card.getColor() == color).count(); + } + + /** + * Counts cards of a specific color within the current board snapshot. + * + * @param cards recovered cards + * @param color color to count + * @return number of cards with the requested color + */ + private int countCardsByColor(List cards, Color color) { + return (int) cards.stream().filter(card -> card.getColor() == color).count(); + } + /** * Returns the current list of cards in a board. * diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java index 37e10322..63b468ab 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameManagerFactory.java @@ -1,6 +1,7 @@ package com.codenames.codenames.backend.playingfield; import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Team; import org.springframework.stereotype.Component; @@ -31,4 +32,14 @@ public GameManagerFactory( public GameManager create(Team startingTeam) { return new GameManager(startingTeam, cardGenerator, clueValidationService); } + + /** + * Recreates a {@link GameManager} from a persisted snapshot. + * + * @param snapshot persisted game state snapshot + * @return restored game manager + */ + public GameManager createFromSnapshot(GameStateDataTransferObject snapshot) { + return new GameManager(snapshot, clueValidationService); + } } diff --git a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java index d750c385..a3ec0b52 100644 --- a/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java +++ b/src/main/java/com/codenames/codenames/backend/playingfield/GameService.java @@ -1,7 +1,6 @@ package com.codenames.codenames.backend.playingfield; import com.codenames.codenames.backend.clue.Clue; -import com.codenames.codenames.backend.game.dto.GameStateDto; import com.codenames.codenames.backend.serialization.DataTransferObjectService; import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Team; @@ -51,6 +50,16 @@ public void removeGame(String lobbyCode) { games.remove(lobbyCode); } + /** + * Registers a recovered {@link GameManager} for a lobby after backend restart. + * + * @param lobbyCode lobby identifier + * @param gameManager recovered game manager + */ + public void restoreGameManager(String lobbyCode, GameManager gameManager) { + games.put(lobbyCode, gameManager); + } + /** * Helper method to retrieve a GM object from the hash map. * @@ -109,25 +118,6 @@ public void passTurn(String lobbyCode, Team callingTeam) { gm.passTurn(callingTeam); } - /** - * Creates a DTO representing the current game state. - * - * @param lobbyCode lobby identifier - * @return DTO containing board and turn information - */ - public GameStateDto createGameStateDto(String lobbyCode) { - - GameManager gm = getGame(lobbyCode); - - return new GameStateDto( - gm.getCardList(), - gm.getCurrentClue(), - gm.getRemainingGuesses(), - gm.getWinner(), - gm.getCurrentTurn(), - gm.getCurrentPhase()); - } - /** * Maps the current game state into a @link GameStateTransferObject. * @@ -137,13 +127,12 @@ public GameStateDto createGameStateDto(String lobbyCode) { public GameStateDataTransferObject getCurrentGameState(String lobbyCode) { GameManager gm = getGame(lobbyCode); return dtoService.createGameStateDataTransferObject( - gm, gm.getCurrentTurn(), gm.getCurrentPhase() - ); + gm, gm.getCurrentTurn(), gm.getCurrentPhase()); } /** - * This method uses the private method getGame to check if a game is already started - * via the existence of a game manager. + * This method uses the private method getGame to check if a game is already started via the + * existence of a game manager. * * @param lobbyCode the lobbyCode of the lobby * @return if the game manager for the lobby already exists aka the game is started @@ -156,4 +145,15 @@ public boolean isGameStarted(String lobbyCode) { return false; } } + + /** + * Returns all active games as serializable snapshots. + * + * @return map of lobby codes to game state dto snapshots + */ + public Map getGameSnapshots() { + return games.keySet().stream() + .collect( + java.util.stream.Collectors.toMap(lobbyCode -> lobbyCode, this::getCurrentGameState)); + } } diff --git a/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java b/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java new file mode 100644 index 00000000..1c58c2ca --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/JsonStateStore.java @@ -0,0 +1,99 @@ +package com.codenames.codenames.backend.recovery; + +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.concurrent.locks.ReentrantLock; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * JSON-backed storage for persisted system snapshots. + * + *

Writes are synchronized and performed via temporary-file replace to reduce corruption risk. + */ +@Component +@Getter +public class JsonStateStore { + + private final ObjectMapper objectMapper; + private final Path stateFilePath; + private final ReentrantLock ioLock = new ReentrantLock(); + + /** + * Creates a new state store with configured target file path. + * + * @param objectMapper mapper used for JSON serialization + * @param stateFile configured path to the persisted state file + */ + public JsonStateStore( + ObjectMapper objectMapper, @Value("${app.state-file:data/state.json}") String stateFile) { + this.objectMapper = objectMapper; + this.stateFilePath = Path.of(stateFile); + } + + /** + * Persists the full system snapshot atomically. + * + * @param snapshot full snapshot to store + */ + public void save(SystemSnapshot snapshot) { + ioLock.lock(); + try { + Path parentDirectory = stateFilePath.getParent(); + if (parentDirectory != null) { + Files.createDirectories(parentDirectory); + } + + Path tempFilePath = stateFilePath.resolveSibling(stateFilePath.getFileName() + ".tmp"); + objectMapper.writerWithDefaultPrettyPrinter().writeValue(tempFilePath.toFile(), snapshot); + + moveAtomically(tempFilePath, stateFilePath); + } catch (IOException exception) { + throw new IllegalStateException("Failed to persist state snapshot.", exception); + } finally { + ioLock.unlock(); + } + } + + /** + * Loads the persisted system snapshot when present. + * + * @return optional snapshot + */ + public Optional load() { + ioLock.lock(); + try { + if (!Files.exists(stateFilePath) || Files.size(stateFilePath) == 0L) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(stateFilePath.toFile(), SystemSnapshot.class)); + } catch (IOException exception) { + throw new IllegalStateException("Failed to load persisted state snapshot.", exception); + } finally { + ioLock.unlock(); + } + } + + /** + * Replaces target file with source file using atomic move where supported. + * + * @param source temporary source file + * @param target final target file + * @throws IOException when move fails + */ + private void moveAtomically(Path source, Path target) throws IOException { + try { + Files.move( + source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java new file mode 100644 index 00000000..17bf2406 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceService.java @@ -0,0 +1,52 @@ +package com.codenames.codenames.backend.recovery; + +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import org.springframework.stereotype.Service; + +/** + * Persists the current runtime state of the backend. + * + *

Collects lobby and game snapshots and stores them through the configured {@link + * JsonStateStore} to enable recovery after application restart. + */ +@Service +public class SystemStatePersistenceService { + + private final JsonStateStore stateStore; + private final LobbyService lobbyService; + private final GameService gameService; + + /** + * Creates a persistence service responsible for storing backend state snapshots. + * + * @param stateStore JSON storage implementation + * @param lobbyService service providing lobby snapshots + * @param gameService service providing game snapshots + */ + public SystemStatePersistenceService( + JsonStateStore stateStore, LobbyService lobbyService, GameService gameService) { + + this.stateStore = stateStore; + this.lobbyService = lobbyService; + this.gameService = gameService; + } + + /** + * Persists the current backend state. + * + *

Creates a {@link SystemSnapshot} containing all active lobbies and games and stores it using + * the configured {@link JsonStateStore}. + */ + public void persistCurrentState() { + + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + lobbyService.getLobbySnapshots(), + gameService.getGameSnapshots()); + + stateStore.save(snapshot); + } +} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java new file mode 100644 index 00000000..1425196d --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryService.java @@ -0,0 +1,137 @@ +package com.codenames.codenames.backend.recovery; + +import com.codenames.codenames.backend.lobby.Lobby; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameManager; +import com.codenames.codenames.backend.playingfield.GameManagerFactory; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** Restores persisted lobbies and games into in-memory runtime services on backend startup. */ +@Slf4j +@Service +public class SystemStateRecoveryService { + + private final JsonStateStore stateStore; + private final LobbyService lobbyService; + private final GameService gameService; + private final GameManagerFactory gameManagerFactory; + + /** + * Creates a recovery service. + * + * @param stateStore JSON state store used for loading snapshots + * @param lobbyService lobby runtime service + * @param gameService game runtime service + * @param gameManagerFactory factory used to rebuild game managers from snapshots + */ + public SystemStateRecoveryService( + JsonStateStore stateStore, + LobbyService lobbyService, + GameService gameService, + GameManagerFactory gameManagerFactory) { + this.stateStore = stateStore; + this.lobbyService = lobbyService; + this.gameService = gameService; + this.gameManagerFactory = gameManagerFactory; + } + + /** Loads and restores persisted state at startup when a compatible snapshot exists. */ + @jakarta.annotation.PostConstruct + public void recoverOnStartup() { + stateStore + .load() + .ifPresent( + snapshot -> { + if (snapshot.schemaVersion() != SystemSnapshot.CURRENT_SCHEMA_VERSION) { + log.warn( + "Skipping snapshot restore due to schema mismatch. Found {}, expected {}.", + snapshot.schemaVersion(), + SystemSnapshot.CURRENT_SCHEMA_VERSION); + return; + } + restoreLobbies(snapshot.lobbies()); + restoreGames(snapshot.games()); + }); + } + + /** + * Restores all lobby snapshots into {@link LobbyService}. + * + * @param lobbySnapshots persisted lobby player lists keyed by lobby code + */ + private void restoreLobbies(Map> lobbySnapshots) { + if (lobbySnapshots == null || lobbySnapshots.isEmpty()) { + return; + } + for (Map.Entry> entry : lobbySnapshots.entrySet()) { + Lobby restoredLobby = buildLobby(entry.getKey(), entry.getValue()); + if (restoredLobby != null) { + lobbyService.restoreLobby(entry.getKey(), restoredLobby); + } + } + } + + /** + * Restores all game snapshots into {@link GameService}. + * + * @param gameSnapshots persisted game states keyed by lobby code + */ + private void restoreGames(Map gameSnapshots) { + if (gameSnapshots == null || gameSnapshots.isEmpty()) { + return; + } + for (Map.Entry entry : gameSnapshots.entrySet()) { + GameManager restoredGame = gameManagerFactory.createFromSnapshot(entry.getValue()); + gameService.restoreGameManager(entry.getKey(), restoredGame); + } + } + + /** + * Builds a runtime lobby from a persisted lobby snapshot. + * + * @param lobbyCode target lobby code + * @param players persisted lobby players + * @return rebuilt lobby, or {@code null} when player data is invalid + */ + private Lobby buildLobby(String lobbyCode, List players) { + if (players == null || players.isEmpty()) { + log.warn("Skipping restore for lobby {} due to missing player data.", lobbyCode); + return null; + } + + List validPlayers = + players.stream() + .filter(player -> player.username() != null && !player.username().isBlank()) + .sorted(Comparator.comparing(PlayerDto::isHost).reversed()) + .toList(); + + if (validPlayers.isEmpty()) { + return null; + } + + PlayerDto host = validPlayers.get(0); + Lobby lobby = new Lobby(lobbyCode, host.username()); + + for (PlayerDto player : validPlayers) { + if (!player.username().equals(host.username())) { + lobby.addPlayer(player.username(), player.isHost()); + } + if (player.team() != null) { + lobby.setPlayerTeam(player.username(), player.team()); + } + if (player.role() != null) { + lobby.setPlayerRole(player.username(), player.role()); + } + } + + return lobby; + } +} diff --git a/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java new file mode 100644 index 00000000..3ae69bb4 --- /dev/null +++ b/src/main/java/com/codenames/codenames/backend/recovery/snapshot/SystemSnapshot.java @@ -0,0 +1,21 @@ +package com.codenames.codenames.backend.recovery.snapshot; + +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import java.util.List; +import java.util.Map; + +/** + * Root snapshot aggregate for persisted backend runtime state. + * + * @param schemaVersion persisted schema version + * @param lobbies lobby player lists keyed by lobby code + * @param games game states keyed by lobby code + */ +public record SystemSnapshot( + int schemaVersion, + Map> lobbies, + Map games) { + + public static final int CURRENT_SCHEMA_VERSION = 2; +} diff --git a/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java b/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java index a282acf5..d1dfb13c 100644 --- a/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java +++ b/src/main/java/com/codenames/codenames/backend/serialization/DataTransferObjectService.java @@ -33,7 +33,7 @@ private CardDataTransferObject createCardDataTransferObject(Card card) { * @return a DTO of the current game state */ public GameStateDataTransferObject createGameStateDataTransferObject( - GameManager gameManager, Team currentTurn, Role currentPhase) { + GameManager gameManager, Team currentTurn, Role currentPhase) { List cardList = gameManager.getCardList(); List cardDataTransferObject = new ArrayList<>(); @@ -46,6 +46,7 @@ public GameStateDataTransferObject createGameStateDataTransferObject( currentTurn, currentPhase, null, + gameManager.getRemainingGuesses(), cardDataTransferObject); } String word = gameManager.getCurrentClue().word(); @@ -55,6 +56,7 @@ public GameStateDataTransferObject createGameStateDataTransferObject( currentTurn, currentPhase, new ClueDto(word, guessAmount), + gameManager.getRemainingGuesses(), cardDataTransferObject); } } diff --git a/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java b/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java index 37f18532..54eb2c7c 100644 --- a/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java +++ b/src/main/java/com/codenames/codenames/backend/serialization/GameStateDataTransferObject.java @@ -10,7 +10,9 @@ * * @param winner the winner * @param currentTurn the current team who is allowed to make a move + * @param currentPhase the current phase (spymaster or operative) * @param currentClue the current clue object, consisting of word and amount of guesses + * @param remainingGuesses amount of guesses left for the current turn * @param cardList the cards on the board */ public record GameStateDataTransferObject( @@ -18,4 +20,5 @@ public record GameStateDataTransferObject( Team currentTurn, Role currentPhase, ClueDto currentClue, + int remainingGuesses, List cardList) {} diff --git a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java index b386568f..3cec2aee 100644 --- a/src/main/java/com/codenames/codenames/backend/websocket/GameController.java +++ b/src/main/java/com/codenames/codenames/backend/websocket/GameController.java @@ -80,6 +80,7 @@ public void join(JoinMessage message, SimpMessageHeaderAccessor headerAccessor) sessionRegistry.register(sessionId, message.getName(), message.getCode()); + sendPlayerUpdate(message.getCode()); sendGameStateUpdate(message.getCode()); } @@ -101,6 +102,6 @@ private void sendPlayerUpdate(String code) { * @param code the lobby code identifying the game */ private void sendGameStateUpdate(String code) { - messagingTemplate.convertAndSend("/topic/game/" + code, gameService.createGameStateDto(code)); + messagingTemplate.convertAndSend("/topic/game/" + code, gameService.getCurrentGameState(code)); } } diff --git a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java index a5183a5a..1c56dd67 100644 --- a/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/game/controller/GameSocketControllerTest.java @@ -1,9 +1,8 @@ package com.codenames.codenames.backend.game.controller; -import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -12,7 +11,10 @@ import com.codenames.codenames.backend.game.dto.RevealCardMessage; import com.codenames.codenames.backend.game.dto.StartGameMessage; import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Team; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -24,83 +26,87 @@ @ExtendWith(MockitoExtension.class) class GameSocketControllerTest { + private static final String LOBBY_CODE = "ABCDE"; + @Mock private GameService gameService; @Mock private SimpMessagingTemplate messagingTemplate; + @Mock private SystemStatePersistenceService persistenceService; + private GameSocketController controller; @BeforeEach void setUp() { - - controller = new GameSocketController(gameService, messagingTemplate); + controller = new GameSocketController(gameService, messagingTemplate, persistenceService); } @Test - void startGameShouldBroadcastState() { - + void startGameShouldBroadcastStateWithoutPersisting() { StartGameMessage message = new StartGameMessage(); + message.setLobbyCode(LOBBY_CODE); - message.setLobbyCode("ABCDE"); - - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.startGame(message); + verify(persistenceService, never()).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @Test - void revealCardShouldBroadcastState() { - + void revealCardShouldPersistAndBroadcastState() { RevealCardMessage message = new RevealCardMessage(); - - message.setLobbyCode("ABCDE"); + message.setLobbyCode(LOBBY_CODE); message.setPosition(0); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.revealCard(message); - verify(gameService).flipCard("ABCDE", 0, Team.RED); - + verify(gameService).flipCard(LOBBY_CODE, 0, Team.RED); + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @Test - void submitClueShouldBroadcastState() { - + void submitClueShouldPersistAndBroadcastState() { ClueMessage message = new ClueMessage(); - - message.setLobbyCode("ABCDE"); + message.setLobbyCode(LOBBY_CODE); message.setWord("animal"); message.setGuessAmount(2); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.submitClue(message); verify(gameService).submitClue(anyString(), any(), any()); - + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } @Test - void passTurnShouldBroadcastUpdatedState() { - + void passTurnShouldPersistAndBroadcastUpdatedState() { PassTurnMessage message = new PassTurnMessage(); - - message.setLobbyCode("ABCDE"); + message.setLobbyCode(LOBBY_CODE); message.setCurrentTurn(Team.RED); - when(gameService.getCurrentGameState("ABCDE")).thenReturn(mock(GameStateDataTransferObject.class)); + when(gameService.getCurrentGameState(LOBBY_CODE)) + .thenReturn(createGameStateDataTransferObject()); controller.passTurn(message); - verify(gameService).passTurn("ABCDE", Team.RED); - + verify(gameService).passTurn(LOBBY_CODE, Team.RED); + verify(persistenceService).persistCurrentState(); verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); } + + private GameStateDataTransferObject createGameStateDataTransferObject() { + return new GameStateDataTransferObject(null, Team.RED, null, null, 0, List.of()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java b/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java index f45a62e9..3ae261c6 100644 --- a/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/lobby/controller/LobbyControllerTest.java @@ -1,9 +1,19 @@ package com.codenames.codenames.backend.lobby.controller; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import com.codenames.codenames.backend.lobby.dto.PlayerDto; import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.recovery.SystemStatePersistenceService; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; +import java.util.List; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -11,113 +21,114 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; -import java.util.List; - -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - @WebMvcTest(LobbyController.class) class LobbyControllerTest { - @Autowired - private MockMvc mockMvc; + @Autowired private MockMvc mockMvc; + + @MockBean private LobbyService service; - @MockBean - private LobbyService service; + @MockBean private SystemStatePersistenceService persistenceService; @Test void createLobbyShouldReturn200() throws Exception { when(service.createLobby("TestUser")).thenReturn("ABCDE"); - mockMvc.perform(get("/lobby/create") - .param("username", "TestUser")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Successfully created Lobby.")) - .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + mockMvc + .perform(get("/lobby/create").param("username", "TestUser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Successfully created Lobby.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + + verifyNoInteractions(persistenceService); } @Test void createLobbyBlankLobbyCode() throws Exception { when(service.createLobby("TestUser")).thenReturn(""); - mockMvc.perform(get("/lobby/create") - .param("username", "TestUser")) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.message").value("Error while creating lobby.")) - .andExpect(jsonPath("$.lobbyCode").value("")); + mockMvc + .perform(get("/lobby/create").param("username", "TestUser")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Error while creating lobby.")) + .andExpect(jsonPath("$.lobbyCode").value("")); } @Test void createLobbyNullLobbyCode() throws Exception { when(service.createLobby("TestUser")).thenReturn(null); - mockMvc.perform(get("/lobby/create") - .param("username", "TestUser")) - .andExpect(status().isInternalServerError()) - .andExpect(jsonPath("$.message").value("Error while creating lobby.")) - .andExpect(jsonPath("$.lobbyCode").value("")); + mockMvc + .perform(get("/lobby/create").param("username", "TestUser")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.message").value("Error while creating lobby.")) + .andExpect(jsonPath("$.lobbyCode").value("")); } @Test - void joinLobbyShouldReturn200_whenSuccess() throws Exception { + void joinLobbyShouldReturn200WhenSuccess() throws Exception { when(service.joinLobby("TestUser", "ABCDE")).thenReturn(true); - mockMvc.perform(get("/lobby/ABCDE/join") - .param("username", "TestUser")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Joined Lobby successfully.")); + mockMvc + .perform(get("/lobby/ABCDE/join").param("username", "TestUser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Joined Lobby successfully.")); + + verifyNoInteractions(persistenceService); } @Test void getLobbyInfoShouldReturn200() throws Exception { - when(service.getPlayersDto("ABCDE")).thenReturn(List.of(new PlayerDto("test", null, null, true))); + when(service.getPlayersDto("ABCDE")) + .thenReturn(List.of(new PlayerDto("test", null, null, true))); String url = "/lobby/ABCDE"; - mockMvc.perform(get(url)) - .andExpect(status().isOk()); + mockMvc.perform(get(url)).andExpect(status().isOk()); } @Test - void joinLobbyShouldReturn400_whenNotFound() throws Exception { + void joinLobbyShouldReturn400WhenNotFound() throws Exception { when(service.joinLobby("TestUser", "XXXXX")).thenReturn(false); - mockMvc.perform(get("/lobby/XXXXX/join") - .param("username", "TestUser")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Could not find lobby.")); + mockMvc + .perform(get("/lobby/XXXXX/join").param("username", "TestUser")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Could not find lobby.")); } @Test - void leaveLobbyShouldReturn200_whenSuccess() throws Exception { + void leaveLobbyShouldReturn200WhenSuccess() throws Exception { + when(service.getIsStarted("ABCDE")).thenReturn(false); when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(true); - mockMvc.perform(get("/lobby/ABCDE/leave") - .param("username", "TestUser")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Left lobby successfully.")); + mockMvc + .perform(get("/lobby/ABCDE/leave").param("username", "TestUser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Left lobby successfully.")); + + verifyNoInteractions(persistenceService); } @Test void leaveLobbyNoSuccess() throws Exception { when(service.leaveLobby("TestUser", "ABCDE")).thenReturn(false); - mockMvc.perform(get("/lobby/ABCDE/leave") - .param("username", "TestUser") - .param("lobbyCode", "ABCDE")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Could not find lobby.")); + mockMvc + .perform( + get("/lobby/ABCDE/leave").param("username", "TestUser").param("lobbyCode", "ABCDE")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Could not find lobby.")); } @Test void selectPositionShouldReturn200whenSuccess() throws Exception { when(service.selectPosition("TestUser", "ABCDE", Team.RED, Role.SPYMASTER)).thenReturn(true); - mockMvc.perform(post("/lobby/ABCDE/select-position") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + mockMvc + .perform( + post("/lobby/ABCDE/select-position") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ { "username": "TestUser", "team": "RED", @@ -125,19 +136,23 @@ void selectPositionShouldReturn200whenSuccess() throws Exception { "isHost": "true" } """)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("Position selected successfully.")) - .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Position selected successfully.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + + verifyNoInteractions(persistenceService); } @Test - void selectPositionShouldReturn400whenAssignmentFails() throws Exception { + void selectPositionShouldReturn400WhenAssignmentFails() throws Exception { when(service.selectPosition("TestUser", "ABCDE", Team.RED, Role.SPYMASTER)).thenReturn(false); - mockMvc.perform(post("/lobby/ABCDE/select-position") - .contentType(MediaType.APPLICATION_JSON) - .content( - """ + mockMvc + .perform( + post("/lobby/ABCDE/select-position") + .contentType(MediaType.APPLICATION_JSON) + .content( + """ { "username": "TestUser", "team": "RED", @@ -145,75 +160,60 @@ void selectPositionShouldReturn400whenAssignmentFails() throws Exception { "isHost": "true" } """)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Could not assign selected team/role.")) - .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Could not assign selected team/role.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")); } @Test - void getLobbyInfoShouldReturn200_whenLobbyExists() throws Exception { - List players = List.of( - new PlayerDto("Alice", null, null, true), - new PlayerDto("Bob", null, null, false) - ); + void getLobbyInfoShouldReturn200WhenLobbyExists() throws Exception { + List players = + List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); when(service.getPlayersDto("ABCDE")).thenReturn(players); - mockMvc.perform(get("/lobby/ABCDE")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message") - .value("Lobby info retrieved successfully.")) - .andExpect(jsonPath("$.lobbyCode") - .value("ABCDE")) - .andExpect(jsonPath("$.playerList[0].username") - .value("Alice")) - .andExpect(jsonPath("$.playerList[1].username") - .value("Bob")); + mockMvc + .perform(get("/lobby/ABCDE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Lobby info retrieved successfully.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")) + .andExpect(jsonPath("$.playerList[0].username").value("Alice")) + .andExpect(jsonPath("$.playerList[1].username").value("Bob")); } @Test - void testStartGameReturns200_WhenConditionIsMet() throws Exception { - List players = List.of( - new PlayerDto("Alice", null, null, true), - new PlayerDto("Bob", null, null, false) - ); + void testStartGameReturns200WhenConditionIsMet() throws Exception { + List players = + List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); when(service.getPlayersDto("ABCDE")).thenReturn(players); when(service.startGame("ABCDE", "Alice")).thenReturn(true); - mockMvc.perform(get("/lobby/ABCDE/start-game") - .param("username", "Alice")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message") - .value("Game is starting now.")) - .andExpect(jsonPath("$.lobbyCode") - .value("ABCDE")) - .andExpect(jsonPath("$.playerList[0].username") - .value("Alice")) - .andExpect(jsonPath("$.isStarted") - .value("true")); + mockMvc + .perform(get("/lobby/ABCDE/start-game").param("username", "Alice")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("Game is starting now.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")) + .andExpect(jsonPath("$.playerList[0].username").value("Alice")) + .andExpect(jsonPath("$.isStarted").value("true")); + + verify(persistenceService).persistCurrentState(); } @Test - void testStartGameReturns400_WhenServiceReturnsFalse() throws Exception{ - List players = List.of( - new PlayerDto("Alice", null, null, true), - new PlayerDto("Bob", null, null, false) - ); + void testStartGameReturns400WhenServiceReturnsFalse() throws Exception { + List players = + List.of(new PlayerDto("Alice", null, null, true), new PlayerDto("Bob", null, null, false)); when(service.getPlayersDto("ABCDE")).thenReturn(players); when(service.startGame("ABCDE", "Alice")).thenReturn(false); - mockMvc.perform(get("/lobby/ABCDE/start-game") - .param("username", "Alice")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message") - .value("Could not start the game.")) - .andExpect(jsonPath("$.lobbyCode") - .value("ABCDE")) - .andExpect(jsonPath("$.playerList[0].username") - .value("Alice")) - .andExpect(jsonPath("$.isStarted") - .value("false")); + mockMvc + .perform(get("/lobby/ABCDE/start-game").param("username", "Alice")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Could not start the game.")) + .andExpect(jsonPath("$.lobbyCode").value("ABCDE")) + .andExpect(jsonPath("$.playerList[0].username").value("Alice")) + .andExpect(jsonPath("$.isStarted").value("false")); } -} \ No newline at end of file +} diff --git a/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java b/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java index ede33cd1..3363a43e 100644 --- a/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/lobby/services/LobbyServiceTest.java @@ -5,9 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullAndEmptySource; -import org.junit.jupiter.params.provider.ValueSource; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; @@ -16,14 +13,19 @@ import static org.mockito.Mockito.when; import com.codenames.codenames.backend.chat.ChatService; +import com.codenames.codenames.backend.lobby.Lobby; import com.codenames.codenames.backend.lobby.dto.PlayerDto; import com.codenames.codenames.backend.playingfield.GameService; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; import com.codenames.codenames.backend.websocket.Player; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; /** * Tests for {@link LobbyService}. @@ -260,6 +262,7 @@ void testLobbyIsRemovedWhenItIsEmpty() { lobbyService.leaveLobby("Host", "ABCDE"); lobbyService.checkLobbyStillHasPlayers("ABCDE"); assertFalse(lobbyService.getLobbyList().containsKey("ABCDE")); + verify(gameService, times(1)).removeGame("ABCDE"); } @Test @@ -288,7 +291,7 @@ void testGetPlayersDto_lobbyNotExists() { } @Test - void getPlayersDtoShouldReturnPlayerDTOs_whenLobbyExists() { + void getPlayersDtoShouldReturnPlayerDtosWhenLobbyExists() { lobbyService.createLobby("Host"); List result = lobbyService.getPlayersDto("ABCDE"); @@ -328,7 +331,7 @@ void testGetIsStarted() { } @Test - void testGetIsStarted_GameServiceReturnsFalse() { + void testGetIsStartedGameServiceReturnsFalse() { when(gameService.isGameStarted("ABCDE")).thenReturn(false); boolean result = lobbyService.getIsStarted("ABCDE"); @@ -336,7 +339,7 @@ void testGetIsStarted_GameServiceReturnsFalse() { } @Test - void testGetHost_Works() { + void testGetHostWorks() { lobbyService.createLobby("Alice"); lobbyService.joinLobby("Bob", "ABCDE"); lobbyService.joinLobby("Caesar", "ABCDE"); @@ -350,9 +353,41 @@ void testGetHost_Works() { @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {"ABCDE"}) - void testGetHost_ReturnsEmptyString(String lobbyCode) { + void testGetHostReturnsEmptyString(String lobbyCode) { String result = lobbyService.getHost(lobbyCode); assertEquals("", result); } + + @Test + void testRestoreLobbyAddsLobbyToLobbyList() { + Lobby restoredLobby = new Lobby("ABCDE", "Host"); + + lobbyService.restoreLobby("ABCDE", restoredLobby); + + assertTrue(lobbyService.getLobbyList().containsKey("ABCDE")); + assertEquals(restoredLobby, lobbyService.getLobbyList().get("ABCDE")); + } + + @Test + void getLobbySnapshotsShouldReturnAllLobbyPlayerDtos() { + lobbyService.createLobby("Host"); + lobbyService.joinLobby("Player", "ABCDE"); + lobbyService.selectPosition("Host", "ABCDE", Team.RED, Role.SPYMASTER); + + Map> snapshots = lobbyService.getLobbySnapshots(); + + assertTrue(snapshots.containsKey("ABCDE")); + assertEquals(2, snapshots.get("ABCDE").size()); + + PlayerDto host = + snapshots.get("ABCDE").stream() + .filter(player -> player.username().equals("Host")) + .findFirst() + .orElseThrow(); + + assertEquals(Team.RED, host.team()); + assertEquals(Role.SPYMASTER, host.role()); + assertTrue(host.isHost()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java index bfde018c..1f6308c1 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/BoardTest.java @@ -11,6 +11,7 @@ import static org.mockito.Mockito.when; import com.codenames.codenames.backend.utility.Color; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -80,4 +81,13 @@ void testSetIsGuessed() { board.setGuessed(0); assertTrue(board.getIsGuessed(0)); } + + @Test + void testRecoveryConstructorCopiesInputList() { + List input = new ArrayList<>(dummyCardList); + Board recoveredBoard = new Board(input); + input.add(new Card("Extra", Color.RED)); + + assertEquals(4, recoveredBoard.getCardList().size()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java index 48574950..0fdce572 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerFactoryTest.java @@ -1,10 +1,19 @@ package com.codenames.codenames.backend.playingfield; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -26,4 +35,83 @@ void testCreate() { assertNotNull(gameManager); } + + @Test + void testCreateFromSnapshotWithClue() { + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + Team.RED, + Team.BLUE, + Role.OPERATIVE, + new ClueDto("ANIMAL", 2), + 2, + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.BLUE, false))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertNotNull(recovered); + assertEquals(Team.BLUE, recovered.getCurrentTurn()); + assertEquals(Role.OPERATIVE, recovered.getCurrentPhase()); + assertEquals("ANIMAL", recovered.getCurrentClueWord()); + assertEquals(2, recovered.getRemainingGuesses()); + assertEquals(1, recovered.getCurrentRedFound()); + assertEquals(0, recovered.getCurrentBlueFound()); + assertEquals(Team.RED, recovered.getWinner()); + assertTrue(recovered.getCardList().get(0).isGuessed()); + } + + @Test + void testCreateFromSnapshotWithoutClue() { + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + null, + Team.BLUE, + Role.SPYMASTER, + null, + 0, + List.of(new CardDataTransferObject("Tree", Color.BLUE, false))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertNotNull(recovered); + assertEquals(Team.BLUE, recovered.getCurrentTurn()); + assertEquals(Role.SPYMASTER, recovered.getCurrentPhase()); + assertNull(recovered.getCurrentClue()); + } + + @Test + void testCreateFromSnapshotMapsCardsAndCountsGuessedCardsByColor() { + GameStateDataTransferObject snapshot = + new GameStateDataTransferObject( + null, + Team.RED, + Role.OPERATIVE, + null, + 1, + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.RED, false), + new CardDataTransferObject("Tree", Color.BLUE, true), + new CardDataTransferObject("Sun", Color.NEUTRAL, true))); + + GameManager recovered = gameManagerFactory.createFromSnapshot(snapshot); + + assertEquals(4, recovered.getCardList().size()); + + assertEquals("Dog", recovered.getCardList().get(0).getWord()); + assertEquals(Color.RED, recovered.getCardList().get(0).getColor()); + assertTrue(recovered.getCardList().get(0).isGuessed()); + + assertEquals("Cat", recovered.getCardList().get(1).getWord()); + assertEquals(Color.RED, recovered.getCardList().get(1).getColor()); + + assertEquals("Tree", recovered.getCardList().get(2).getWord()); + assertEquals(Color.BLUE, recovered.getCardList().get(2).getColor()); + assertTrue(recovered.getCardList().get(2).isGuessed()); + + assertEquals(1, recovered.getCurrentRedFound()); + assertEquals(1, recovered.getCurrentBlueFound()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java index 5397a1d7..40e934c7 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameManagerTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; @@ -13,6 +14,9 @@ import com.codenames.codenames.backend.clue.Clue; import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import com.codenames.codenames.backend.utility.Color; import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; @@ -328,4 +332,69 @@ void testCheckCorrectTurn_throwsWhenWrongTeam() { void testPassTurn_throwsWhenSpymaster() { assertThrows(IllegalStateException.class, () -> gameManager.passTurn(redTeam)); } + + @Test + void recoveryConstructorThrowsWhenCardsAreNull() { + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, Role.SPYMASTER, null, 0, null); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCardsAreEmpty() { + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, Role.SPYMASTER, null, 0, List.of()); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCurrentTurnIsNull() { + List cards = + List.of(new CardDataTransferObject("Dog", Color.RED, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, null, Role.SPYMASTER, null, 0, cards); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorThrowsWhenCurrentPhaseIsNull() { + List cards = + List.of(new CardDataTransferObject("Dog", Color.RED, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject(null, Team.RED, null, null, 0, cards); + + assertThrows( + IllegalArgumentException.class, () -> new GameManager(state, mockClueValidationService)); + } + + @Test + void recoveryConstructorRestoresPersistedState() { + List cards = + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.BLUE, false)); + + GameStateDataTransferObject state = + new GameStateDataTransferObject( + Team.RED, Team.BLUE, Role.OPERATIVE, new ClueDto("ANIMAL", 2), 2, cards); + + GameManager restored = new GameManager(state, mockClueValidationService); + + assertEquals(Team.BLUE, restored.getCurrentTurn()); + assertEquals(Role.OPERATIVE, restored.getCurrentPhase()); + assertEquals(2, restored.getRemainingGuesses()); + assertEquals("ANIMAL", restored.getCurrentClueWord()); + assertEquals(Team.RED, restored.getWinner()); + assertEquals(1, restored.getCurrentRedFound()); + assertEquals(0, restored.getCurrentBlueFound()); + assertTrue(restored.getCardList().get(0).isGuessed()); + } } diff --git a/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java index 9bcbaa80..d1e17882 100644 --- a/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/playingfield/GameServiceTest.java @@ -1,18 +1,24 @@ package com.codenames.codenames.backend.playingfield; -import com.codenames.codenames.backend.serialization.DataTransferObjectService; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.codenames.codenames.backend.clue.Clue; -import com.codenames.codenames.backend.game.dto.GameStateDto; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.DataTransferObjectService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; import com.codenames.codenames.backend.utility.Team; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -21,6 +27,7 @@ class GameServiceTest { private GameService gameService; private GameManager mockGameManager; private GameManagerFactory mockGameManagerFactory; + private DataTransferObjectService mockDtoService; private final String lobbyCode = "ABCDE"; private final Team redTeam = Team.RED; @@ -29,7 +36,7 @@ class GameServiceTest { void setup() { mockGameManagerFactory = mock(GameManagerFactory.class); mockGameManager = mock(GameManager.class); - DataTransferObjectService mockDtoService = mock(DataTransferObjectService.class); + mockDtoService = mock(DataTransferObjectService.class); gameService = new GameService(mockGameManagerFactory, mockDtoService); when(mockGameManagerFactory.create(redTeam)).thenReturn(mockGameManager); @@ -58,7 +65,7 @@ void testRemoveGame() { @Test void testSubmitClue() { - Clue mockClue = mock(Clue.class); + Clue mockClue = new Clue("ANIMAL", 2); gameService.submitClue(lobbyCode, mockClue, redTeam); verify(mockGameManager, times(1)).submitClue(mockClue, redTeam); @@ -88,12 +95,65 @@ void testGetGameState() { } @Test - void testCreateGameStateDto() { + void testRestoreGameManager() { + String restoredLobbyCode = "VWXYZ"; + GameManager restoredGameManager = mock(GameManager.class); - when(mockGameManager.getCardList()).thenReturn(List.of()); + gameService.restoreGameManager(restoredLobbyCode, restoredGameManager); - GameStateDto dto = gameService.createGameStateDto(lobbyCode); + assertEquals(restoredGameManager, gameService.getGameState(restoredLobbyCode)); + } - assertNotNull(dto); + @Test + void testGetCurrentGameState() { + GameStateDataTransferObject expected = + new GameStateDataTransferObject( + null, + redTeam, + Role.SPYMASTER, + new ClueDto("ANIMAL", 2), + 2, + List.of(new CardDataTransferObject("Dog", Color.RED, false))); + when(mockGameManager.getCurrentTurn()).thenReturn(redTeam); + when(mockGameManager.getCurrentPhase()).thenReturn(Role.SPYMASTER); + when(mockGameManager.getRemainingGuesses()).thenReturn(2); + when(mockDtoService.createGameStateDataTransferObject(mockGameManager, redTeam, Role.SPYMASTER)) + .thenReturn(expected); + + GameStateDataTransferObject result = gameService.getCurrentGameState(lobbyCode); + + assertEquals(expected, result); + } + + @Test + void testIsGameStartedWhenGameExists() { + assertTrue(gameService.isGameStarted(lobbyCode)); + } + + @Test + void testIsGameStartedWhenGameDoesNotExist() { + assertFalse(gameService.isGameStarted("UNKNOWN")); + } + + @Test + void getGameSnapshotsShouldReturnAllCurrentGameStates() { + GameStateDataTransferObject expected = + new GameStateDataTransferObject( + null, + redTeam, + Role.SPYMASTER, + new ClueDto("ANIMAL", 2), + 2, + List.of(new CardDataTransferObject("Dog", Color.RED, false))); + + when(mockGameManager.getCurrentTurn()).thenReturn(redTeam); + when(mockGameManager.getCurrentPhase()).thenReturn(Role.SPYMASTER); + when(mockDtoService.createGameStateDataTransferObject(mockGameManager, redTeam, Role.SPYMASTER)) + .thenReturn(expected); + + Map snapshots = gameService.getGameSnapshots(); + + assertEquals(1, snapshots.size()); + assertEquals(expected, snapshots.get(lobbyCode)); } } diff --git a/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java new file mode 100644 index 00000000..86abe926 --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/JsonStateStoreTest.java @@ -0,0 +1,174 @@ +package com.codenames.codenames.backend.recovery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class JsonStateStoreTest { + + @TempDir Path tempDir; + + @Test + void loadReturnsEmptyWhenStateFileDoesNotExist() { + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isEmpty()); + } + + @Test + void loadReturnsEmptyWhenStateFileIsEmpty() throws IOException { + Path stateFile = tempDir.resolve("state.json"); + Files.createFile(stateFile); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isEmpty()); + } + + @Test + void saveAndLoadRoundTrip() { + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + List lobbyPlayers = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); + + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, Team.RED, Role.OPERATIVE, new ClueDto("ANIMAL", 2), 2, List.of()); + + SystemSnapshot expectedSnapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + Map.of("ABCDE", lobbyPlayers), + Map.of("ABCDE", gameSnapshot)); + + stateStore.save(expectedSnapshot); + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isPresent()); + assertTrue(Files.exists(stateFile)); + + SystemSnapshot actualSnapshot = loadedSnapshot.get(); + assertEquals(SystemSnapshot.CURRENT_SCHEMA_VERSION, actualSnapshot.schemaVersion()); + assertEquals(1, actualSnapshot.lobbies().size()); + assertEquals(1, actualSnapshot.games().size()); + + List actualPlayers = actualSnapshot.lobbies().get("ABCDE"); + assertEquals(2, actualPlayers.size()); + assertEquals("Host", actualPlayers.get(0).username()); + assertEquals(Team.RED, actualPlayers.get(0).team()); + assertEquals(Role.SPYMASTER, actualPlayers.get(0).role()); + assertTrue(actualPlayers.get(0).isHost()); + + GameStateDataTransferObject actualGameSnapshot = actualSnapshot.games().get("ABCDE"); + assertEquals(Team.RED, actualGameSnapshot.currentTurn()); + assertEquals(Role.OPERATIVE, actualGameSnapshot.currentPhase()); + assertEquals(2, actualGameSnapshot.remainingGuesses()); + assertEquals("ANIMAL", actualGameSnapshot.currentClue().word()); + assertEquals(2, actualGameSnapshot.currentClue().guessAmount()); + assertTrue(actualGameSnapshot.cardList().isEmpty()); + } + + @Test + void saveOverwritesPreviousSnapshot() { + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + SystemSnapshot firstSnapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + SystemSnapshot secondSnapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + Map.of("ABCDE", List.of()), + Map.of()); + + stateStore.save(firstSnapshot); + stateStore.save(secondSnapshot); + + Optional loadedSnapshot = loadSnapshot(stateStore); + + assertTrue(loadedSnapshot.isPresent()); + assertTrue(loadedSnapshot.get().lobbies().containsKey("ABCDE")); + assertEquals(1, loadedSnapshot.get().lobbies().size()); + } + + @Test + void saveThrowsIllegalStateWhenStateParentCannotBeCreated() throws IOException { + Path fileAsParent = tempDir.resolve("not-a-directory"); + Files.writeString(fileAsParent, "occupied"); + Path stateFile = fileAsParent.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + + assertThrows(IllegalStateException.class, () -> stateStore.save(snapshot)); + } + + @Test + void loadThrowsIllegalStateWhenJsonIsInvalid() throws IOException { + Path stateFile = tempDir.resolve("state.json"); + Files.writeString(stateFile, "{invalid-json"); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + + assertThrows(IllegalStateException.class, stateStore::load); + } + + @Test + void gettersExposeConfiguredDependencies() { + ObjectMapper mapper = new ObjectMapper(); + Path stateFile = tempDir.resolve("state.json"); + JsonStateStore stateStore = new JsonStateStore(mapper, stateFile.toString()); + + assertSame(mapper, stateStore.getObjectMapper()); + assertEquals(stateFile, stateStore.getStateFilePath()); + assertNotNull(stateStore.getIoLock()); + } + + @Test + void saveWorksWhenStateFileHasNoParentDirectory() throws IOException { + String fileName = "json-state-store-" + System.nanoTime() + ".json"; + Path stateFile = Path.of(fileName); + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), fileName); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + + try { + stateStore.save(snapshot); + + assertTrue(Files.exists(stateFile)); + assertTrue(stateStore.load().isPresent()); + } finally { + Files.deleteIfExists(stateFile); + Files.deleteIfExists(Path.of(fileName + ".tmp")); + } + } + + private Optional loadSnapshot(JsonStateStore stateStore) { + return stateStore.load(); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java new file mode 100644 index 00000000..c0d956dc --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStatePersistenceServiceTest.java @@ -0,0 +1,49 @@ +package com.codenames.codenames.backend.recovery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class SystemStatePersistenceServiceTest { + + @Test + void persistCurrentStateSavesCurrentSystemSnapshot() { + JsonStateStore stateStore = mock(JsonStateStore.class); + LobbyService lobbyService = mock(LobbyService.class); + GameService gameService = mock(GameService.class); + + Map> lobbySnapshots = Map.of("ABCDE", List.of()); + + Map gameSnapshots = Map.of(); + + when(lobbyService.getLobbySnapshots()).thenReturn(lobbySnapshots); + when(gameService.getGameSnapshots()).thenReturn(gameSnapshots); + + SystemStatePersistenceService persistenceService = + new SystemStatePersistenceService(stateStore, lobbyService, gameService); + + persistenceService.persistCurrentState(); + + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(SystemSnapshot.class); + + verify(stateStore, times(1)).save(snapshotCaptor.capture()); + + SystemSnapshot snapshot = snapshotCaptor.getValue(); + + assertEquals(SystemSnapshot.CURRENT_SCHEMA_VERSION, snapshot.schemaVersion()); + assertEquals(lobbySnapshots, snapshot.lobbies()); + assertEquals(gameSnapshots, snapshot.games()); + } +} diff --git a/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java new file mode 100644 index 00000000..30a757ba --- /dev/null +++ b/src/test/java/com/codenames/codenames/backend/recovery/SystemStateRecoveryServiceTest.java @@ -0,0 +1,265 @@ +package com.codenames.codenames.backend.recovery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codenames.codenames.backend.chat.ChatService; +import com.codenames.codenames.backend.clue.ClueValidationService; +import com.codenames.codenames.backend.game.dto.ClueDto; +import com.codenames.codenames.backend.lobby.Lobby; +import com.codenames.codenames.backend.lobby.dto.PlayerDto; +import com.codenames.codenames.backend.lobby.services.LobbyCodeGenerator; +import com.codenames.codenames.backend.lobby.services.LobbyService; +import com.codenames.codenames.backend.playingfield.CardGenerator; +import com.codenames.codenames.backend.playingfield.GameManager; +import com.codenames.codenames.backend.playingfield.GameManagerFactory; +import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.recovery.snapshot.SystemSnapshot; +import com.codenames.codenames.backend.serialization.CardDataTransferObject; +import com.codenames.codenames.backend.serialization.DataTransferObjectService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; +import com.codenames.codenames.backend.utility.Color; +import com.codenames.codenames.backend.utility.Role; +import com.codenames.codenames.backend.utility.Team; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SystemStateRecoveryServiceTest { + + @TempDir Path tempDir; + + @Test + void recoverOnStartupDoesNothingWhenNoSnapshotExists() { + TestContext context = createContext(tempDir.resolve("state.json")); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupSkipsSnapshotWhenSchemaVersionDiffers() { + TestContext context = createContext(tempDir.resolve("state.json")); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION + 1, Map.of(), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupRestoresLobbiesAndGamesFromSnapshot() { + TestContext context = createContext(tempDir.resolve("state.json")); + List lobbyPlayers = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", Team.BLUE, Role.OPERATIVE, false)); + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, + Team.RED, + Role.OPERATIVE, + new ClueDto("ANIMAL", 2), + 2, + List.of( + new CardDataTransferObject("Dog", Color.RED, true), + new CardDataTransferObject("Cat", Color.BLUE, false))); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, + Map.of("ABCDE", lobbyPlayers), + Map.of("ABCDE", gameSnapshot)); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + Lobby restoredLobby = context.lobbyService().getLobbyList().get("ABCDE"); + assertEquals(2, restoredLobby.getPlayerList().size()); + assertEquals(Team.RED, restoredLobby.getPlayerTeam("Host")); + assertEquals(Role.SPYMASTER, restoredLobby.getPlayerRole("Host")); + assertEquals(Team.BLUE, restoredLobby.getPlayerTeam("Player")); + assertEquals(Role.OPERATIVE, restoredLobby.getPlayerRole("Player")); + + assertTrue(context.gameService().isGameStarted("ABCDE")); + GameManager restoredGame = context.gameService().getGameState("ABCDE"); + assertEquals(Team.RED, restoredGame.getCurrentTurn()); + assertEquals(Role.OPERATIVE, restoredGame.getCurrentPhase()); + assertEquals(2, restoredGame.getRemainingGuesses()); + assertEquals("ANIMAL", restoredGame.getCurrentClue().word()); + assertTrue(restoredGame.getCardList().get(0).isGuessed()); + assertFalse(restoredGame.getCardList().get(1).isGuessed()); + } + + @Test + void recoverOnStartupWithCurrentSchemaAndEmptyMapsDoesNothing() { + TestContext context = createContext(tempDir.resolve("state-empty.json")); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of(), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupHandlesNullLobbyAndGameMaps() { + TestContext context = createContext(tempDir.resolve("state-null-maps.json")); + SystemSnapshot snapshot = new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, null, null); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupSkipsLobbyWhenSnapshotEntryIsNull() { + TestContext context = createContext(tempDir.resolve("state-null-lobby-entry.json")); + Map> lobbies = new HashMap<>(); + lobbies.put("ABCDE", null); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, lobbies, Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupSkipsLobbyWhenPlayersListIsNull() { + TestContext context = createContext(tempDir.resolve("state-null-players.json")); + Map> lobbies = new HashMap<>(); + lobbies.put("ABCDE", null); + SystemSnapshot snapshot = + new SystemSnapshot(SystemSnapshot.CURRENT_SCHEMA_VERSION, lobbies, Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupSkipsLobbyWhenPlayersListIsEmpty() { + TestContext context = createContext(tempDir.resolve("state-empty-players.json")); + List lobbySnapshot = List.of(); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupSkipsLobbyWhenAllUsernamesAreInvalid() { + TestContext context = createContext(tempDir.resolve("state-invalid-usernames.json")); + List lobbySnapshot = + List.of( + new PlayerDto(" ", Team.RED, Role.SPYMASTER, true), + new PlayerDto(null, Team.BLUE, Role.OPERATIVE, false)); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + } + + @Test + void recoverOnStartupRestoresLobbyWhenSomeTeamOrRoleValuesAreMissing() { + TestContext context = createContext(tempDir.resolve("state-missing-team-role.json")); + List lobbySnapshot = + List.of( + new PlayerDto("Host", Team.RED, Role.SPYMASTER, true), + new PlayerDto("Player", null, null, false)); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), Map.of()); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + Lobby restoredLobby = context.lobbyService().getLobbyList().get("ABCDE"); + assertEquals(Team.RED, restoredLobby.getPlayerTeam("Host")); + assertEquals(Role.SPYMASTER, restoredLobby.getPlayerRole("Host")); + assertNull(restoredLobby.getPlayerTeam("Player")); + assertNull(restoredLobby.getPlayerRole("Player")); + } + + @Test + void recoverOnStartupRestoresOnlyGamesWhenLobbiesMapIsNull() { + TestContext context = createContext(tempDir.resolve("state-lobbies-null-games-present.json")); + GameStateDataTransferObject gameSnapshot = + new GameStateDataTransferObject( + null, + Team.BLUE, + Role.SPYMASTER, + null, + 0, + List.of(new CardDataTransferObject("Tree", Color.BLUE, false))); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, null, Map.of("ABCDE", gameSnapshot)); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().isEmpty()); + assertTrue(context.gameService().isGameStarted("ABCDE")); + } + + @Test + void recoverOnStartupRestoresOnlyLobbiesWhenGamesMapIsNull() { + TestContext context = createContext(tempDir.resolve("state-games-null-lobbies-present.json")); + List lobbySnapshot = List.of(new PlayerDto("Host", Team.RED, Role.SPYMASTER, true)); + SystemSnapshot snapshot = + new SystemSnapshot( + SystemSnapshot.CURRENT_SCHEMA_VERSION, Map.of("ABCDE", lobbySnapshot), null); + context.stateStore().save(snapshot); + + context.recoveryService().recoverOnStartup(); + + assertTrue(context.lobbyService().getLobbyList().containsKey("ABCDE")); + assertFalse(context.gameService().isGameStarted("ABCDE")); + } + + private TestContext createContext(Path stateFile) { + JsonStateStore stateStore = new JsonStateStore(new ObjectMapper(), stateFile.toString()); + GameManagerFactory gameManagerFactory = + new GameManagerFactory( + new CardGenerator("CodenamesWordlist.txt"), new ClueValidationService()); + GameService gameService = new GameService(gameManagerFactory, new DataTransferObjectService()); + LobbyService lobbyService = + new LobbyService(new LobbyCodeGenerator(), new ChatService(null), gameService); + SystemStateRecoveryService recoveryService = + new SystemStateRecoveryService(stateStore, lobbyService, gameService, gameManagerFactory); + + return new TestContext(stateStore, lobbyService, gameService, recoveryService); + } + + private record TestContext( + JsonStateStore stateStore, + LobbyService lobbyService, + GameService gameService, + SystemStateRecoveryService recoveryService) {} +} diff --git a/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java b/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java index 5b848712..0097b0c0 100644 --- a/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java +++ b/src/test/java/com/codenames/codenames/backend/serialization/DataTransferObjectServiceTest.java @@ -37,6 +37,7 @@ void setUp() { when(mockGameManager.getWinner()).thenReturn(Team.RED); when(mockGameManager.getCurrentRedFound()).thenReturn(0); when(mockGameManager.getCurrentBlueFound()).thenReturn(0); + when(mockGameManager.getRemainingGuesses()).thenReturn(2); gameStateDto = service.createGameStateDataTransferObject(mockGameManager, redTeam, spymaster); @@ -78,8 +79,10 @@ void testCreateGameStateDataTransferObject() { when(mockGameManager.getCardList()).thenReturn(List.of()); when(mockGameManager.getWinner()).thenReturn(null); when(mockGameManager.getCurrentClue()).thenReturn(clue); + when(mockGameManager.getRemainingGuesses()).thenReturn(3); - GameStateDataTransferObject dto = service.createGameStateDataTransferObject(mockGameManager, redTeam, operative); + GameStateDataTransferObject dto = + service.createGameStateDataTransferObject(mockGameManager, redTeam, operative); assertEquals(clue.word(), dto.currentClue().word()); assertEquals(clue.guessAmount(), dto.currentClue().guessAmount()); @@ -87,5 +90,6 @@ void testCreateGameStateDataTransferObject() { assertEquals(0, dto.cardList().size()); assertEquals(redTeam, dto.currentTurn()); assertEquals(operative, dto.currentPhase()); + assertEquals(3, dto.remainingGuesses()); } } diff --git a/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java b/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java index 5f6837dd..403ee7f2 100644 --- a/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java +++ b/src/test/java/com/codenames/codenames/backend/serialization/SerializationJsonTest.java @@ -34,15 +34,15 @@ void setUp() { dummyList = List.of(new CardDataTransferObject("TEST", null, false)); dummyGameState = - new GameStateDataTransferObject(redTeam, redTeam, spymaster, new ClueDto("Test", 1), dummyList); + new GameStateDataTransferObject( + redTeam, redTeam, spymaster, new ClueDto("Test", 1), 1, dummyList); } @Test void testSerialize_pass() { String expectedResult = - """ - {"winner":"RED","currentTurn":"RED","currentPhase":"SPYMASTER","currentClue":{"word":"Test","guessAmount":1},"cardList":[{"word":"TEST","color":null,"isGuessed":false}]}""" - ; + """ + {"winner":"RED","currentTurn":"RED","currentPhase":"SPYMASTER","currentClue":{"word":"Test","guessAmount":1},"remainingGuesses":1,"cardList":[{"word":"TEST","color":null,"isGuessed":false}]}"""; String result = serializer.serialize(dummyGameState); assertEquals(expectedResult, result); } diff --git a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java index 1d52bc07..e630b4f1 100644 --- a/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java +++ b/src/test/java/com/codenames/codenames/backend/websocket/GameControllerTest.java @@ -11,6 +11,7 @@ import com.codenames.codenames.backend.lobby.services.LobbyService; import com.codenames.codenames.backend.playingfield.GameService; +import com.codenames.codenames.backend.serialization.GameStateDataTransferObject; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -57,8 +58,7 @@ void shouldRegisterJoinAndRegisterSession() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")) - .thenReturn(mock(com.codenames.codenames.backend.game.dto.GameStateDto.class)); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -90,7 +90,6 @@ void shouldSendErrorMessageWhenJoinFails() { controller.join(msg, accessor); verify(messagingTemplate).convertAndSend("/topic/errors/123", "Join failed"); - verifyNoMoreInteractions(messagingTemplate); } @@ -124,8 +123,7 @@ void shouldUseSessionAttributesFallbackWhenSessionIdIsNull() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(true); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")) - .thenReturn(mock(com.codenames.codenames.backend.game.dto.GameStateDto.class)); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -152,8 +150,7 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { when(lobbyService.joinLobby("Max", "ABCDE")).thenReturn(false); when(lobbyService.getPlayers("ABCDE")).thenReturn(List.of(new Player("Max", true))); - when(gameService.createGameStateDto("ABCDE")) - .thenReturn(mock(com.codenames.codenames.backend.game.dto.GameStateDto.class)); + when(gameService.getCurrentGameState("ABCDE")).thenReturn(createGameStatePayload()); controller.join(msg, accessor); @@ -163,4 +160,8 @@ void shouldTreatExistingPlayerAsReconnectWhenJoinReturnsFalse() { verify(messagingTemplate).convertAndSend(eq("/topic/lobby/ABCDE"), any(Object.class)); verify(messagingTemplate).convertAndSend(eq("/topic/game/ABCDE"), any(Object.class)); } + + private GameStateDataTransferObject createGameStatePayload() { + return new GameStateDataTransferObject(null, null, null, null, 0, List.of()); + } } diff --git a/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..fdbd0b15 --- /dev/null +++ b/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass