diff --git a/src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java b/src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java new file mode 100644 index 0000000..9910601 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/controller/JamScoringController.java @@ -0,0 +1,270 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamCriterionData; +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.data.JamScoreData; +import com.pandoli365.bibimbap.jam.JamEvalWindow; +import com.pandoli365.bibimbap.mapper.JamCriteriaMapper; +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper; +import com.pandoli365.bibimbap.mapper.JamScoresMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.JamRoleGate; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +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.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; + +import java.time.OffsetDateTime; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Controller +public class JamScoringController { + + private final JamsMapper jamsMapper; + private final JamEntriesMapper jamEntriesMapper; + private final JamCriteriaMapper jamCriteriaMapper; + private final JamScoresMapper jamScoresMapper; + private final JamScoreStatsMapper jamScoreStatsMapper; + private final JamRoleGate jamRoleGate; + private final PermissionGate permissionGate; + + public JamScoringController( + JamsMapper jamsMapper, + JamEntriesMapper jamEntriesMapper, + JamCriteriaMapper jamCriteriaMapper, + JamScoresMapper jamScoresMapper, + JamScoreStatsMapper jamScoreStatsMapper, + JamRoleGate jamRoleGate, + PermissionGate permissionGate + ) { + this.jamsMapper = jamsMapper; + this.jamEntriesMapper = jamEntriesMapper; + this.jamCriteriaMapper = jamCriteriaMapper; + this.jamScoresMapper = jamScoresMapper; + this.jamScoreStatsMapper = jamScoreStatsMapper; + this.jamRoleGate = jamRoleGate; + this.permissionGate = permissionGate; + } + + @RequestMapping(value = "/jams/{jamId}/games/{gameId}/scores", + method = {RequestMethod.POST, RequestMethod.PUT}) + @ResponseBody + @Transactional + public ResponseEntity> submitScores( + @PathVariable long jamId, + @PathVariable long gameId, + @RequestBody Map body, + HttpServletRequest request, + HttpSession session + ) { + // 게이트 1 — CSRF (난제1 순서: 가장 먼저) + 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 — 심사 자격 (W2-2) + if (!jamRoleGate.isJudge(session, jamId)) { + return response(HttpStatus.FORBIDDEN, "심사 권한이 없습니다."); + } + // 게이트 4 — 잼 존재 + JamData jam = jamsMapper.getById(jamId); + if (jam == null) { + return response(HttpStatus.NOT_FOUND, "잼을 찾을 수 없습니다."); + } + // 게이트 5 — 평가 기간 (W2-3 F6) + if (!JamEvalWindow.isOpen(jam, OffsetDateTime.now())) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "평가 기간이 아닙니다."); + } + // 게이트 6 — 출품작 존재 + if (!jamEntriesMapper.exists(jamId, gameId)) { + return response(HttpStatus.NOT_FOUND, "출품작을 찾을 수 없습니다."); + } + // 게이트 7 — 자기 출품작 차단 (W2-2) + if (jamRoleGate.isOwnEntry(jamId, gameId, userId)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "자기 출품작은 심사할 수 없습니다."); + } + + // 게이트 8 — body 파싱 + 전수 검증 (통과 후에만 UPSERT, 부분 저장 금지) + Object rawScores = body == null ? null : body.get("scores"); + if (!(rawScores instanceof List scoreList) || scoreList.isEmpty()) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "입력된 점수가 없습니다."); + } + + Set allowedKeys = new HashSet<>(); + for (JamCriterionData criterion : jamCriteriaMapper.listByJam(jamId)) { + allowedKeys.add(criterion.getCriterionKey()); + } + + // 검증과 변환을 먼저 전수 수행 — 1건이라도 실패하면 upsert 미호출 + Map validated = new LinkedHashMap<>(); + for (Object element : scoreList) { + if (!(element instanceof Map entry)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "점수 항목 형식이 올바르지 않습니다."); + } + String criterionKey = String.valueOf(entry.get("criterionKey")); + if (!allowedKeys.contains(criterionKey)) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "등록되지 않은 평가 기준입니다."); + } + Integer score = parseScore(entry.get("score")); + if (score == null || score < 1 || score > 5) { + return response(HttpStatus.UNPROCESSABLE_ENTITY, "점수는 1~5 사이여야 합니다."); + } + validated.put(criterionKey, score); + } + + int savedCount = 0; + for (Map.Entry e : validated.entrySet()) { + jamScoresMapper.upsertScore(jamId, gameId, userId, e.getKey(), e.getValue()); + savedCount++; + } + + Map success = new LinkedHashMap<>(); + success.put("status", 200); + success.put("message", "점수가 저장되었습니다."); + success.put("jamId", jamId); + success.put("gameId", gameId); + success.put("savedCount", savedCount); + return ResponseEntity.ok(success); + } + + @GetMapping("/jams/{jamId}/games/{gameId}/scores/mine") + @ResponseBody + public ResponseEntity> myScores( + @PathVariable long jamId, + @PathVariable long gameId, + HttpSession session + ) { + Long userId = sessionUserId(session); + if (userId == null) { + return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."); + } + if (!jamRoleGate.isJudge(session, jamId)) { + return response(HttpStatus.FORBIDDEN, "심사 권한이 없습니다."); + } + + List mine = jamScoresMapper.listByJudge(jamId, gameId, userId); + List criteria = jamCriteriaMapper.listByJam(jamId); + + Map result = new LinkedHashMap<>(); + result.put("status", 200); + result.put("scores", mine); + result.put("criteria", criteria); + return ResponseEntity.ok(result); + } + + @GetMapping("/jams/{jamId}/scores/summary") + @ResponseBody + public ResponseEntity> summary( + @PathVariable long jamId, + HttpSession session + ) { + JamData jam = jamsMapper.getById(jamId); + 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; + if (!publiclyVisible + && !permissionGate.has(session, PermissionKeys.GAME_JAM_MANAGE.name())) { + return response(HttpStatus.FORBIDDEN, "집계는 평가 종료 후 공개됩니다."); + } + + List> stats = jamScoreStatsMapper.listStatsByJam(jamId); + + Map result = new LinkedHashMap<>(); + result.put("status", 200); + result.put("stats", stats); + return ResponseEntity.ok(result); + } + + @GetMapping("/jams/{jamId}/games/{gameId}/score") + public String scoringForm( + @PathVariable long jamId, + @PathVariable long gameId, + HttpServletRequest request, + HttpSession session, + Model model + ) { + Long userId = sessionUserId(session); + if (userId == null) { + return "redirect:/login"; + } + if (!jamRoleGate.isJudge(session, jamId)) { + return "redirect:/jams"; + } + JamData jam = jamsMapper.getById(jamId); + if (jam == null) { + return "redirect:/jams"; + } + + model.addAttribute("jam", jam); + model.addAttribute("jamId", jamId); + model.addAttribute("gameId", gameId); + model.addAttribute("criteria", jamCriteriaMapper.listByJam(jamId)); + model.addAttribute("mine", jamScoresMapper.listByJudge(jamId, gameId, userId)); + model.addAttribute("csrfToken", CsrfTokens.getOrCreate(request.getSession())); + return "jam-scoring"; + } + + private Integer parseScore(Object raw) { + if (raw instanceof Number number) { + return number.intValue(); + } + if (raw instanceof String text) { + try { + return Integer.parseInt(text.trim()); + } catch (NumberFormatException e) { + return null; + } + } + return null; + } + + 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/data/JamCriterionData.java b/src/main/java/com/pandoli365/bibimbap/data/JamCriterionData.java new file mode 100644 index 0000000..7e37188 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/JamCriterionData.java @@ -0,0 +1,47 @@ +package com.pandoli365.bibimbap.data; + +import java.math.BigDecimal; + +/** + * jam_criteria 행 POJO (W2-3 동결 스키마 소비). + * 점수 폼 라벨 + criterion_key 화이트리스트 소스(W2-4 §파일영향맵 K-DOMAIN). + */ +public class JamCriterionData { + + private String criterionKey; + private String displayName; + private Integer sortOrder; + private BigDecimal weight; + + public String getCriterionKey() { + return criterionKey; + } + + public void setCriterionKey(String criterionKey) { + this.criterionKey = criterionKey; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public Integer getSortOrder() { + return sortOrder; + } + + public void setSortOrder(Integer sortOrder) { + this.sortOrder = sortOrder; + } + + public BigDecimal getWeight() { + return weight; + } + + public void setWeight(BigDecimal weight) { + this.weight = weight; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java b/src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java new file mode 100644 index 0000000..d0d8aa1 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/data/JamScoreData.java @@ -0,0 +1,47 @@ +package com.pandoli365.bibimbap.data; + +import java.time.OffsetDateTime; + +/** + * jam_scores 행 POJO (W2-3 동결 스키마 소비). + * 심사위원 본인 입력 현황 조회(폼 prefill·수정용, W2-4 §파일영향맵 K-DOMAIN, S2 시퀀스). + */ +public class JamScoreData { + + private String criterionKey; + private Integer score; + private Long judgeUserId; + private OffsetDateTime updatedAt; + + public String getCriterionKey() { + return criterionKey; + } + + public void setCriterionKey(String criterionKey) { + this.criterionKey = criterionKey; + } + + public Integer getScore() { + return score; + } + + public void setScore(Integer score) { + this.score = score; + } + + public Long getJudgeUserId() { + return judgeUserId; + } + + public void setJudgeUserId(Long judgeUserId) { + this.judgeUserId = judgeUserId; + } + + public OffsetDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(OffsetDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java b/src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java new file mode 100644 index 0000000..db704a2 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/jam/JamEvalWindow.java @@ -0,0 +1,36 @@ +package com.pandoli365.bibimbap.jam; + +import com.pandoli365.bibimbap.data.JamData; + +import java.time.OffsetDateTime; + +/** + * 평가기간 게이트 판정(W2-3 F6 계약 구현, W2-4 설계 §게이트연동 2 / AC-3). + * + *

평가가 열려 있으려면 잼이 EVAL 상태이고 현재 시각이 [eval_start_at, eval_end_at] 구간 안에 있어야 한다. + * eval_start_at/eval_end_at 가 NULL 이면 "기간 미설정"으로 보고 닫힘(EVAL 인데 기간 미설정은 운영 오류) 처리한다. + * + *

now 를 Clock 빈 DI 대신 인자로 받는다 — 테스트에서 경계 시각을 직접 주입하기 위함(설계 inflate 마킹 근거). + * jam 은 JamData 전체를 받지만 status/eval_start_at/eval_end_at 3필드만 읽는다(호출부 단순화 — getById 반환 그대로 전달). + */ +public final class JamEvalWindow { + + private JamEvalWindow() { + } + + public static boolean isOpen(JamData jam, OffsetDateTime now) { + if (jam == null || now == null) { + return false; + } + if (!"EVAL".equals(jam.getStatus())) { + return false; + } + OffsetDateTime start = jam.getEvalStartAt(); + OffsetDateTime end = jam.getEvalEndAt(); + if (start == null || end == null) { + return false; + } + // now ∈ [start, end] (경계 포함) + return !now.isBefore(start) && !now.isAfter(end); + } +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java new file mode 100644 index 0000000..2e78dc9 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamCriteriaMapper.java @@ -0,0 +1,23 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.JamCriterionData; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface JamCriteriaMapper { + + @Select(""" + SELECT + criterion_key AS criterionKey, + display_name AS displayName, + sort_order AS sortOrder, + weight + FROM jam_criteria + WHERE jam_id = #{jamId} + ORDER BY sort_order, criterion_key + """) + List listByJam(long jamId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java new file mode 100644 index 0000000..5ae0a5e --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamScoreStatsMapper.java @@ -0,0 +1,24 @@ +package com.pandoli365.bibimbap.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface JamScoreStatsMapper { + + @Select(""" + SELECT + game_id AS "gameId", + weighted_total AS "weightedTotal", + simple_total AS "simpleTotal", + scored_criteria AS "scoredCriteria", + judge_count AS "judgeCount" + FROM jam_score_stats + WHERE jam_id = #{jamId} + ORDER BY weighted_total DESC NULLS LAST, judge_count DESC, game_id ASC + """) + List> listStatsByJam(long jamId); +} diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java new file mode 100644 index 0000000..b0d5803 --- /dev/null +++ b/src/main/java/com/pandoli365/bibimbap/mapper/JamScoresMapper.java @@ -0,0 +1,52 @@ +package com.pandoli365.bibimbap.mapper; + +import com.pandoli365.bibimbap.data.JamScoreData; +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 java.util.List; + +@Mapper +public interface JamScoresMapper { + + @Insert(""" + INSERT INTO jam_scores ( + jam_id, + game_id, + judge_user_id, + criterion_key, + score + ) VALUES ( + #{jamId}, + #{gameId}, + #{judgeUserId}, + #{criterionKey}, + #{score} + ) + ON CONFLICT (jam_id, game_id, judge_user_id, criterion_key) + DO UPDATE SET score = EXCLUDED.score, updated_at = now() + """) + int upsertScore(@Param("jamId") long jamId, + @Param("gameId") long gameId, + @Param("judgeUserId") long judgeUserId, + @Param("criterionKey") String criterionKey, + @Param("score") int score); + + @Select(""" + SELECT + criterion_key AS criterionKey, + score, + judge_user_id AS judgeUserId, + updated_at AS updatedAt + FROM jam_scores + WHERE jam_id = #{jamId} + AND game_id = #{gameId} + AND judge_user_id = #{judgeUserId} + ORDER BY criterion_key + """) + List listByJudge(@Param("jamId") long jamId, + @Param("gameId") long gameId, + @Param("judgeUserId") long judgeUserId); +} diff --git a/src/main/webapp/WEB-INF/views/jam-scoring.jsp b/src/main/webapp/WEB-INF/views/jam-scoring.jsp new file mode 100644 index 0000000..a46b9e1 --- /dev/null +++ b/src/main/webapp/WEB-INF/views/jam-scoring.jsp @@ -0,0 +1,288 @@ +<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" language="java" %> +<%@ page import="java.util.List" %> +<%@ page import="java.util.Map" %> +<%@ page import="java.util.HashMap" %> +<%@ page import="com.pandoli365.bibimbap.data.JamData" %> +<%@ page import="com.pandoli365.bibimbap.data.JamCriterionData" %> +<%@ page import="com.pandoli365.bibimbap.data.JamScoreData" %> +<%@ page import="org.springframework.web.util.HtmlUtils" %> +<% + String ctx = request.getContextPath(); + JamData jam = (JamData) request.getAttribute("jam"); + Long jamId = (Long) request.getAttribute("jamId"); + Long gameId = (Long) request.getAttribute("gameId"); + List criteria = (List) request.getAttribute("criteria"); + List mine = (List) request.getAttribute("mine"); + + Object rawCsrf = request.getAttribute("csrfToken"); + String csrfToken = rawCsrf == null ? "" : String.valueOf(rawCsrf); + String csrfTokenHtml = HtmlUtils.htmlEscape(csrfToken); + // JS 문자열 컨텍스트용: 따옴표/역슬래시/스크립트 종료 시퀀스 차단 (jam-detail.jsp 동형) + String csrfTokenJs = csrfToken + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("<", "\\u003C") + .replace(">", "\\u003E") + .replace("\r", "") + .replace("\n", ""); + + // prefill: criterionKey -> score + Map mineByKey = new HashMap(); + if (mine != null) { + for (JamScoreData s : mine) { + if (s == null || s.getCriterionKey() == null) { + continue; + } + mineByKey.put(s.getCriterionKey(), s.getScore()); + } + } +%> + + + + + + + + <%= jam == null ? "심사 채점" : HtmlUtils.htmlEscape(jam.getTitle() == null ? "" : jam.getTitle()) %> 심사 채점 | bibimbap + + + + +

+ ← 게임잼 목록 +
+

<%= jam == null ? "" : HtmlUtils.htmlEscape(jam.getTitle() == null ? "" : jam.getTitle()) %> 심사 채점

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

채점 항목이 없습니다.

+<% + } else { +%> +
+<% + for (JamCriterionData c : criteria) { + if (c == null || c.getCriterionKey() == null) { + continue; + } + String key = c.getCriterionKey(); + String keyHtml = HtmlUtils.htmlEscape(key); + String nameHtml = HtmlUtils.htmlEscape(c.getDisplayName() == null ? key : c.getDisplayName()); + String weightStr = c.getWeight() == null ? "" : c.getWeight().toPlainString(); + Integer prefill = mineByKey.get(key); +%> +
+ + +
+<% + } +%> + +
+ +
+

+
+<% + } +%> +
+
+ + + + diff --git a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java index 84cf1aa..0525d48 100644 --- a/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java +++ b/src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java @@ -5,8 +5,11 @@ import com.pandoli365.bibimbap.mapper.GameReviewAxesMapper; import com.pandoli365.bibimbap.mapper.GameReviewStatsMapper; import com.pandoli365.bibimbap.mapper.GameReviewsMapper; import com.pandoli365.bibimbap.mapper.GamesMapper; +import com.pandoli365.bibimbap.mapper.JamCriteriaMapper; import com.pandoli365.bibimbap.mapper.JamEntriesMapper; import com.pandoli365.bibimbap.mapper.JamJudgesMapper; +import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper; +import com.pandoli365.bibimbap.mapper.JamScoresMapper; import com.pandoli365.bibimbap.mapper.JamStatusLogMapper; import com.pandoli365.bibimbap.mapper.JamTeamMembersMapper; import com.pandoli365.bibimbap.mapper.JamTeamsMapper; @@ -82,6 +85,15 @@ class BibimbapApplicationTests { @MockBean private JamStatusLogMapper jamStatusLogMapper; + @MockBean + private JamCriteriaMapper jamCriteriaMapper; + + @MockBean + private JamScoresMapper jamScoresMapper; + + @MockBean + private JamScoreStatsMapper jamScoreStatsMapper; + @MockBean private PermissionGate permissionGate; diff --git a/src/test/java/com/pandoli365/bibimbap/controller/JamScoringControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/JamScoringControllerTest.java new file mode 100644 index 0000000..5da5bb7 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/JamScoringControllerTest.java @@ -0,0 +1,472 @@ +package com.pandoli365.bibimbap.controller; + +import com.pandoli365.bibimbap.data.JamCriterionData; +import com.pandoli365.bibimbap.data.JamData; +import com.pandoli365.bibimbap.mapper.JamCriteriaMapper; +import com.pandoli365.bibimbap.mapper.JamEntriesMapper; +import com.pandoli365.bibimbap.mapper.JamScoreStatsMapper; +import com.pandoli365.bibimbap.mapper.JamScoresMapper; +import com.pandoli365.bibimbap.mapper.JamsMapper; +import com.pandoli365.bibimbap.security.CsrfTokens; +import com.pandoli365.bibimbap.security.JamRoleGate; +import com.pandoli365.bibimbap.security.PermissionGate; +import com.pandoli365.bibimbap.security.PermissionKeys; +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.math.BigDecimal; +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.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * W2-4 심사위원 평가 컨트롤러 단위 테스트 (plain Mockito, MockMvc 미사용). + * + *

submitScores 게이트 순서(난제1)를 첫 실패 지점으로 전수 검증: + * CSRF(403) → 인증(401) → 심사자격(403) → 잼존재(404) → 평가기간(422) → + * 출품작존재(404) → 자기출품작(422) → body 검증(422). upsertScore 는 전 게이트 + * 통과 + 전수 검증 통과 후에만 호출(부분 저장 금지). + * + *

AC-T5 5분류(401/403/404/422 + 200) 전수 커버, VP-1~VP-6 매핑은 메서드 주석 참조. + */ +@ExtendWith(MockitoExtension.class) +class JamScoringControllerTest { + + private static final String GAME_JAM_MANAGE = PermissionKeys.GAME_JAM_MANAGE.name(); + private static final long JUDGE_ID = 99L; + private static final long JAM_ID = 42L; + private static final long GAME_ID = 7L; + + @Mock + private JamsMapper jamsMapper; + + @Mock + private JamEntriesMapper jamEntriesMapper; + + @Mock + private JamCriteriaMapper jamCriteriaMapper; + + @Mock + private JamScoresMapper jamScoresMapper; + + @Mock + private JamScoreStatsMapper jamScoreStatsMapper; + + @Mock + private JamRoleGate jamRoleGate; + + @Mock + private PermissionGate permissionGate; + + // ==== submitScores: 게이트 순서 전수 검증 ==== + + /** VP-5: CSRF 누락 → 403, 매퍼 접근 전 차단. */ + @Test + void submitRejectsMissingCsrf() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = noCsrfPost(session); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("status", 403); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + verifyNoInteractions(jamsMapper); + verifyNoInteractions(jamCriteriaMapper); + } + + /** 인증 미통과 → 401 (CSRF 토큰은 세션에 있으나 userId 미설정). */ + @Test + void submitReturns401WhenUnauthenticated() { + JamScoringController controller = controller(); + MockHttpSession session = anonymousSession(); + MockHttpServletRequest request = csrfPost(session); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(response.getBody()).containsEntry("status", HttpStatus.UNAUTHORIZED.value()); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** VP-1: 심사 자격 없음 → 403. */ + @Test + void submitReturns403WhenNotJudge() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(false); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).containsEntry("message", "심사 권한이 없습니다."); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** 잼 없음 → 404. */ + @Test + void submitReturns404WhenJamMissing() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(null); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** 평가 기간 아님(RECRUIT 상태) → 422. */ + @Test + void submitReturns422WhenEvalClosed() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(recruitJam()); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).containsEntry("message", "평가 기간이 아닙니다."); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** 출품작 없음 → 404. */ + @Test + void submitReturns404WhenEntryMissing() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(evalJam()); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(false); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** 자기 출품작 → 422. */ + @Test + void submitReturns422WhenOwnEntry() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(evalJam()); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(true); + when(jamRoleGate.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).thenReturn(true); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).containsEntry("message", "자기 출품작은 심사할 수 없습니다."); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** VP-6: 화이트리스트 밖 criterion → 422, 전건 미저장. */ + @Test + void submitReturns422WhenUnknownCriterion() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + passAllGates(session); + when(jamCriteriaMapper.listByJam(JAM_ID)).thenReturn(List.of(criterion("fun", 1))); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("unknown", 4), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).containsEntry("message", "등록되지 않은 평가 기준입니다."); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** 점수 범위 밖(9) → 422, 전건 미저장. */ + @Test + void submitReturns422WhenScoreOutOfRange() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + passAllGates(session); + when(jamCriteriaMapper.listByJam(JAM_ID)).thenReturn(List.of(criterion("fun", 1))); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 9), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).containsEntry("message", "점수는 1~5 사이여야 합니다."); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** 점수 배열 비어 있음 → 422. */ + @Test + void submitReturns422WhenScoresEmpty() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + passAllGates(session); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, Map.of("scores", List.of()), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + assertThat(response.getBody()).containsEntry("message", "입력된 점수가 없습니다."); + verify(jamScoresMapper, never()).upsertScore(anyLong(), anyLong(), anyLong(), any(), anyInt()); + } + + /** VP-1 성공: 전 게이트 + 전수 검증 통과 → 200, savedCount 2, upsert 2회. */ + @Test + void submitUpsertsWhenAllGatesPass() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + passAllGates(session); + when(jamCriteriaMapper.listByJam(JAM_ID)) + .thenReturn(List.of(criterion("fun", 1), criterion("art", 2))); + + Map body = Map.of("scores", List.of( + Map.of("criterionKey", "fun", "score", 4), + Map.of("criterionKey", "art", "score", 5) + )); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, body, request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("status", 200); + assertThat(response.getBody()).containsEntry("savedCount", 2); + verify(jamScoresMapper).upsertScore(JAM_ID, GAME_ID, JUDGE_ID, "fun", 4); + verify(jamScoresMapper).upsertScore(JAM_ID, GAME_ID, JUDGE_ID, "art", 5); + } + + /** VP-2 멱등: 동일 키 재제출도 200 + upsert 호출(멱등은 SQL ON CONFLICT 책임). */ + @Test + void submitReturns200OnIdempotentResubmit() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + MockHttpServletRequest request = csrfPost(session); + passAllGates(session); + when(jamCriteriaMapper.listByJam(JAM_ID)).thenReturn(List.of(criterion("fun", 1))); + + ResponseEntity> response = + controller.submitScores(JAM_ID, GAME_ID, scoresBody("fun", 3), request, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsEntry("savedCount", 1); + verify(jamScoresMapper, times(1)).upsertScore(JAM_ID, GAME_ID, JUDGE_ID, "fun", 3); + } + + // ==== myScores ==== + + @Test + void myScoresReturns401WhenUnauthenticated() { + JamScoringController controller = controller(); + MockHttpSession session = anonymousSession(); + + ResponseEntity> response = + controller.myScores(JAM_ID, GAME_ID, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(jamScoresMapper); + } + + @Test + void myScoresReturns403WhenNotJudge() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(false); + + ResponseEntity> response = + controller.myScores(JAM_ID, GAME_ID, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verifyNoInteractions(jamScoresMapper); + } + + @Test + void myScoresReturnsScoresAndCriteria() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(true); + when(jamScoresMapper.listByJudge(JAM_ID, GAME_ID, JUDGE_ID)).thenReturn(List.of()); + when(jamCriteriaMapper.listByJam(JAM_ID)).thenReturn(List.of(criterion("fun", 1))); + + ResponseEntity> response = + controller.myScores(JAM_ID, GAME_ID, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsKey("scores"); + assertThat(response.getBody()).containsKey("criteria"); + verify(jamScoresMapper).listByJudge(JAM_ID, GAME_ID, JUDGE_ID); + } + + // ==== summary: 노출 게이트 ==== + + @Test + void summaryReturns404WhenJamMissing() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + when(jamsMapper.getById(JAM_ID)).thenReturn(null); + + ResponseEntity> response = controller.summary(JAM_ID, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + verify(jamScoreStatsMapper, never()).listStatsByJam(anyLong()); + } + + @Test + void summaryReturns403WhenEvalInProgressAndNotManager() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + when(jamsMapper.getById(JAM_ID)).thenReturn(evalJam()); + when(permissionGate.has(session, GAME_JAM_MANAGE)).thenReturn(false); + + ResponseEntity> response = controller.summary(JAM_ID, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + verify(jamScoreStatsMapper, never()).listStatsByJam(anyLong()); + } + + @Test + void summaryReturnsStatsWhenClosed() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + when(jamsMapper.getById(JAM_ID)).thenReturn(closedJam()); + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of()); + + ResponseEntity> response = controller.summary(JAM_ID, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsKey("stats"); + verify(jamScoreStatsMapper).listStatsByJam(JAM_ID); + } + + @Test + void summaryReturnsStatsWhenManagerDuringEval() { + JamScoringController controller = controller(); + MockHttpSession session = judgeSession(JUDGE_ID); + when(jamsMapper.getById(JAM_ID)).thenReturn(evalJam()); + when(permissionGate.has(session, GAME_JAM_MANAGE)).thenReturn(true); + when(jamScoreStatsMapper.listStatsByJam(JAM_ID)).thenReturn(List.of()); + + ResponseEntity> response = controller.summary(JAM_ID, session); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).containsKey("stats"); + verify(jamScoreStatsMapper).listStatsByJam(JAM_ID); + } + + // ==== helpers ==== + + private JamScoringController controller() { + return new JamScoringController( + jamsMapper, + jamEntriesMapper, + jamCriteriaMapper, + jamScoresMapper, + jamScoreStatsMapper, + jamRoleGate, + permissionGate + ); + } + + /** submitScores 게이트 3~7(자기출품작 제외)을 통과시킨다. */ + private void passAllGates(MockHttpSession session) { + when(jamRoleGate.isJudge(session, JAM_ID)).thenReturn(true); + when(jamsMapper.getById(JAM_ID)).thenReturn(evalJam()); + when(jamEntriesMapper.exists(JAM_ID, GAME_ID)).thenReturn(true); + when(jamRoleGate.isOwnEntry(JAM_ID, GAME_ID, JUDGE_ID)).thenReturn(false); + } + + 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; + } + + private JamCriterionData criterion(String key, int sortOrder) { + JamCriterionData data = new JamCriterionData(); + data.setCriterionKey(key); + data.setDisplayName(key); + data.setSortOrder(sortOrder); + data.setWeight(BigDecimal.ONE); + return data; + } + + private Map scoresBody(String criterionKey, int score) { + return Map.of("scores", List.of(Map.of("criterionKey", criterionKey, "score", score))); + } + + private MockHttpSession judgeSession(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; + } +} diff --git a/src/test/java/com/pandoli365/bibimbap/jam/JamEvalWindowTest.java b/src/test/java/com/pandoli365/bibimbap/jam/JamEvalWindowTest.java new file mode 100644 index 0000000..a0b24b3 --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/jam/JamEvalWindowTest.java @@ -0,0 +1,73 @@ +package com.pandoli365.bibimbap.jam; + +import com.pandoli365.bibimbap.data.JamData; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +class JamEvalWindowTest { + + private static final OffsetDateTime BASE = OffsetDateTime.parse("2026-06-24T12:00:00+09:00"); + private static final OffsetDateTime START = BASE.minusHours(1); + private static final OffsetDateTime END = BASE.plusHours(1); + + private static JamData jam(String status, OffsetDateTime start, OffsetDateTime end) { + JamData jam = new JamData(); + jam.setStatus(status); + jam.setEvalStartAt(start); + jam.setEvalEndAt(end); + return jam; + } + + @Test + void openWhenEvalAndWithinWindow() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", START, END), BASE)).isTrue(); + } + + @Test + void openAtStartBoundary() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", START, END), START)).isTrue(); + } + + @Test + void openAtEndBoundary() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", START, END), END)).isTrue(); + } + + @Test + void closedWhenBeforeStart() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", START, END), START.minusSeconds(1))).isFalse(); + } + + @Test + void closedWhenAfterEnd() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", START, END), END.plusSeconds(1))).isFalse(); + } + + @Test + void closedWhenStatusNotEval() { + assertThat(JamEvalWindow.isOpen(jam("RECRUIT", START, END), BASE)).isFalse(); + } + + @Test + void closedWhenEvalStartNull() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", null, END), BASE)).isFalse(); + } + + @Test + void closedWhenEvalEndNull() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", START, null), BASE)).isFalse(); + } + + @Test + void closedWhenJamNull() { + assertThat(JamEvalWindow.isOpen(null, BASE)).isFalse(); + } + + @Test + void closedWhenNowNull() { + assertThat(JamEvalWindow.isOpen(jam("EVAL", START, END), null)).isFalse(); + } +}