feat(jam): W2-5 인기투표 — jam_votes 1인1표 UNIQUE + 평가기간 게이트 + 종료후 공개

- JamVoteController 4핸들러(POST vote 토글 / DELETE cancelVote / GET mine / GET results)
- 1인1표: jam_votes UNIQUE(jam_id,voter_user_id) + 컨트롤러 토글(findVotedGameId → cast/no-op/update)
- 평가기간 게이트: JamEvalWindow.isOpen(W2-4 재사용) → 422. CSRF 누락 → 403. 미로그인 → 401(results 공개)
- 종료후 공개(밴드왜건 회피): CLOSED || eval_end 경과 시만 게임별 count 노출 — 컨트롤러+JSP 2지점 게이트, 진행 중 은닉
- JamVotesMapper: listCountsByJam Map resultType 집계 → AS "gameId"/"voteCount" 인용(§33 케이스폴딩 가드 — Map resultType 기준, 설계 concern3 정정). 신규 DDL 0(W2-3 jam_votes 소비)
- BibimbapApplicationTests @MockBean JamVotesMapper

검증: ./mvnw -o test 171/171 GREEN(신규 23 JamVoteControllerTest, 회귀 0). L2 contract PASS — ux_jam_votes UNIQUE 거부·토글 update·alias camelCase 보존(비인용 대조군 폴딩 재현). 집합전수 AC-T1~4 PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
이정수 2026-06-24 12:16:20 +09:00
parent 09ed6bcaa6
commit 5ed06d9942
5 changed files with 976 additions and 0 deletions

View File

@ -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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("votedGameId", null);
return ResponseEntity.ok(body);
}
@GetMapping("/jams/{slug}/vote/mine")
@ResponseBody
public ResponseEntity<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("votedGameId", votedGameId);
return ResponseEntity.ok(body);
}
@GetMapping("/jams/{slug}/vote/results")
@ResponseBody
public ResponseEntity<Map<String, Object>> 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<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
if (publiclyVisible) {
List<Map<String, Object>> results = jamVotesMapper.listCountsByJam(jam.getId());
long total = 0;
for (Map<String, Object> 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<Map<String, Object>> response(HttpStatus status, String message) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", status.value());
body.put("message", message);
return ResponseEntity.status(status).body(body);
}
}

View File

@ -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<Map<String, Object>> listCountsByJam(@Param("jamId") long jamId);
}

View File

@ -397,6 +397,66 @@
%> %>
</section> </section>
<%
boolean voteOpen = "EVAL".equals(status);
boolean evalEnded = jam.getEvalEndAt() != null && java.time.OffsetDateTime.now().isAfter(jam.getEvalEndAt());
boolean resultsPublic = "CLOSED".equals(status) || evalEnded;
%>
<section class="detail-section" aria-labelledby="jam-vote">
<h2 id="jam-vote">인기투표</h2>
<%
if (entries == null || entries.isEmpty()) {
%>
<p class="empty-note">출품작이 없습니다.</p>
<%
} else if (voteOpen) {
%>
<p>평가기간입니다. 마음에 드는 출품작에 투표하세요. (한 표만 행사할 수 있으며 변경·취소가 가능합니다.)</p>
<div class="entry-grid">
<%
for (JamEntryData entry : entries) {
if (entry == null || entry.getGameId() == null) {
continue;
}
String voteGameName = entry.getGameName() == null || entry.getGameName().isBlank() ? "이름 없는 게임" : entry.getGameName();
%>
<div class="entry-card">
<div class="entry-card__body">
<p class="entry-card__name"><%= HtmlUtils.htmlEscape(voteGameName) %></p>
<p class="entry-card__meta">
<button type="button" class="detail-button vote-btn" data-game-id="<%= entry.getGameId() %>">투표</button>
</p>
</div>
</div>
<%
}
%>
</div>
<div class="detail-actions">
<button type="button" id="vote-cancel" class="detail-button">투표 취소</button>
</div>
<%
} else {
%>
<p class="empty-note">투표 기간이 아닙니다.</p>
<%
}
%>
<div id="my-vote" class="empty-note" style="margin-top:0.75rem;"></div>
<%
if (resultsPublic) {
%>
<h2 style="margin-top:1.25rem;">투표 결과</h2>
<div id="vote-results"></div>
<%
} else {
%>
<p class="empty-note" style="margin-top:0.75rem;">투표 결과는 평가 종료 후 공개됩니다.</p>
<%
}
%>
</section>
<% <%
if (teams != null && !teams.isEmpty()) { if (teams != null && !teams.isEmpty()) {
%> %>
@ -450,6 +510,139 @@
<button type="submit" class="detail-button detail-button--primary">팀 생성</button> <button type="submit" class="detail-button detail-button--primary">팀 생성</button>
</form> </form>
</section> </section>
<script>
(function () {
var ctx = '<%= ctx %>';
// slug 는 JS 문자열 컨텍스트 이스케이프 후 주입, 사용 시점에 encodeURIComponent 로 경로 안전화
var slug = '<%= jam.getSlug() == null ? "" : jam.getSlug().replace("\\", "\\\\").replace("'", "\\'").replace("<", "\\u003C").replace(">", "\\u003E").replace("\r", "").replace("\n", "") %>';
var csrf = (document.querySelector('meta[name="_csrf"]') || {}).content || '';
var resultsPublic = <%= resultsPublic %>;
var base = ctx + '/jams/' + encodeURIComponent(slug) + '/vote';
var myVoteEl = document.getElementById('my-vote');
var resultsEl = document.getElementById('vote-results');
function renderMyVote(votedGameId) {
if (!myVoteEl) {
return;
}
myVoteEl.textContent = (votedGameId == null || votedGameId === undefined)
? '아직 투표하지 않았습니다.'
: ('내가 투표한 게임 ID: ' + votedGameId);
}
function loadMyVote() {
fetch(base + '/mine', { headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (data) {
renderMyVote(data.votedGameId);
}
})
.catch(function () {});
}
function renderResults(data) {
if (!resultsEl) {
return;
}
resultsEl.textContent = '';
if (!data || !data.results || data.results.length === 0) {
var empty = document.createElement('p');
empty.className = 'empty-note';
empty.textContent = '집계된 표가 없습니다.';
resultsEl.appendChild(empty);
return;
}
var list = document.createElement('ul');
list.className = 'team-list';
data.results.forEach(function (row) {
var li = document.createElement('li');
var name = document.createElement('span');
name.className = 'team-list__name';
name.textContent = '게임 ID ' + row.gameId;
var count = document.createElement('span');
count.className = 'team-list__count';
count.textContent = (row.voteCount == null ? 0 : row.voteCount) + '표';
li.appendChild(name);
li.appendChild(count);
list.appendChild(li);
});
resultsEl.appendChild(list);
if (data.total != null) {
var totalEl = document.createElement('p');
totalEl.className = 'empty-note';
totalEl.style.marginTop = '0.5rem';
totalEl.textContent = '총 ' + data.total + '표';
resultsEl.appendChild(totalEl);
}
}
function loadResults() {
if (!resultsPublic || !resultsEl) {
return;
}
fetch(base + '/results', { headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (data && data.open) {
// 서버 기준 아직 진행 중이면 집계 미노출 (밴드왜건 회피)
resultsEl.textContent = '';
return;
}
renderResults(data);
})
.catch(function () {});
}
Array.prototype.forEach.call(document.querySelectorAll('.vote-btn'), function (btn) {
btn.addEventListener('click', function () {
var gameId = btn.getAttribute('data-game-id');
if (!gameId) {
return;
}
fetch(base, {
method: 'POST',
headers: {
'X-CSRF-Token': csrf,
'Content-Type': 'application/x-www-form-urlencoded'
},
credentials: 'same-origin',
body: 'gameId=' + encodeURIComponent(gameId)
})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (data) {
renderMyVote(data.votedGameId);
}
})
.catch(function () {});
});
});
var cancelBtn = document.getElementById('vote-cancel');
if (cancelBtn) {
cancelBtn.addEventListener('click', function () {
fetch(base, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf },
credentials: 'same-origin'
})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) {
if (data) {
renderMyVote(data.votedGameId);
}
})
.catch(function () {});
});
}
loadMyVote();
loadResults();
})();
</script>
<% <%
} }
%> %>

View File

@ -13,6 +13,7 @@ import com.pandoli365.bibimbap.mapper.JamScoresMapper;
import com.pandoli365.bibimbap.mapper.JamStatusLogMapper; import com.pandoli365.bibimbap.mapper.JamStatusLogMapper;
import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper;
import com.pandoli365.bibimbap.mapper.JamTeamsMapper; import com.pandoli365.bibimbap.mapper.JamTeamsMapper;
import com.pandoli365.bibimbap.mapper.JamVotesMapper;
import com.pandoli365.bibimbap.mapper.JamsMapper; import com.pandoli365.bibimbap.mapper.JamsMapper;
import com.pandoli365.bibimbap.mapper.PermissionsMapper; import com.pandoli365.bibimbap.mapper.PermissionsMapper;
import com.pandoli365.bibimbap.mapper.RbacAuditMapper; import com.pandoli365.bibimbap.mapper.RbacAuditMapper;
@ -94,6 +95,9 @@ class BibimbapApplicationTests {
@MockBean @MockBean
private JamScoreStatsMapper jamScoreStatsMapper; private JamScoreStatsMapper jamScoreStatsMapper;
@MockBean
private JamVotesMapper jamVotesMapper;
@MockBean @MockBean
private PermissionGate permissionGate; private PermissionGate permissionGate;

View File

@ -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 미사용).
*
* <p>vote/cancelVote 게이트 순서(CSRF인증잼존재평가기간출품작존재) 토글
* 시맨틱(신규/멱등 no-op/교체) 검증하고, results 노출 게이트(밴드왜건 회피)
* 진행 은닉 / 종료 노출로 전수 검증한다.
*
* <p>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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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;
}
}