diff --git a/src/main/java/com/patina/codebloom/api/duel/DuelController.java b/src/main/java/com/patina/codebloom/api/duel/DuelController.java index eafc9f29c..9b390f89e 100644 --- a/src/main/java/com/patina/codebloom/api/duel/DuelController.java +++ b/src/main/java/com/patina/codebloom/api/duel/DuelController.java @@ -1,5 +1,7 @@ package com.patina.codebloom.api.duel; +import java.time.OffsetDateTime; + import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; @@ -8,9 +10,19 @@ import org.springframework.web.server.ResponseStatusException; import com.patina.codebloom.common.components.DuelManager; +import com.patina.codebloom.common.db.models.lobby.Lobby; +import com.patina.codebloom.common.db.models.lobby.LobbyStatus; +import com.patina.codebloom.common.db.models.lobby.player.LobbyPlayer; +import com.patina.codebloom.common.db.models.user.User; +import com.patina.codebloom.common.db.repos.lobby.LobbyRepository; +import com.patina.codebloom.common.db.repos.lobby.player.LobbyPlayerRepository; import com.patina.codebloom.common.dto.ApiResponder; import com.patina.codebloom.common.dto.Empty; import com.patina.codebloom.common.env.Env; +import com.patina.codebloom.common.security.AuthenticationObject; +import com.patina.codebloom.common.security.annotation.Protected; +import com.patina.codebloom.common.time.StandardizedOffsetDateTime; +import com.patina.codebloom.common.utils.duel.PartyCodeGenerator; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -24,10 +36,15 @@ public class DuelController { private final Env env; private final DuelManager duelManager; + private final LobbyRepository lobbyRepository; + private final LobbyPlayerRepository lobbyPlayerRepository; - public DuelController(final Env env, final DuelManager duelManager) { + public DuelController(final Env env, final DuelManager duelManager, final LobbyRepository lobbyRepository, + final LobbyPlayerRepository lobbyPlayerRepository) { this.env = env; this.duelManager = duelManager; + this.lobbyRepository = lobbyRepository; + this.lobbyPlayerRepository = lobbyPlayerRepository; } @Operation(summary = "Join party", description = "WIP") @@ -52,14 +69,46 @@ public ResponseEntity> leaveParty() { return ResponseEntity.ok(ApiResponder.success("ok", Empty.of())); } - @Operation(summary = "Create party", description = "WIP") - @ApiResponse(responseCode = "403", description = "Endpoint is currently non-functional") + @Operation(summary = "Create party", description = "Create a new lobby and become the host") + @ApiResponse(responseCode = "200", description = "Lobby created successfully") + @ApiResponse(responseCode = "400", description = "Player is already in a lobby") + @ApiResponse(responseCode = "401", description = "User not authenticated") @PostMapping("/party/create") - public ResponseEntity> createParty() { + public ResponseEntity> createParty(@Protected final AuthenticationObject authenticationObject) { if (env.isProd()) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Endpoint is currently non-functional"); } - return ResponseEntity.ok(ApiResponder.success("ok", Empty.of())); + User user = authenticationObject.getUser(); + String playerId = user.getId(); + + LobbyPlayer existingLobbyPlayer = lobbyPlayerRepository.findLobbyPlayerByPlayerId(playerId); + if (existingLobbyPlayer != null) { + return ResponseEntity.badRequest() + .body(ApiResponder.failure("You are already in a lobby. Please leave your current lobby before creating a new one.")); + } + + String joinCode = PartyCodeGenerator.generateCode(); + OffsetDateTime expiresAt = StandardizedOffsetDateTime.now().plusMinutes(30); + + Lobby lobby = Lobby.builder() + .joinCode(joinCode) + .status(LobbyStatus.AVAILABLE) + .expiresAt(expiresAt) + .playerCount(1) + .winnerId(null) + .build(); + + lobbyRepository.createLobby(lobby); + + LobbyPlayer lobbyPlayer = LobbyPlayer.builder() + .lobbyId(lobby.getId()) + .playerId(playerId) + .points(0) + .build(); + + lobbyPlayerRepository.createLobbyPlayer(lobbyPlayer); + + return ResponseEntity.ok(ApiResponder.success("Lobby created successfully! Share the join code: " + lobby.getJoinCode(), Empty.of())); } } diff --git a/src/main/java/com/patina/codebloom/api/duel/dto/LobbyDto.java b/src/main/java/com/patina/codebloom/api/duel/dto/LobbyDto.java new file mode 100644 index 000000000..468150399 --- /dev/null +++ b/src/main/java/com/patina/codebloom/api/duel/dto/LobbyDto.java @@ -0,0 +1,47 @@ +package com.patina.codebloom.api.duel.dto; + +import java.time.OffsetDateTime; + +import com.patina.codebloom.common.db.models.lobby.Lobby; +import com.patina.codebloom.common.db.models.lobby.LobbyStatus; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import lombok.extern.jackson.Jacksonized; + +@Getter +@Builder +@Jacksonized +@ToString +@EqualsAndHashCode +public class LobbyDto { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String joinCode; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LobbyStatus status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private OffsetDateTime expiresAt; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private OffsetDateTime createdAt; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int playerCount; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, nullable = true) + private String winnerId; + + public static LobbyDto fromLobby(final Lobby lobby) { + return LobbyDto.builder() + .id(lobby.getId()) + .joinCode(lobby.getJoinCode()) + .status(lobby.getStatus()) + .expiresAt(lobby.getExpiresAt()) + .createdAt(lobby.getCreatedAt()) + .playerCount(lobby.getPlayerCount()) + .winnerId(lobby.getWinnerId()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/patina/codebloom/common/utils/duel/PartyCodeGenerator.java b/src/main/java/com/patina/codebloom/common/utils/duel/PartyCodeGenerator.java new file mode 100644 index 000000000..884dc163c --- /dev/null +++ b/src/main/java/com/patina/codebloom/common/utils/duel/PartyCodeGenerator.java @@ -0,0 +1,22 @@ +package com.patina.codebloom.common.utils.duel; + +import java.security.SecureRandom; + +public class PartyCodeGenerator { + private static final String CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int CODE_LENGTH = 6; + private static final SecureRandom RANDOM = new SecureRandom(); + + /** + * Generates a random party code + * + * @return A random party code (e.g., "ABC123") + */ + public static String generateCode() { + StringBuilder codeBuilder = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + codeBuilder.append(CHARACTERS.charAt(RANDOM.nextInt(CHARACTERS.length()))); + } + return codeBuilder.toString(); + } +} \ No newline at end of file diff --git a/src/test/java/com/patina/codebloom/component/DuelManagerTest.java b/src/test/java/com/patina/codebloom/component/DuelManagerTest.java new file mode 100644 index 000000000..cd429a515 --- /dev/null +++ b/src/test/java/com/patina/codebloom/component/DuelManagerTest.java @@ -0,0 +1,297 @@ +package com.patina.codebloom.component; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.ResponseEntity; + +import com.github.javafaker.Faker; +import com.patina.codebloom.api.duel.DuelController; +import com.patina.codebloom.common.db.models.lobby.Lobby; +import com.patina.codebloom.common.db.models.lobby.LobbyStatus; +import com.patina.codebloom.common.db.models.lobby.player.LobbyPlayer; +import com.patina.codebloom.common.db.models.user.User; +import com.patina.codebloom.common.db.repos.lobby.LobbyRepository; +import com.patina.codebloom.common.db.repos.lobby.player.LobbyPlayerRepository; +import com.patina.codebloom.common.dto.ApiResponder; +import com.patina.codebloom.common.dto.Empty; +import com.patina.codebloom.common.env.Env; +import com.patina.codebloom.common.security.AuthenticationObject; + +public class DuelManagerTest { + private final DuelController duelController; + private final Faker faker; + + private LobbyRepository lobbyRepository = mock(LobbyRepository.class); + private LobbyPlayerRepository lobbyPlayerRepository = mock(LobbyPlayerRepository.class); + private Env env = mock(Env.class); + + public DuelManagerTest() { + this.duelController = new DuelController(env, null, lobbyRepository, lobbyPlayerRepository); + this.faker = Faker.instance(); + } + + private String randomUUID() { + return UUID.randomUUID().toString(); + } + + private User createRandomUser() { + return User.builder() + .id(randomUUID()) + .discordId(String.valueOf(faker.number().randomNumber(18, true))) + .discordName(faker.name().username()) + .leetcodeUsername(faker.name().username()) + .admin(false) + .verifyKey(faker.crypto().md5()) + .build(); + } + + private AuthenticationObject createAuthenticationObject(final User user) { + return new AuthenticationObject(user, null); + } + + @Test + void testCreatePartySuccessWhenUserNotInParty() { + when(env.isProd()).thenReturn(false); + + User user = createRandomUser(); + AuthenticationObject authObj = createAuthenticationObject(user); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user.getId())) + .thenReturn(null); + + ResponseEntity> response = duelController.createParty(authObj); + + assertEquals(200, response.getStatusCode().value()); + assertTrue(response.getBody().isSuccess()); + assertTrue(response.getBody().getPayload() instanceof Empty); + + ArgumentCaptor lobbyCaptor = ArgumentCaptor.forClass(Lobby.class); + ArgumentCaptor playerCaptor = ArgumentCaptor.forClass(LobbyPlayer.class); + + verify(lobbyRepository, times(1)).createLobby(lobbyCaptor.capture()); + verify(lobbyPlayerRepository, times(1)).createLobbyPlayer(playerCaptor.capture()); + + Lobby createdLobby = lobbyCaptor.getValue(); + assertNotNull(createdLobby.getJoinCode()); + assertEquals(6, createdLobby.getJoinCode().length()); + assertEquals(LobbyStatus.AVAILABLE, createdLobby.getStatus()); + assertEquals(1, createdLobby.getPlayerCount()); + assertNull(createdLobby.getWinnerId()); + assertNotNull(createdLobby.getExpiresAt()); + + LobbyPlayer createdPlayer = playerCaptor.getValue(); + assertEquals(user.getId(), createdPlayer.getPlayerId()); + assertEquals(0, createdPlayer.getPoints()); + } + + @Test + void testCreatePartyFailureWhenUserAlreadyInParty() { + when(env.isProd()).thenReturn(false); + + User user = createRandomUser(); + AuthenticationObject authObj = createAuthenticationObject(user); + + LobbyPlayer existingLobbyPlayer = LobbyPlayer.builder() + .id(randomUUID()) + .lobbyId(randomUUID()) + .playerId(user.getId()) + .points(100) + .build(); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user.getId())) + .thenReturn(existingLobbyPlayer); + + ResponseEntity> response = duelController.createParty(authObj); + + assertEquals(400, response.getStatusCode().value()); + assertFalse(response.getBody().isSuccess()); + assertEquals("You are already in a lobby. Please leave your current lobby before creating a new one.", + response.getBody().getMessage()); + + verify(lobbyRepository, times(0)).createLobby(any()); + verify(lobbyPlayerRepository, times(0)).createLobbyPlayer(any()); + } + + @Test + void testMultipleUsersCanCreatePartiesIndependently() { + when(env.isProd()).thenReturn(false); + + User user1 = createRandomUser(); + User user2 = createRandomUser(); + AuthenticationObject authObj1 = createAuthenticationObject(user1); + AuthenticationObject authObj2 = createAuthenticationObject(user2); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user1.getId())) + .thenReturn(null); + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user2.getId())) + .thenReturn(null); + + ResponseEntity> response1 = duelController.createParty(authObj1); + ResponseEntity> response2 = duelController.createParty(authObj2); + + assertEquals(200, response1.getStatusCode().value()); + assertEquals(200, response2.getStatusCode().value()); + assertTrue(response1.getBody().isSuccess()); + assertTrue(response2.getBody().isSuccess()); + + ArgumentCaptor lobbyCaptor = ArgumentCaptor.forClass(Lobby.class); + ArgumentCaptor playerCaptor = ArgumentCaptor.forClass(LobbyPlayer.class); + + verify(lobbyRepository, times(2)).createLobby(lobbyCaptor.capture()); + verify(lobbyPlayerRepository, times(2)).createLobbyPlayer(playerCaptor.capture()); + + var lobbyPlayers = playerCaptor.getAllValues(); + assertEquals(user1.getId(), lobbyPlayers.get(0).getPlayerId()); + assertEquals(user2.getId(), lobbyPlayers.get(1).getPlayerId()); + } + + @Test + void testEachPartyHasUniqueJoinCode() { + when(env.isProd()).thenReturn(false); + + User user1 = createRandomUser(); + User user2 = createRandomUser(); + AuthenticationObject authObj1 = createAuthenticationObject(user1); + AuthenticationObject authObj2 = createAuthenticationObject(user2); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user1.getId())) + .thenReturn(null); + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user2.getId())) + .thenReturn(null); + + duelController.createParty(authObj1); + duelController.createParty(authObj2); + + ArgumentCaptor lobbyCaptor = ArgumentCaptor.forClass(Lobby.class); + verify(lobbyRepository, times(2)).createLobby(lobbyCaptor.capture()); + + var lobbies = lobbyCaptor.getAllValues(); + assertNotEquals(lobbies.get(0).getJoinCode(), lobbies.get(1).getJoinCode()); + } + + @Test + void testPartyExpirationTimeIsSet30MinutesInFuture() { + when(env.isProd()).thenReturn(false); + + User user = createRandomUser(); + AuthenticationObject authObj = createAuthenticationObject(user); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user.getId())) + .thenReturn(null); + + OffsetDateTime beforeCreation = OffsetDateTime.now(); + + duelController.createParty(authObj); + + OffsetDateTime afterCreation = OffsetDateTime.now(); + + ArgumentCaptor lobbyCaptor = ArgumentCaptor.forClass(Lobby.class); + verify(lobbyRepository, times(1)).createLobby(lobbyCaptor.capture()); + + Lobby createdLobby = lobbyCaptor.getValue(); + OffsetDateTime expiresAt = createdLobby.getExpiresAt(); + + OffsetDateTime expectedExpiration = beforeCreation.plusMinutes(30); + assertTrue(expiresAt.isAfter(expectedExpiration.minusSeconds(5)), + "Expiration time should be after expected 30 minute mark"); + assertTrue(expiresAt.isBefore(afterCreation.plusMinutes(30).plusSeconds(5)), + "Expiration time should be before expected 30 minute mark plus buffer"); + } + + @Test + void testCreatePartyFailsInProductionEnvironment() { + when(env.isProd()).thenReturn(true); + + User user = createRandomUser(); + AuthenticationObject authObj = createAuthenticationObject(user); + + org.springframework.web.server.ResponseStatusException exception = assertThrows( + org.springframework.web.server.ResponseStatusException.class, + () -> duelController.createParty(authObj)); + + assertEquals(403, exception.getStatusCode().value()); + assertEquals("Endpoint is currently non-functional", exception.getReason()); + + verify(lobbyPlayerRepository, times(0)).findLobbyPlayerByPlayerId(any()); + verify(lobbyRepository, times(0)).createLobby(any()); + verify(lobbyPlayerRepository, times(0)).createLobbyPlayer(any()); + } + + @Test + void testNewLobbyPlayerStartsWithZeroPoints() { + when(env.isProd()).thenReturn(false); + + User user = createRandomUser(); + AuthenticationObject authObj = createAuthenticationObject(user); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user.getId())) + .thenReturn(null); + + ResponseEntity> response = duelController.createParty(authObj); + + assertEquals(200, response.getStatusCode().value()); + + ArgumentCaptor playerCaptor = ArgumentCaptor.forClass(LobbyPlayer.class); + verify(lobbyPlayerRepository, times(1)).createLobbyPlayer(playerCaptor.capture()); + + LobbyPlayer createdPlayer = playerCaptor.getValue(); + assertEquals(0, createdPlayer.getPoints()); + } + + @Test + void testCreatePartyInitializesLobbyWithoutWinner() { + when(env.isProd()).thenReturn(false); + + User user = createRandomUser(); + AuthenticationObject authObj = createAuthenticationObject(user); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user.getId())) + .thenReturn(null); + + ResponseEntity> response = duelController.createParty(authObj); + + assertEquals(200, response.getStatusCode().value()); + + ArgumentCaptor lobbyCaptor = ArgumentCaptor.forClass(Lobby.class); + verify(lobbyRepository, times(1)).createLobby(lobbyCaptor.capture()); + + Lobby createdLobby = lobbyCaptor.getValue(); + assertNull(createdLobby.getWinnerId()); + } + + @Test + void testSuccessResponseContainsJoinCode() { + when(env.isProd()).thenReturn(false); + + User user = createRandomUser(); + AuthenticationObject authObj = createAuthenticationObject(user); + + when(lobbyPlayerRepository.findLobbyPlayerByPlayerId(user.getId())) + .thenReturn(null); + + ResponseEntity> response = duelController.createParty(authObj); + + assertEquals(200, response.getStatusCode().value()); + assertTrue(response.getBody().isSuccess()); + assertTrue(response.getBody().getMessage().contains("Lobby created successfully"), + "Message should indicate successful lobby creation"); + assertTrue(response.getBody().getMessage().contains("join code"), + "Message should mention join code"); + } +}