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:
parent
09ed6bcaa6
commit
5ed06d9942
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
<%
|
<%
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue