diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java new file mode 100644 index 0000000..12cf308 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamVoteController.java @@ -0,0 +1,216 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.jam.JamEvalWindow; +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamVotesMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Controller +public class JamVoteController { + + private final JamsMapper jamsMapper; + private final JamEntriesMapper jamEntriesMapper; + private final JamVotesMapper jamVotesMapper; + + public JamVoteController( + JamsMapper jamsMapper, + JamEntriesMapper jamEntriesMapper, + JamVotesMapper jamVotesMapper + ) { + this.jamsMapper = jamsMapper; + this.jamEntriesMapper = jamEntriesMapper; + this.jamVotesMapper = jamVotesMapper; + } + + @PostMapping("/jams/{slug}/vote") + @ResponseBody + @Transactional + public ResponseEntity> vote( + @PathVariable("slug") String slug, + @RequestParam("gameId") long gameId, + HttpServletRequest request, + HttpSession session + ) { + // 게이트 1 — CSRF + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + // 게이트 2 — 인증 + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + // 게이트 3 — 잼 존재 + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + // 게이트 4 — 평가 기간 + if (!JamEvalWindow.isOpen(jam, OffsetDateTime.now())) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "투표 기간이 아닙니다."); + } + // 게이트 5 — 출품작 존재 + if (!jamEntriesMapper.exists(jam.getId(), gameId)) { + return response(HttpStatus.NOT_FOUND, "출품작을 찾을 수 없습니다."); + } + + // 토글: 미투표→신규, 동일 대상→멱등 no-op, 다른 대상→교체 + Long current = jamVotesMapper.findVotedGameId(jam.getId(), userId); + boolean changed; + if (current == null) { + jamVotesMapper.castVote(jam.getId(), gameId, userId); + changed = false; + } else if (current.longValue() == gameId) { + changed = false; + } else { + jamVotesMapper.updateVote(jam.getId(), userId, gameId); + changed = true; + } + + // 진행 중에는 집계를 노출하지 않는다(밴드왜건 회피) — 본인 표만 반환 + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("votedGameId", gameId); + body.put("changed", changed); + return ResponseEntity.ok(body); + } + + @DeleteMapping("/jams/{slug}/vote") + @ResponseBody + @Transactional + public ResponseEntity> cancelVote( + @PathVariable("slug") String slug, + HttpServletRequest request, + HttpSession session + ) { + // 게이트 1 — CSRF + if (!CsrfTokens.isValid(request)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody()); + } + // 게이트 2 — 인증 + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + // 게이트 3 — 잼 존재 + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + // 게이트 4 — 평가 기간 + if (!JamEvalWindow.isOpen(jam, OffsetDateTime.now())) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "투표 기간이 아닙니다."); + } + + int affected = jamVotesMapper.deleteVote(jam.getId(), userId); + if (affected == 0) { + return response(HttpStatus.NOT_FOUND, "취소할 표가 없습니다."); + } + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("votedGameId", null); + return ResponseEntity.ok(body); + } + + @GetMapping("/jams/{slug}/vote/mine") + @ResponseBody + public ResponseEntity> myVote( + @PathVariable("slug") String slug, + HttpSession session + ) { + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + + // 본인 표는 진행 중에도 노출 + Long votedGameId = jamVotesMapper.findVotedGameId(jam.getId(), userId); + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + body.put("votedGameId", votedGameId); + return ResponseEntity.ok(body); + } + + @GetMapping("/jams/{slug}/vote/results") + @ResponseBody + public ResponseEntity> results( + @PathVariable("slug") String slug + ) { + JamData jam = jamsMapper.getBySlug(slug); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + + // 노출 게이트(밴드왜건 회피) — 종료 전에는 출품작별 집계를 숨긴다 + boolean evalEnded = jam.getEvalEndAt() != null + && OffsetDateTime.now().isAfter(jam.getEvalEndAt()); + boolean publiclyVisible = "CLOSED".equals(jam.getStatus()) || evalEnded; + + Map body = new LinkedHashMap<>(); + body.put("status", 200); + if (publiclyVisible) { + List> results = jamVotesMapper.listCountsByJam(jam.getId()); + long total = 0; + for (Map row : results) { + total += ((Number) row.get("voteCount")).longValue(); + } + body.put("results", results); + body.put("total", total); + } else { + body.put("open", true); + body.put("results", null); + body.put("total", jamVotesMapper.countByJam(jam.getId())); + } + return ResponseEntity.ok(body); + } + + private Long sessionUserId(HttpSession session) { + if (session == null) { + return null; + } + Object userId = session.getAttribute("userId"); + if (userId instanceof Number number) { + return number.longValue(); + } + if (userId instanceof String text) { + try { + return Long.parseLong(text); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + private ResponseEntity> response(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("status", status.value()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java new file mode 100644 index 0000000..44a4dc9 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamVotesMapper.java @@ -0,0 +1,69 @@ +package com.pandoli365.bibimbap.mapper; + +import org.apache.ibatis.annotations.Delete; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; +import java.util.Map; + +/** + * 게임잼 인기투표(잼당 1인 1표) 매퍼. + */ +@Mapper +public interface JamVotesMapper { + + @Insert(""" + INSERT INTO jam_votes (jam_id, game_id, voter_user_id) + VALUES (#{jamId}, #{gameId}, #{voterUserId}) + """) + int castVote(@Param("jamId") long jamId, + @Param("gameId") long gameId, + @Param("voterUserId") long voterUserId); + + @Update(""" + UPDATE jam_votes + SET game_id = #{gameId} + WHERE jam_id = #{jamId} + AND voter_user_id = #{voterUserId} + """) + int updateVote(@Param("jamId") long jamId, + @Param("voterUserId") long voterUserId, + @Param("gameId") long gameId); + + @Delete(""" + DELETE FROM jam_votes + WHERE jam_id = #{jamId} + AND voter_user_id = #{voterUserId} + """) + int deleteVote(@Param("jamId") long jamId, + @Param("voterUserId") long voterUserId); + + @Select(""" + SELECT game_id + FROM jam_votes + WHERE jam_id = #{jamId} + AND voter_user_id = #{voterUserId} + """) + Long findVotedGameId(@Param("jamId") long jamId, + @Param("voterUserId") long voterUserId); + + @Select(""" + SELECT COUNT(*) + FROM jam_votes + WHERE jam_id = #{jamId} + """) + long countByJam(@Param("jamId") long jamId); + + @Select(""" + SELECT game_id AS "gameId", COUNT(*) AS "voteCount" + FROM jam_votes + WHERE jam_id = #{jamId} + GROUP BY game_id + ORDER BY COUNT(*) DESC, game_id ASC + """) + List> listCountsByJam(@Param("jamId") long jamId); +} diff --git a/src/main/webapp/WEB-INF/views/jam-detail.jsp b/src/main/webapp/WEB-INF/views/jam-detail.jsp index 75cde31..8b07238 100644 --- a/src/main/webapp/WEB-INF/views/jam-detail.jsp +++ b/src/main/webapp/WEB-INF/views/jam-detail.jsp @@ -397,6 +397,66 @@ %> +<% + boolean voteOpen = "EVAL".equals(status); + boolean evalEnded = jam.getEvalEndAt() != null && java.time.OffsetDateTime.now().isAfter(jam.getEvalEndAt()); + boolean resultsPublic = "CLOSED".equals(status) || evalEnded; +%> +
+

인기투표

+<% + if (entries == null || entries.isEmpty()) { +%> +

출품작이 없습니다.

+<% + } else if (voteOpen) { +%> +

평가기간입니다. 마음에 드는 출품작에 투표하세요. (한 표만 행사할 수 있으며 변경·취소가 가능합니다.)

+
+<% + for (JamEntryData entry : entries) { + if (entry == null || entry.getGameId() == null) { + continue; + } + String voteGameName = entry.getGameName() == null || entry.getGameName().isBlank() ? "이름 없는 게임" : entry.getGameName(); +%> +
+
+

<%= HtmlUtils.htmlEscape(voteGameName) %>

+

+ +

+
+
+<% + } +%> +
+
+ +
+<% + } else { +%> +

투표 기간이 아닙니다.

+<% + } +%> +
+<% + if (resultsPublic) { +%> +

투표 결과

+
+<% + } else { +%> +

투표 결과는 평가 종료 후 공개됩니다.

+<% + } +%> +
+ <% if (teams != null && !teams.isEmpty()) { %> @@ -450,6 +510,139 @@ + + <% } %> diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index 0525d48..ea8d621 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -13,6 +13,7 @@ import com.pandoli365.bibimbap.mapper.JamScoresMapper; import com.pandoli365.bibimbap.mapper.JamStatusLogMapper; import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; import com.pandoli365.bibimbap.mapper.JamTeamsMapper; +import com.pandoli365.bibimbap.mapper.JamVotesMapper; import com.pandoli365.bibimbap.mapper.JamsMapper; import com.pandoli365.bibimbap.mapper.PermissionsMapper; import com.pandoli365.bibimbap.mapper.RbacAuditMapper; @@ -94,6 +95,9 @@ class BibimbapApplicationTests { @MockBean private JamScoreStatsMapper jamScoreStatsMapper; + @MockBean + private JamVotesMapper jamVotesMapper; + @MockBean private PermissionGate permissionGate; diff --git a/src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java new file mode 100644 index 0000000..8738117 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/JamVoteControllerTest.java @@ -0,0 +1,494 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamVotesMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpSession; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * W2-5 인기투표 컨트롤러 단위 테스트 (plain Mockito, MockMvc 미사용). + * + *

vote/cancelVote 의 게이트 순서(CSRF→인증→잼존재→평가기간→출품작존재)와 토글 + * 시맨틱(신규/멱등 no-op/교체)을 검증하고, results 노출 게이트(밴드왜건 회피)를 + * 진행 중 은닉 / 종료 후 노출로 전수 검증한다. + * + *

VP-1~VP-8, VP-11 매핑은 메서드 주석 참조. AC-T1(상태변경 2핸들러 CSRF 차단), + * AC-T3(results 진행 중 출품작별 집계 은닉, 종료 후 노출) 커버. + */ +@ExtendWith(MockitoExtension.class) +class JamVoteControllerTest { + + private static final long JAM_ID = 42L; + private static final long GAME_ID = 7L; + private static final long OTHER_GAME_ID = 8L; + private static final long USER_ID = 99L; + private static final String SLUG = "my-jam"; + + @Mock + private JamsMapper jamsMapper; + + @Mock + private JamEntriesMapper jamEntriesMapper; + + @Mock + private JamVotesMapper jamVotesMapper; + + // ==== vote (POST): 게이트 + 토글 ==== + + /** VP-5/AC-T1: CSRF 누락 → 403, 매퍼 접근 전 차단. */ + @Test + void voteRejectsMissingCsrf() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + verifyNoInteractions(jamsMapper); + verify(jamVotesMapper, never()).castVote(anyLong(), anyLong(), anyLong()); + verify(jamVotesMapper, never()).updateVote(anyLong(), anyLong(), anyLong()); + } + + /** VP-3: 미인증(userId 미설정) → 401, 표 매퍼 미상호작용. */ + @Test + void voteReturns401WhenUnauthenticated() { + JamVoteController controller = controller(); + MockHttpSession session = anonymousSession(); + MockHttpServletRequest request = csrfPost(session); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(jamVotesMapper); + } + + /** 잼 없음 → 404. */ + @Test + void voteReturns404WhenJamMissing() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(null); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verifyNoInteractions(jamVotesMapper); + } + + /** VP-2/AC-4: 평가 기간 아님(RECRUIT) → 422, 투표 미반영. */ + @Test + void voteReturns422WhenNotEvalPeriod() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(recruitJam()); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).containsEntry("message", "투표 기간이 아닙니다."); + verifyNoInteractions(jamVotesMapper); + } + + /** 출품작 없음 → 404. */ + @Test + void voteReturns404WhenEntryMissing() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(false); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verifyNoInteractions(jamVotesMapper); + } + + /** VP-1/VP-8: 첫 투표 → castVote, 200 changed=false votedGameId=GAME_ID. */ + @Test + void voteCastsWhenFirstVote() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(true); + when(jamVotesMapper.findVotedGameId(JAM_ID, USER_ID)).thenReturn(null); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("changed", false); + assertThat(response.getBody()).containsEntry("votedGameId", GAME_ID); + verify(jamVotesMapper).castVote(JAM_ID, GAME_ID, USER_ID); + verify(jamVotesMapper, never()).updateVote(anyLong(), anyLong(), anyLong()); + } + + /** VP-8 멱등: 이미 같은 대상 → no-op, 200 changed=false. */ + @Test + void voteIsNoOpWhenSameGame() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(true); + when(jamVotesMapper.findVotedGameId(JAM_ID, USER_ID)).thenReturn(GAME_ID); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("changed", false); + verify(jamVotesMapper, never()).castVote(anyLong(), anyLong(), anyLong()); + verify(jamVotesMapper, never()).updateVote(anyLong(), anyLong(), anyLong()); + } + + /** VP-1/VP-8 변경: 다른 대상으로 → updateVote, 200 changed=true. */ + @Test + void voteUpdatesWhenDifferentGame() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(true); + when(jamVotesMapper.findVotedGameId(JAM_ID, USER_ID)).thenReturn(OTHER_GAME_ID); + + ResponseEntity> response = + controller.vote(SLUG, GAME_ID, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("changed", true); + assertThat(response.getBody()).containsEntry("votedGameId", GAME_ID); + verify(jamVotesMapper).updateVote(JAM_ID, USER_ID, GAME_ID); + verify(jamVotesMapper, never()).castVote(anyLong(), anyLong(), anyLong()); + } + + // ==== cancelVote (DELETE) ==== + + /** AC-T1: CSRF 누락 → 403, 삭제 미수행. */ + @Test + void cancelRejectsMissingCsrf() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.cancelVote(SLUG, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + verifyNoInteractions(jamsMapper); + verify(jamVotesMapper, never()).deleteVote(anyLong(), anyLong()); + } + + /** VP-3: 미인증 → 401. */ + @Test + void cancelReturns401WhenUnauthenticated() { + JamVoteController controller = controller(); + MockHttpSession session = anonymousSession(); + MockHttpServletRequest request = csrfPost(session); + + ResponseEntity> response = + controller.cancelVote(SLUG, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(jamVotesMapper); + } + + /** 잼 없음 → 404. */ + @Test + void cancelReturns404WhenJamMissing() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(null); + + ResponseEntity> response = + controller.cancelVote(SLUG, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(jamVotesMapper, never()).deleteVote(anyLong(), anyLong()); + } + + /** AC-4: 평가 기간 아님 → 422, 삭제 미수행. */ + @Test + void cancelReturns422WhenNotEvalPeriod() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(recruitJam()); + + ResponseEntity> response = + controller.cancelVote(SLUG, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + verify(jamVotesMapper, never()).deleteVote(anyLong(), anyLong()); + } + + /** 취소할 표 없음(affected 0) → 404. */ + @Test + void cancelReturns404WhenNoVote() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamVotesMapper.deleteVote(JAM_ID, USER_ID)).thenReturn(0); + + ResponseEntity> response = + controller.cancelVote(SLUG, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).containsEntry("message", "취소할 표가 없습니다."); + } + + /** VP-8: 취소 성공 → 200, votedGameId null. */ + @Test + void cancelSucceeds() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamVotesMapper.deleteVote(JAM_ID, USER_ID)).thenReturn(1); + + ResponseEntity> response = + controller.cancelVote(SLUG, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsKey("votedGameId"); + assertThat(response.getBody().get("votedGameId")).isNull(); + } + + // ==== myVote (GET /mine) ==== + + /** VP-3: 미인증 → 401, 잼 조회 전 차단. */ + @Test + void myVoteReturns401WhenUnauthenticated() { + JamVoteController controller = controller(); + MockHttpSession session = anonymousSession(); + + ResponseEntity> response = controller.myVote(SLUG, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(jamsMapper); + verifyNoInteractions(jamVotesMapper); + } + + /** 잼 없음 → 404. */ + @Test + void myVoteReturns404WhenJamMissing() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + when(jamsMapper.getBySlug(SLUG)).thenReturn(null); + + ResponseEntity> response = controller.myVote(SLUG, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verifyNoInteractions(jamVotesMapper); + } + + /** VP-11/AC-11: 진행 중에도 본인 표 노출 → votedGameId=GAME_ID. */ + @Test + void myVoteReturnsVotedGameId() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamVotesMapper.findVotedGameId(JAM_ID, USER_ID)).thenReturn(GAME_ID); + + ResponseEntity> response = controller.myVote(SLUG, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("votedGameId", GAME_ID); + } + + /** 본인 표 없음 → 200, votedGameId null. */ + @Test + void myVoteReturnsNullWhenNotVoted() { + JamVoteController controller = controller(); + MockHttpSession session = userSession(USER_ID); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamVotesMapper.findVotedGameId(JAM_ID, USER_ID)).thenReturn(null); + + ResponseEntity> response = controller.myVote(SLUG, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsKey("votedGameId"); + assertThat(response.getBody().get("votedGameId")).isNull(); + } + + // ==== results (GET /results): 노출 게이트(AC-T3) ==== + + /** 잼 없음 → 404. */ + @Test + void resultsReturns404WhenJamMissing() { + JamVoteController controller = controller(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(null); + + ResponseEntity> response = controller.results(SLUG); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verifyNoInteractions(jamVotesMapper); + } + + /** VP-4/AC-6/AC-T3: 진행 중에는 출품작별 집계 은닉 — open/total 만 노출. */ + @Test + void resultsHidesCountsDuringEval() { + JamVoteController controller = controller(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalJam()); + when(jamVotesMapper.countByJam(JAM_ID)).thenReturn(5L); + + ResponseEntity> response = controller.results(SLUG); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("open", true); + assertThat(response.getBody()).containsEntry("total", 5L); + assertThat(response.getBody().get("results")).isNull(); + verify(jamVotesMapper).countByJam(JAM_ID); + verify(jamVotesMapper, never()).listCountsByJam(anyLong()); + } + + /** VP-4/AC-6: 종료(CLOSED)면 출품작별 집계 노출, total 합산. */ + @Test + void resultsRevealsCountsWhenClosed() { + JamVoteController controller = controller(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(closedJam()); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 7L, "voteCount", 3L), + Map.of("gameId", 8L, "voteCount", 1L) + )); + + ResponseEntity> response = controller.results(SLUG); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsKey("results"); + assertThat(response.getBody().get("results")).isNotNull(); + assertThat(response.getBody()).containsEntry("total", 4L); + verify(jamVotesMapper).listCountsByJam(JAM_ID); + verify(jamVotesMapper, never()).countByJam(anyLong()); + } + + /** VP-4: 평가 종료(now > evalEnd)면 CLOSED 아니어도 집계 노출. */ + @Test + void resultsRevealsCountsWhenEvalEnded() { + JamVoteController controller = controller(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(evalEndedJam()); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of( + Map.of("gameId", 7L, "voteCount", 2L), + Map.of("gameId", 8L, "voteCount", 2L) + )); + + ResponseEntity> response = controller.results(SLUG); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().get("results")).isNotNull(); + assertThat(response.getBody()).containsEntry("total", 4L); + verify(jamVotesMapper).listCountsByJam(JAM_ID); + } + + /** VP-3: results 는 세션 인자 없음 — 미로그인도 잼 존재 시 200. */ + @Test + void resultsAccessibleWhenAnonymous() { + JamVoteController controller = controller(); + when(jamsMapper.getBySlug(SLUG)).thenReturn(closedJam()); + when(jamVotesMapper.listCountsByJam(JAM_ID)).thenReturn(List.of()); + + ResponseEntity> response = controller.results(SLUG); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("total", 0L); + } + + // ==== helpers ==== + + private JamVoteController controller() { + return new JamVoteController(jamsMapper, jamEntriesMapper, jamVotesMapper); + } + + private JamData evalJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setStatus("EVAL"); + jam.setEvalStartAt(OffsetDateTime.now().minusHours(1)); + jam.setEvalEndAt(OffsetDateTime.now().plusHours(1)); + return jam; + } + + private JamData recruitJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setStatus("RECRUIT"); + return jam; + } + + private JamData closedJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setStatus("CLOSED"); + return jam; + } + + /** 평가 종료 시각이 과거 — results 노출 게이트(now > evalEnd) 검증용. */ + private JamData evalEndedJam() { + JamData jam = new JamData(); + jam.setId(JAM_ID); + jam.setStatus("EVAL"); + jam.setEvalStartAt(OffsetDateTime.now().minusHours(2)); + jam.setEvalEndAt(OffsetDateTime.now().minusHours(1)); + return jam; + } + + private MockHttpSession userSession(long userId) { + MockHttpSession session = new MockHttpSession(); + session.setAttribute("userId", userId); + CsrfTokens.getOrCreate(session); + return session; + } + + /** userId 미설정 — CSRF 통과를 위해 토큰만 보유. */ + private MockHttpSession anonymousSession() { + MockHttpSession session = new MockHttpSession(); + CsrfTokens.getOrCreate(session); + return session; + } + + private MockHttpServletRequest csrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE)); + return request; + } + + private MockHttpServletRequest noCsrfPost(MockHttpSession session) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setSession(session); + return request; + } +}