fix(game): 좋아요 서버 영속화 — 상세 토글이 explore 카운트에 반영되도록
좋아요가 서버에 저장되지 않고 브라우저 localStorage 에만 토글되어
explore/상세가 읽는 games.like_count 컬럼이 갱신되지 않던 버그 수정.
- POST /game/{id}/like 토글 엔드포인트 신설 (CSRF→로그인→존재 게이트, @Transactional)
- GameLikesMapper: findByGameAndUser/deleteByGameAndUser 추가 (addGameLike 재사용)
- GamesMapper: incrementLikeCount/decrementLikeCount(GREATEST 0)/getLikeCount 추가
- game_likes row 변경 + games.like_count ±1 단일 트랜잭션 동기화
- 상세 addGameModel 이 로그인 사용자 기존 좋아요 여부(liked) 모델 주입
- game-detail.jsp 좋아요 버튼을 localStorage→fetch POST 로 교체
- explore 5개 조회 쿼리 무변경 (컬럼 갱신으로 자동 반영)
- game_likes UNIQUE(game_id,user_key) schema 반영 + 마이그레이션 작성 (DB 미적용)
- GameLikeControllerTest 7건 (회귀 AC-1 포함)
검증: L1 PASS (컴파일 + 7/7 + 컨트롤러 회귀 219/219). L2(dev DB) skip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
This commit is contained in:
parent
e1790423e4
commit
fa6a301fb5
|
|
@ -0,0 +1,24 @@
|
|||
-- 게임 좋아요 서버 영속화: (game_id, user_key) 사용자별 1회 좋아요 멱등 보장
|
||||
-- 적용 전 반드시 중복 row 점검 후, 중복이 있으면 제거하고 제약 추가할 것.
|
||||
-- 이 파일은 작성만 한다. DB 적용은 orchestrator 가 사용자 확인 후 수행.
|
||||
|
||||
-- 1) 중복 점검 (적용 전 실행, 결과가 0행이어야 안전)
|
||||
SELECT game_id, user_key, COUNT(*)
|
||||
FROM game_likes
|
||||
GROUP BY game_id, user_key
|
||||
HAVING COUNT(*) > 1;
|
||||
|
||||
-- 2) (중복이 있을 경우) 최신 1건만 남기고 제거 — 점검 결과 확인 후 수동 실행
|
||||
-- DELETE FROM game_likes gl
|
||||
-- USING game_likes keep
|
||||
-- WHERE gl.game_id = keep.game_id
|
||||
-- AND gl.user_key = keep.user_key
|
||||
-- AND gl.id < keep.id
|
||||
-- AND keep.id = (SELECT MAX(id) FROM game_likes x WHERE x.game_id = gl.game_id AND x.user_key = gl.user_key);
|
||||
|
||||
-- 3) UNIQUE 제약 추가
|
||||
ALTER TABLE game_likes
|
||||
ADD CONSTRAINT uq_game_likes_game_user UNIQUE (game_id, user_key);
|
||||
|
||||
-- 롤백:
|
||||
-- ALTER TABLE game_likes DROP CONSTRAINT uq_game_likes_game_user;
|
||||
|
|
@ -249,6 +249,7 @@ COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관
|
|||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- game_likes (비권위 복원본 — 매퍼는 hard delete 사용, is_delete 컬럼 없음)
|
||||
-- uq_game_likes_game_user: (game_id, user_key) UNIQUE 로 사용자별 1회 좋아요 멱등 보장
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE SEQUENCE IF NOT EXISTS "game_likes_id_seq";
|
||||
CREATE TABLE IF NOT EXISTS "game_likes" (
|
||||
|
|
@ -256,7 +257,8 @@ CREATE TABLE IF NOT EXISTS "game_likes" (
|
|||
"game_id" bigint NOT NULL REFERENCES "games" ("id"),
|
||||
"user_key" character varying(200) NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
PRIMARY KEY ("id")
|
||||
PRIMARY KEY ("id"),
|
||||
CONSTRAINT "uq_game_likes_game_user" UNIQUE ("game_id", "user_key")
|
||||
);
|
||||
ALTER SEQUENCE "game_likes_id_seq" OWNED BY "game_likes"."id";
|
||||
|
||||
|
|
|
|||
|
|
@ -168,8 +168,8 @@ Java/Maven 웹앱(Spring Boot 3 MVC + MyBatis + PostgreSQL, JSP 뷰) 전면 read
|
|||
| 회원가입/로그인/로그아웃 | POST `/signup`,`/login`,`/logout` | Users/UserAuthIdentities add/get/update | signup.jsp, login.jsp | users, user_auth_identities |
|
||||
| 프로필 | GET `/profile`(뷰), POST `/profile/nickname`,`/profile/avatar` | getUser, updateUser, getGamesByUserId | profile.jsp | users, user_auth_identities |
|
||||
| 정적 페이지 | GET `/terms`,`/operation-policy` | — | terms.jsp, operation-policy.jsp | — |
|
||||
| 게임 댓글 | **(엔드포인트 없음)** | GameCommentsMapper(미연결) | game-detail.jsp(localStorage) | game_comments(미사용) |
|
||||
| 게임 좋아요 | **(엔드포인트 없음)** | GameLikesMapper(deleteGameLikes 만 호출) | game-detail.jsp(JS baseLikes) | game_likes(쓰기경로 없음) |
|
||||
| 게임 댓글 | (분석시점: 엔드포인트 없음) → **연결 완료** GameCommentController(W3-2) | GameCommentsMapper | game-detail.jsp(fetch 서버) | game_comments |
|
||||
| 게임 좋아요 | (분석시점: 엔드포인트 없음) → **연결 완료** `POST /game/{id}/like`(2026-06-29) | GameLikesMapper(add/find/delete + deleteGameLikes) | game-detail.jsp(fetch 서버) | game_likes(쓰기경로 배선) |
|
||||
|
||||
### 데이터 모델 (매퍼 SQL 에서 역추출 — `확인됨`)
|
||||
|
||||
|
|
@ -204,7 +204,7 @@ Java/Maven 웹앱(Spring Boot 3 MVC + MyBatis + PostgreSQL, JSP 뷰) 전면 read
|
|||
|
||||
## 미해결 / open_questions
|
||||
|
||||
1. 게임 좋아요·댓글의 서버 영속화는 의도된 미완성인가, 폐기된 기능인가? (스키마·매퍼는 완비, 엔드포인트만 없음) — 코드만으로 의도 판별 불가(`미확인`).
|
||||
1. ~~게임 좋아요·댓글의 서버 영속화는 의도된 미완성인가, 폐기된 기능인가?~~ → **해소: 의도된 미완성(절반 구현). 댓글은 W3-2 에서, 좋아요는 2026-06-29(세션 20260629-151216)에 서버 영속화 배선 완료. 상세: [changes/2026-06-29-game-like-server-persistence.md](../changes/2026-06-29-game-like-server-persistence.md), [security/security-remediation-checklist.md](../security/security-remediation-checklist.md) B3.**
|
||||
2. 세션 쿠키 Secure/SameSite·HTTPS 강제는 배포 톰캣/리버스프록시 설정에 의존 — 저장소 코드 밖이라 `미확인`. 프로덕션 설정 확인 필요.
|
||||
3. `provider`/`provider_user_id` 컬럼 = 소셜로그인 확장 예정 스키마인지(현재 email 전용) — `추정`.
|
||||
4. spring-boot 3.5.14-SNAPSHOT 을 의도적으로 SNAPSHOT 유지하는 이유(특정 미릴리스 픽스 의존?) — `미확인`. 로컬 빌드 영향(온라인 의존·오프라인 빌드 불가)은 [usage/local-setup.md](../usage/local-setup.md) §1·§7 참조.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
# 2026-06-29 게임 "좋아요" 서버 영속화
|
||||
|
||||
세션: `20260629-151216` · 검증: L1 PASS (L2 dev DB skip)
|
||||
|
||||
## 한 줄 요약
|
||||
|
||||
게임 좋아요가 서버에 전혀 저장되지 않고 브라우저 `localStorage` 로만 토글되던 버그를 수정. 신규 `POST /game/{id}/like` 토글 엔드포인트를 추가해 `game_likes` row 와 `games.like_count` 비정규화 컬럼을 단일 트랜잭션으로 동기 변경하고, explore 목록은 무변경으로 증가된 컬럼을 읽어 좋아요 수가 반영된다.
|
||||
|
||||
## 배경 / 버그
|
||||
|
||||
- 증상: 게임 상세페이지에서 좋아요를 눌러도 explore(탐색) 페이지에서 좋아요 수가 올라가지 않는다.
|
||||
- 근본원인(seed 가정 반전): "explore 와 상세가 서로 다른 카운트 소스를 읽는 동기화 불일치" 가 아니라 **좋아요가 어떤 영속 저장소에도 쓰이지 않았다.**
|
||||
- 좋아요 토글 서버 엔드포인트가 애초에 부재 (GameController 매핑 0개).
|
||||
- `GameLikesMapper.addGameLike/updateGameLike` 는 호출자 0건 dead code.
|
||||
- 상세 JSP 좋아요 버튼이 서버 호출 없이 `localStorage('bibimbap-game-liked')` 만 토글하고 화면 카운트를 `baseLikes ± 1` 로 로컬 계산.
|
||||
- explore 5개 조회 + 상세 모두 동일하게 `games.like_count` 컬럼을 직접 SELECT — 그 컬럼이 갱신되지 않으므로 영원히 그대로.
|
||||
- 근본원인 상세: `.atp/work-session/20260629-151216/research/like-count-rootcause.md`
|
||||
|
||||
## 사용자 결정
|
||||
|
||||
- D1 (수정 범위 / 카운트 모델): 서버 영속 + `games.like_count` 비정규화 컬럼 동기화. explore 쿼리는 무변경.
|
||||
- D2 (좋아요 주체): 로그인 사용자 기준(`sessionUserId`). 미로그인 시 401 로 거부하고 로그인 유도. 사용자당 게임당 1회(멱등 토글).
|
||||
|
||||
설계 상세: `.atp/work-session/20260629-151216/artifacts/like-persistence-design.md`
|
||||
|
||||
## 변경 내용
|
||||
|
||||
### 신규 엔드포인트: `POST /game/{id}/like`
|
||||
|
||||
- `GameController.toggleLike(id, request, session)` (`@Transactional`).
|
||||
- 게이트 순서: CSRF(403) → 로그인(401) → 게임 존재(404). 기존 컨트롤러 메서드와 동일.
|
||||
- 동작: 현재 좋아요 row 조회 후 분기 — 없으면 추가(`addGameLike` + `incrementLikeCount`, liked=true), 있으면 취소(`deleteByGameAndUser` + `decrementLikeCount`, liked=false). 변경 직후 컬럼을 재조회해 진실값 응답.
|
||||
- 응답(200): `{ status:200, liked:<bool>, likeCount:<int> }`. 실패: 403 / 401 / 404.
|
||||
- 계약 상세는 contracts 가 아닌 본 문서 + 설계 문서에 기록(별도 contract 기준 문서는 미생성 — 아래 concerns 참조).
|
||||
|
||||
### 매퍼
|
||||
|
||||
- `GameLikesMapper`: `findByGameAndUser(gameId, userKey)` / `deleteByGameAndUser(gameId, userKey)` 신규. `addGameLike` 는 기존 재사용. `user_key = String.valueOf(sessionUserId)`.
|
||||
- `GamesMapper`: `incrementLikeCount(id)`(+1) / `decrementLikeCount(id)`(`GREATEST(like_count - 1, 0)` 음수 방어) / `getLikeCount(id)` 신규. 모두 `#{}` 바인딩, `${}` 동적치환 없음.
|
||||
|
||||
### 상세 컨트롤러 / JSP
|
||||
|
||||
- `addGameModel` 이 현재 사용자의 기존 좋아요 여부를 `liked` 모델 속성으로 주입. 카탈로그 폴백 경로는 `liked=false`.
|
||||
- `game-detail.jsp` 좋아요 버튼을 `localStorage` 토글 → `fetch POST`(`window.BibimbapCsrf.headers`)로 교체. 서버 응답 `likeCount`/`liked` 로 `textContent` 갱신. 초기 상태는 서버 주입 `${liked}` 사용.
|
||||
|
||||
### explore 무변경
|
||||
|
||||
- explore 조회 쿼리(`GamesMapper` 의 `g.like_count AS likeCount` SELECT)는 손대지 않음. 컬럼이 갱신되므로 자동 반영. (검증 AC-8: `g.like_count AS likeCount` 패턴 7건 유지)
|
||||
|
||||
### DB 스키마
|
||||
|
||||
- `db/schema.sql`: `game_likes` 에 `UNIQUE(game_id, user_key)` 제약 반영(비권위 복원본 동기).
|
||||
- `db/migrations/20260629-game-likes-unique.sql`: 신규. **운영 DB 미적용** — 중복 row 점검 SELECT 선행 후 적용 대기. 적용 절차는 [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md) 참조.
|
||||
|
||||
## 검증
|
||||
|
||||
- L1 PASS: `test-compile` + `GameLikeControllerTest` 7/7 + `*ControllerTest` 회귀 219/219 GREEN (eclipse-temurin:21-jdk 컨테이너).
|
||||
- L2 (dev DB contract): skip — dev DB 미기동 + harness 미구축. like 매퍼는 INSERT/DELETE/UPDATE int 반환 위주라 camelCase Map alias 케이스폴딩 리스크 낮음.
|
||||
- 검증 상세: `.atp/work-session/20260629-151216/artifacts/verification-result.md`
|
||||
|
||||
## 잔여 / 미수행 (needs_user_verification)
|
||||
|
||||
- UNIQUE 마이그레이션(`db/migrations/20260629-game-likes-unique.sql`) 운영 적용 미수행 — 중복 점검 후 적용 게이트 대기.
|
||||
- 실환경 스모크 미수행: 로그인 → 상세 좋아요 클릭 → explore 카운트 +1 + 상세 초기 `aria-pressed` 상태(AC-7) 수동 확인.
|
||||
- L2 dev DB contract: PostgreSQL dev 기동 후 mapper SQL 실DB 동작 확인.
|
||||
- 기존 `like_count` 컬럼값과 `game_likes` row 수 초기 불일치는 보존(±1 상대 증감, 절대 재계산은 비목표). 선택적 정합 보정 쿼리는 설계 문서 롤아웃 §3 참조.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 보안 체크리스트 B3(좋아요 서버 영속화 연결): [security/security-remediation-checklist.md](../security/security-remediation-checklist.md)
|
||||
- 운영 적용 절차: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md)
|
||||
- 프로젝트 분석(미완성 기능 항목): [analysis/2026-06-16-project-analysis.md](../analysis/2026-06-16-project-analysis.md)
|
||||
- 댓글 서버 영속화(선행 유사 작업): [changes/2026-06-18-w3-2-comments-reviews.md](./2026-06-18-w3-2-comments-reviews.md)
|
||||
|
|
@ -7,4 +7,5 @@
|
|||
- [2026-06-18-w3-2-comments-reviews.md](./2026-06-18-w3-2-comments-reviews.md) — W3-2 댓글/리뷰 분리 구현. game_comments 서버 영속화 전환 + game_reviews 도메인 신설. 신규 API 9개(댓글 C1~C4, 리뷰 R1~R5), DDL 2종, 권한(작성자/운영자), cascade 확장. L1 31테스트 PASS. L3 스모크·DDL 적용은 needs_user_verification. 좋아요는 범위밖.
|
||||
- [2026-06-22-w3-2-comments-reviews-enhancement.md](./2026-06-22-w3-2-comments-reviews-enhancement.md) — W3-2 고도화(코어 위에 얹음). 다축 평점(game_review_axes 6축·육각형 SVG 레이더) + A1~A3 일관성(commentView 통일·작성자 마스킹 QG-2 해결·edited/updatedAt) + B1~B3 목록규모(페이지네이션·본문10자·TextNormalizer) + C1~C6 UX + game_review_stats 집계뷰(C3 재분류 — W2-3 동결 무관 확정). 신규 4파일+변경 13. L1 43/43 GREEN. DDL·L3 스모크 needs_user_verification.
|
||||
- [2026-06-29-w3-w4-features.md](./2026-06-29-w3-w4-features.md) — W3(잔여)+W4 5기능 구현(W3-1·W3-4·W3-3·W3-5·W4) + 후속(20260629-142115) 배지 표시 배선 완료·보안 하드닝 b1/b2. 태그+검색·메인허브·포스팅보드·업로드보안·배지/평판 구현(5 커밋 35f1dc3~305cc73, 최종 L1 347/347 GREEN). 후속: 게임카드 creator 배지 칩·프로필 myBadges 배선(3d10449, L1 352/352) + SsrfSafeFetcher @PostConstruct DNS ttl=30(b1 best-effort) + 업로드 저장루트 static 밖 이전(b2, 9041bb7, L1 353/353). DB DDL 8종 사용자 직접 적용 완료. L3 스모크 및 b2 자산 수동 이전은 배포 후 과제 이월 — 상세: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md).
|
||||
- [2026-06-29-game-like-server-persistence.md](./2026-06-29-game-like-server-persistence.md) — 게임 "좋아요" 서버 영속화. 좋아요가 서버에 안 써지고 localStorage 로만 토글되던 버그 수정. 신규 `POST /game/{id}/like` 토글(@Transactional, CSRF→로그인→존재 게이트) + game_likes row/`games.like_count` 컬럼 단일 트랜잭션 ±1 동기 + 상세 JSP fetch 전환 + explore 무변경(컬럼 자동반영). L1 PASS(GameLikeControllerTest 7/7 + 회귀 219/219), L2 dev DB skip. UNIQUE 마이그레이션(20260629-game-likes-unique.sql) 운영 적용·실환경 스모크는 needs_user_verification — 절차: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md). 좋아요 항목으로 보안 B3 부분 충족.
|
||||
- [2026-06-24-w2-jam-platform.md](./2026-06-24-w2-jam-platform.md) — W2 게임잼 워크스트림 전체(W2-1~6) 구현. 잼 엔티티/라이프사이클(jams/jam_teams/jam_team_members/jam_entries/jam_status_log) + 심사위원 역할(jam_judges 잼스코프) + 평가 동결 스키마(jam_criteria/jam_scores/jam_votes/jam_awards + jam_score_stats VIEW, 평가단위 (jam_id,game_id) 활성 자연키) + 심사 평가(3중게이트 UPSERT 가중집계) + 인기투표(1인1표 UNIQUE·종료후 공개) + 시상 집계(3트랙+가중 GRAND·CLOSED 확정 멱등). GAME_JAM_MANAGE 첫 enforcement 연결·잼스코프 isJudge·평가기간 게이트·CSRF 전수. 6 커밋 ccf1e42~a74bf74. 최종 L1 190/190 GREEN(회귀 0), L2 dev DB contract 전 PASS(격리 throwaway DB). dev DB 마이그레이션·L3 스모크 needs_user_verification.
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@
|
|||
|
||||
## 목록
|
||||
|
||||
- [post-deploy-verification-checklist.md](./post-deploy-verification-checklist.md) — 배포 후 검증 체크리스트(L3 deferred). W3/W4 + 하드닝 b1/b2 배포 후 수동 확인 항목: 검색/허브/포스팅/업로드/배지/SSRF ttl 런타임 반영 + b2 자산 수동 이전 절차(user 8 프로필 `~/.bibimbap/uploads/profile/8/`). DB DDL 8종 적용 완료(사용자 직접 적용) 기록 포함.
|
||||
- [post-deploy-verification-checklist.md](./post-deploy-verification-checklist.md) — 배포 후 검증 체크리스트(L3 deferred). W3/W4 + 하드닝 b1/b2 배포 후 수동 확인 항목: 검색/허브/포스팅/업로드/배지/SSRF ttl 런타임 반영 + b2 자산 수동 이전 절차(user 8 프로필 `~/.bibimbap/uploads/profile/8/`). DB DDL 8종 적용 완료(사용자 직접 적용) 기록 포함. + 게임 좋아요 영속화(20260629-151216): `game_likes` UNIQUE 마이그레이션 운영 적용 절차(중복 점검 게이트) + 좋아요 L3 스모크 항목.
|
||||
|
|
|
|||
|
|
@ -54,6 +54,33 @@ cp -r src/main/resources/static/profile/8/. ~/.bibimbap/uploads/profile/8/
|
|||
|
||||
---
|
||||
|
||||
## §게임 좋아요 UNIQUE 마이그레이션 (세션 20260629-151216, DB 미적용)
|
||||
|
||||
게임 좋아요 서버 영속화([changes/2026-06-29-game-like-server-persistence.md](../changes/2026-06-29-game-like-server-persistence.md))에서 `game_likes (game_id, user_key)` 중복을 막는 UNIQUE 제약을 `db/migrations/20260629-game-likes-unique.sql` 로 추가했으나 **운영 DB 에는 아직 적용하지 않았다.** 제약이 없으면 동시 더블클릭 시 중복 좋아요 row 가 생길 수 있다(애플리케이션 레벨 select-then-act + 단일 트랜잭션으로 1차 방어하나 DB 보증은 별도).
|
||||
|
||||
**적용 절차 (중복 점검 게이트 선행 필수):**
|
||||
|
||||
```sql
|
||||
-- 1) 중복 row 점검 — 결과가 0건이어야 바로 ALTER 가능
|
||||
SELECT game_id, user_key, COUNT(*) FROM game_likes
|
||||
GROUP BY game_id, user_key HAVING COUNT(*) > 1;
|
||||
|
||||
-- 2) (중복이 있으면) 중복 제거 후 진행. 없으면 곧장 제약 추가
|
||||
ALTER TABLE game_likes ADD CONSTRAINT uq_game_likes_game_user UNIQUE (game_id, user_key);
|
||||
```
|
||||
|
||||
- 롤백: `ALTER TABLE game_likes DROP CONSTRAINT uq_game_likes_game_user;`
|
||||
- (선택) 초기 정합 보정 — 기존 `like_count` 컬럼과 row 수 불일치 교정이 필요할 때만. 시드/레거시 카운트를 보존하려면 실행하지 않는다(본 기능 비목표):
|
||||
```sql
|
||||
UPDATE games g SET like_count = COALESCE((SELECT COUNT(*) FROM game_likes gl WHERE gl.game_id = g.id), 0);
|
||||
```
|
||||
|
||||
완료 후 체크:
|
||||
- [ ] 중복 점검 SELECT 0건 확인
|
||||
- [ ] `uq_game_likes_game_user` UNIQUE 제약 적용
|
||||
|
||||
---
|
||||
|
||||
## L3 런타임 스모크 체크리스트
|
||||
|
||||
WAR 기동 후 아래 기능을 수동으로 확인한다.
|
||||
|
|
@ -94,6 +121,15 @@ WAR 기동 후 아래 기능을 수동으로 확인한다.
|
|||
- [ ] 리뷰 작성자 배지 칩 — 게임 상세 리뷰 목록에서 리뷰어 배지 칩 표시
|
||||
- [ ] **프로필 myBadges 표시** (이번 세션 신규 — `profile.jsp` 본인 배지 주입)
|
||||
|
||||
### 5-1. 게임 좋아요 서버 영속화 (세션 20260629-151216 신규)
|
||||
|
||||
> 선행: 위 §게임 좋아요 UNIQUE 마이그레이션 적용 권장(미적용 상태로도 기능 동작은 가능).
|
||||
|
||||
- [ ] 로그인 → 게임 상세 좋아요 클릭 → explore 페이지에서 해당 게임 카운트 +1 반영 확인 (AC-1 핵심 버그)
|
||||
- [ ] 같은 게임 두 번째 클릭 → 좋아요 취소(카운트 원복, game_likes row 0건) (AC-2 멱등 토글)
|
||||
- [ ] 좋아요한 게임 상세 재진입(다른 브라우저/시크릿창) → 버튼 `aria-pressed="true"` 초기 렌더 (AC-7, 서버 진실 — localStorage 무관)
|
||||
- [ ] 미로그인 상태 좋아요 클릭 → 401 + 로그인 유도(상태변경 0)
|
||||
|
||||
### 6. 보안 하드닝 b1 — SSRF DNS 캐시 TTL
|
||||
|
||||
- [ ] **★SSRF ttl 런타임 반영 확인**: 앱 기동 후 `java.security.Security.getProperty("networkaddress.cache.ttl")` 값이 `"30"` 인지 확인.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
| --- | --- | --- | --- |
|
||||
| B1 | P1 | login/signup CSRF 검증 추가 | MED, 완료 |
|
||||
| B2 | P2 | 프로토타입 dead code 제거 | LOW-MED |
|
||||
| B3 | P2 | 좋아요/댓글 서버 영속화 연결 | MED, 기능 무결성 |
|
||||
| B3 | P2 | 좋아요/댓글 서버 영속화 연결 | MED, 기능 무결성. 댓글 완료(W3-2) + 좋아요 완료(20260629-151216, UNIQUE 마이그레이션 운영 적용 대기) |
|
||||
| B4 | P2 | 의존성/세션/운영 하드닝 | MED |
|
||||
|
||||
## B1. login/signup CSRF 검증 추가 (MED)
|
||||
|
|
@ -90,21 +90,21 @@
|
|||
- 게임 삭제 시 댓글/좋아요 데이터 정리 로직도 있다. `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:243`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:244`
|
||||
- 현재 UI는 좋아요와 댓글을 `localStorage`에만 저장한다. `src/main/webapp/WEB-INF/views/game-detail.jsp:812`, `src/main/webapp/WEB-INF/views/game-detail.jsp:830`, `src/main/webapp/WEB-INF/views/game-detail.jsp:913`, `src/main/webapp/WEB-INF/views/game-detail.jsp:928`
|
||||
|
||||
의도 확인 (댓글 결정 완료 / 좋아요 미결 유지):
|
||||
의도 확인 (댓글 결정 완료 / 좋아요 결정 완료):
|
||||
|
||||
- [hold] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. ← **좋아요는 범위 밖, 미결 유지.**
|
||||
- [x] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. → **로그인 사용자만(`sessionUserId`). `game_likes.user_key = String.valueOf(userId)`. 미로그인 401 로그인 유도. 사용자당 게임당 1회(멱등 토글).** 세션 20260629-151216 D2 확정.
|
||||
- [x] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. → **로그인 사용자만(서버 영속화, session userId 귀속). 기존 닉네임 레코드는 user_id=NULL 보존(비파괴, QG-2).** W3-2 세션(20260618-104034) 확정.
|
||||
- [x] 기존 localStorage 댓글을 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. → **비마이그레이션(신규 서버 데이터로만 전환).** 기존 localStorage 댓글 소멸. W3-2 세션 확정.
|
||||
- [hold] 기존 localStorage 좋아요 처리 방침 — **좋아요는 범위 밖, 미결 유지.**
|
||||
- [x] 기존 localStorage 좋아요 처리 방침 → **비마이그레이션. JSP 가 더 이상 `localStorage('bibimbap-game-liked')` 를 읽지 않으므로 잔존 키는 무해(정리 불필요).** 세션 20260629-151216.
|
||||
|
||||
체크리스트:
|
||||
|
||||
> **좋아요 항목(101~104)은 범위 밖 — 미충족 유지. 댓글 항목만 W3-2에서 충족.**
|
||||
> **좋아요 항목은 세션 20260629-151216 에서 충족. 단 `game_likes` UNIQUE 제약은 마이그레이션 운영 적용 대기(아래).**
|
||||
|
||||
- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. ← **좋아요: 미착수(범위 밖)**
|
||||
- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다. ← **좋아요: 미착수(범위 밖)**
|
||||
- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. ← **좋아요: 미착수(범위 밖)**
|
||||
- [ ] `games.like_count` 증감은 race condition 없이 처리한다. ← **좋아요: 미착수(범위 밖)**
|
||||
- [x] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. → **`POST /game/{id}/like` 토글(@Transactional). 게이트 CSRF(403)→로그인(401)→존재(404).**
|
||||
- [x] 좋아요 추가/취소는 CSRF 검증을 적용한다. → **`CsrfTokens.isValid(request)` 선행(403). 회귀 테스트 AC-4 PASS.**
|
||||
- [~] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. → **애플리케이션 레벨 select-then-act + 단일 트랜잭션으로 1차 방어. DB UNIQUE(game_id,user_key) 제약은 `db/schema.sql` 반영 + `db/migrations/20260629-game-likes-unique.sql` 신규, 운영 DB 미적용(중복 점검 후 적용 게이트 — [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md)).**
|
||||
- [x] `games.like_count` 증감은 race condition 없이 처리한다. → **`incrementLikeCount`(`SET like_count = like_count + 1`)/`decrementLikeCount`(`GREATEST(like_count - 1, 0)`) DB 원자 UPDATE + row 변경과 동일 `@Transactional` 단위. 클라이언트 ±1 추정 제거(토글 직후 컬럼 재조회 응답).**
|
||||
- [x] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. → **초기 모델 주입 가능 + 별도 GET C1(`GET /game/{id}/comments`) fetch 방식 채택.** GameCommentController C1 구현 완료(20260618-104034).
|
||||
- [x] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. → **C2 `POST /game/{id}/comments`: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용.** L1 12건 PASS(20260618-104034).
|
||||
- [x] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403.** L1 PASS(20260618-104034).
|
||||
|
|
@ -114,12 +114,12 @@
|
|||
|
||||
완료 조건:
|
||||
|
||||
- [x] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L3 스모크(20260622-170857) 확인 — 댓글/리뷰 작성 후 새로고침 유지 + 쿠키 없는(=다른 브라우저/시크릿 동치) GET `/game/3/comments`·`/reviews` 가 서버 데이터를 반환(localStorage 비의존 증명).** 좋아요: localStorage 유지(범위 밖, 미충족).
|
||||
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖.
|
||||
- [x] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L3 스모크(20260622-170857) 확인. 좋아요: 서버 영속화 완료(20260629-151216) — `game_likes` row + `games.like_count` 컬럼 저장. 상세 초기 `liked` 는 서버 모델 주입(localStorage 비의존). 실환경 스모크(AC-1/AC-7)는 needs_user_verification([maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md)).**
|
||||
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요: `POST /game/{id}/like` CSRF 게이트(403) 회귀 테스트 AC-4 PASS(20260629-151216).
|
||||
- [x] XSS payload 댓글이 스크립트로 실행되지 않는다. → **L3 브라우저 스모크(20260622-170857) 확인: 댓글 `<img src=x onerror=alert(1)>`·`<script>alert(2)</script>` + 리뷰 본문 모두 DB raw 저장, 클라 textContent 렌더(game-detail.jsp:1840 댓글/:2136 리뷰) → 텍스트 노드로만 표시(img/script 미생성), alert/confirm/prompt 0회 발화.**
|
||||
- [x] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → **GameController.deleteGame에 softDeleteGameReviews 추가.** 댓글 soft-delete도 기존 로직 확인. L1 PASS.
|
||||
|
||||
> 구현 이력 상세: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md)
|
||||
> 구현 이력 상세: 댓글 — [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md) / 좋아요 — [changes/2026-06-29-game-like-server-persistence.md](../changes/2026-06-29-game-like-server-persistence.md)
|
||||
|
||||
## B4. 의존성/세션/운영 하드닝
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
package com.pandoli365.bibimbap.controller.api;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.GameLikeData;
|
||||
import com.pandoli365.bibimbap.game.GameCatalog;
|
||||
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameLikesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
|
|
@ -36,6 +38,7 @@ public class GameController {
|
|||
private final GameCommentsMapper gameCommentsMapper;
|
||||
private final GameReviewsMapper gameReviewsMapper;
|
||||
private final GameViewsMapper gameViewsMapper;
|
||||
private final GameLikesMapper gameLikesMapper;
|
||||
private final GameAssetCleanupService assetCleanupService;
|
||||
private final com.pandoli365.bibimbap.badge.ReputationService reputationService;
|
||||
|
||||
|
|
@ -46,12 +49,14 @@ public class GameController {
|
|||
GameCommentsMapper gameCommentsMapper,
|
||||
GameReviewsMapper gameReviewsMapper,
|
||||
GameViewsMapper gameViewsMapper,
|
||||
GameLikesMapper gameLikesMapper,
|
||||
GameAssetCleanupService assetCleanupService,
|
||||
com.pandoli365.bibimbap.badge.ReputationService reputationService) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.gameCommentsMapper = gameCommentsMapper;
|
||||
this.gameReviewsMapper = gameReviewsMapper;
|
||||
this.gameViewsMapper = gameViewsMapper;
|
||||
this.gameLikesMapper = gameLikesMapper;
|
||||
this.assetCleanupService = assetCleanupService;
|
||||
this.reputationService = reputationService;
|
||||
}
|
||||
|
|
@ -163,6 +168,7 @@ public class GameController {
|
|||
model.addAttribute("reviews", List.of());
|
||||
model.addAttribute("currentUserId", sessionUserId(session));
|
||||
model.addAttribute("userRole", (String) session.getAttribute("role"));
|
||||
model.addAttribute("liked", false);
|
||||
return "game-detail";
|
||||
}
|
||||
|
||||
|
|
@ -300,6 +306,48 @@ public class GameController {
|
|||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/game/{id}/like")
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, Object>> toggleLike(@PathVariable("id") long id,
|
||||
HttpServletRequest request,
|
||||
HttpSession session) {
|
||||
// 게이트 순서: CSRF(403) → 로그인(401) → 존재(404)
|
||||
if (!CsrfTokens.isValid(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
|
||||
}
|
||||
Long userId = sessionUserId(session);
|
||||
if (userId == null) {
|
||||
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
|
||||
}
|
||||
GameData game = gamesMapper.getGame(id);
|
||||
if (game == null) {
|
||||
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
String userKey = String.valueOf(userId);
|
||||
GameLikeData existing = gameLikesMapper.findByGameAndUser(id, userKey);
|
||||
boolean liked;
|
||||
if (existing == null) {
|
||||
GameLikeData like = new GameLikeData();
|
||||
like.setGameId(id);
|
||||
like.setUserKey(userKey);
|
||||
gameLikesMapper.addGameLike(like);
|
||||
gamesMapper.incrementLikeCount(id);
|
||||
liked = true;
|
||||
} else {
|
||||
gameLikesMapper.deleteByGameAndUser(id, userKey);
|
||||
gamesMapper.decrementLikeCount(id);
|
||||
liked = false;
|
||||
}
|
||||
int likeCount = gamesMapper.getLikeCount(id);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("status", 200);
|
||||
body.put("liked", liked);
|
||||
body.put("likeCount", likeCount);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private void addGameModel(Model model, GameData game, Long currentUserId) {
|
||||
int likeCount = game.getLikeCount() == null ? 0 : game.getLikeCount();
|
||||
String webglPath = trimToEmpty(game.getWebglPath());
|
||||
|
|
@ -315,6 +363,9 @@ public class GameController {
|
|||
model.addAttribute("webglDeployPath", webglPath);
|
||||
model.addAttribute("owner", currentUserId != null && currentUserId.equals(game.getUserId()));
|
||||
model.addAttribute("currentUserId", currentUserId);
|
||||
boolean liked = currentUserId != null
|
||||
&& gameLikesMapper.findByGameAndUser(game.getId(), String.valueOf(currentUserId)) != null;
|
||||
model.addAttribute("liked", liked);
|
||||
}
|
||||
|
||||
private String webglFrameSrc(String path) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package com.pandoli365.bibimbap.mapper;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameLikeData;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
|
|
@ -41,4 +43,15 @@ public interface GameLikesMapper {
|
|||
WHERE id = #{id}
|
||||
""")
|
||||
int updateGameLike(GameLikeData gameLike);
|
||||
|
||||
// (game_id, user_key) 로 현재 좋아요 row 조회 — 토글 분기/초기 liked 판정에 사용
|
||||
@Select("SELECT id, game_id AS gameId, user_key AS userKey, created_at AS createdAt "
|
||||
+ "FROM game_likes WHERE game_id = #{gameId} AND user_key = #{userKey}")
|
||||
GameLikeData findByGameAndUser(@Param("gameId") long gameId,
|
||||
@Param("userKey") String userKey);
|
||||
|
||||
// 좋아요 취소 시 row 삭제 — 토글-off 경로에 사용
|
||||
@Delete("DELETE FROM game_likes WHERE game_id = #{gameId} AND user_key = #{userKey}")
|
||||
int deleteByGameAndUser(@Param("gameId") long gameId,
|
||||
@Param("userKey") String userKey);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,4 +340,30 @@ public interface GamesMapper {
|
|||
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
|
||||
@Param("cursorId") Long cursorId,
|
||||
@Param("limit") int limit);
|
||||
|
||||
// 좋아요 추가 시 컬럼 +1 (단일 트랜잭션 내) — #{} 바인딩, ${} 금지
|
||||
@Update("""
|
||||
UPDATE games
|
||||
SET like_count = like_count + 1
|
||||
WHERE id = #{id}
|
||||
AND is_delete IS NOT TRUE
|
||||
""")
|
||||
int incrementLikeCount(@Param("id") long id);
|
||||
|
||||
// 좋아요 취소 시 컬럼 -1, 음수 방어(GREATEST 0)
|
||||
@Update("""
|
||||
UPDATE games
|
||||
SET like_count = GREATEST(like_count - 1, 0)
|
||||
WHERE id = #{id}
|
||||
AND is_delete IS NOT TRUE
|
||||
""")
|
||||
int decrementLikeCount(@Param("id") long id);
|
||||
|
||||
// 토글 직후 진실값 응답용 컬럼 재조회
|
||||
@Select("""
|
||||
SELECT like_count
|
||||
FROM games
|
||||
WHERE id = #{id}
|
||||
""")
|
||||
int getLikeCount(@Param("id") long id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1383,34 +1383,10 @@
|
|||
(function () {
|
||||
var ctx = '${pageContext.request.contextPath}';
|
||||
var gameId = ${gameId};
|
||||
var baseLikes = ${likeCount};
|
||||
var LIKE_KEY = 'bibimbap-game-liked';
|
||||
var liked = ${empty liked ? 'false' : liked};
|
||||
var viewerId = ${empty currentUserId ? 'null' : currentUserId};
|
||||
var viewerRole = '${empty userRole ? "" : userRole}';
|
||||
|
||||
function getLikedMap() {
|
||||
try {
|
||||
var raw = localStorage.getItem(LIKE_KEY);
|
||||
var o = raw ? JSON.parse(raw) : {};
|
||||
return o && typeof o === 'object' ? o : {};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setLiked(gameIdStr, liked) {
|
||||
var m = getLikedMap();
|
||||
if (liked) m[gameIdStr] = true;
|
||||
else delete m[gameIdStr];
|
||||
try {
|
||||
localStorage.setItem(LIKE_KEY, JSON.stringify(m));
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
function isLiked(gameIdStr) {
|
||||
return !!getLikedMap()[gameIdStr];
|
||||
}
|
||||
|
||||
function formatCount(n) {
|
||||
return n.toLocaleString('ko-KR');
|
||||
}
|
||||
|
|
@ -1470,19 +1446,59 @@
|
|||
});
|
||||
}
|
||||
|
||||
function syncLike() {
|
||||
var liked = isLiked(gid);
|
||||
function applyLikeUi() {
|
||||
likeBtn.setAttribute('aria-pressed', liked ? 'true' : 'false');
|
||||
likeBtn.setAttribute('aria-label', liked ? '좋아요 취소' : '좋아요');
|
||||
likeCountEl.textContent = formatCount(baseLikes + (liked ? 1 : 0));
|
||||
}
|
||||
|
||||
var pending = false;
|
||||
|
||||
likeBtn.addEventListener('click', function () {
|
||||
var next = !isLiked(gid);
|
||||
setLiked(gid, next);
|
||||
syncLike();
|
||||
if (pending) return;
|
||||
pending = true;
|
||||
fetch(ctx + '/game/' + encodeURIComponent(gid) + '/like', {
|
||||
method: 'POST',
|
||||
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}) : {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
}).then(function (res) {
|
||||
if (res.status === 401) {
|
||||
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||
window.BibimbapModal.alert({ title: '로그인 필요', message: '좋아요는 로그인 후 이용할 수 있습니다.', confirmText: '확인' });
|
||||
} else {
|
||||
alert('좋아요는 로그인 후 이용할 수 있습니다.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
|
||||
window.BibimbapModal.alert({ title: '요청 실패', message: '요청 보안 토큰이 유효하지 않습니다. 새로고침 후 다시 시도해 주세요.', confirmText: '확인' });
|
||||
} else {
|
||||
alert('요청 보안 토큰이 유효하지 않습니다.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (!res.ok) { return null; }
|
||||
return res.json().catch(function () { return null; });
|
||||
}).then(function (data) {
|
||||
if (data && typeof data.liked !== 'undefined') {
|
||||
liked = !!data.liked;
|
||||
applyLikeUi();
|
||||
if (typeof data.likeCount === 'number') {
|
||||
likeCountEl.textContent = formatCount(data.likeCount);
|
||||
}
|
||||
}
|
||||
}).catch(function () {
|
||||
// 네트워크 실패 — 상태 원복(현재 liked 유지). 별도 처리 없음.
|
||||
}).then(function () {
|
||||
pending = false;
|
||||
});
|
||||
syncLike();
|
||||
});
|
||||
applyLikeUi();
|
||||
|
||||
// ===== 공통 헬퍼 (서버 연동 덧글/리뷰) =====
|
||||
var AXIS_KEYS = ['immersion', 'creativity', 'controls', 'completeness', 'sound', 'visual'];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,243 @@
|
|||
package com.pandoli365.bibimbap.controller.api;
|
||||
|
||||
import com.pandoli365.bibimbap.badge.ReputationService;
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.GameLikeData;
|
||||
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameLikesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.security.CsrfTokens;
|
||||
import com.pandoli365.bibimbap.service.GameAssetCleanupService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
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.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class GameLikeControllerTest {
|
||||
|
||||
@Mock
|
||||
private GamesMapper gamesMapper;
|
||||
|
||||
@Mock
|
||||
private GameCommentsMapper gameCommentsMapper;
|
||||
|
||||
@Mock
|
||||
private GameReviewsMapper gameReviewsMapper;
|
||||
|
||||
@Mock
|
||||
private GameViewsMapper gameViewsMapper;
|
||||
|
||||
@Mock
|
||||
private GameLikesMapper gameLikesMapper;
|
||||
|
||||
@Mock
|
||||
private GameAssetCleanupService assetCleanupService;
|
||||
|
||||
@Mock
|
||||
private ReputationService reputationService;
|
||||
|
||||
// ---- AC-1: 미좋아요 → 좋아요 추가, incrementLikeCount 호출 + 응답 likeCount 반영 ----
|
||||
|
||||
@Test
|
||||
void toggleLikeAddsLikeAndIncrementsCount() {
|
||||
GameController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "사용자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameLikesMapper.findByGameAndUser(1L, "7")).thenReturn(null);
|
||||
when(gamesMapper.getLikeCount(1L)).thenReturn(6);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.toggleLike(1L, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("liked", true);
|
||||
assertThat(response.getBody()).containsEntry("likeCount", 6);
|
||||
// 회귀 가드: 컬럼 +1 = incrementLikeCount 호출 + row 추가
|
||||
verify(gameLikesMapper).addGameLike(any());
|
||||
verify(gamesMapper).incrementLikeCount(1L);
|
||||
}
|
||||
|
||||
// ---- AC-2: 이미 좋아요 → 토글 취소, decrementLikeCount 호출, increment 미호출 ----
|
||||
|
||||
@Test
|
||||
void toggleLikeRemovesLikeAndDecrementsCount() {
|
||||
GameController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "사용자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameLikesMapper.findByGameAndUser(1L, "7")).thenReturn(existingLike(1L, "7"));
|
||||
when(gamesMapper.getLikeCount(1L)).thenReturn(5);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.toggleLike(1L, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(response.getBody()).containsEntry("liked", false);
|
||||
assertThat(response.getBody()).containsEntry("likeCount", 5);
|
||||
verify(gameLikesMapper).deleteByGameAndUser(eq(1L), anyString());
|
||||
verify(gamesMapper).decrementLikeCount(1L);
|
||||
verify(gamesMapper, never()).incrementLikeCount(anyLong());
|
||||
}
|
||||
|
||||
// ---- AC-3: 추가 경로에서 game_likes row 생성 + userKey == String.valueOf(userId) ----
|
||||
// created_at 은 DB default 이므로 단위테스트 범위 밖 (mapper insert 시점 자동 채움).
|
||||
|
||||
@Test
|
||||
void toggleLikePersistsRowWithUserKeyEqualToUserIdString() {
|
||||
GameController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "사용자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
|
||||
when(gameLikesMapper.findByGameAndUser(1L, "7")).thenReturn(null);
|
||||
when(gamesMapper.getLikeCount(1L)).thenReturn(1);
|
||||
|
||||
controller.toggleLike(1L, request, session);
|
||||
|
||||
ArgumentCaptor<GameLikeData> captor = ArgumentCaptor.forClass(GameLikeData.class);
|
||||
verify(gameLikesMapper).addGameLike(captor.capture());
|
||||
assertThat(captor.getValue().getGameId()).isEqualTo(1L);
|
||||
assertThat(captor.getValue().getUserKey()).isEqualTo(String.valueOf(7L));
|
||||
}
|
||||
|
||||
// ---- AC-4: CSRF 누락 → 403, mutation 전 차단 ----
|
||||
|
||||
@Test
|
||||
void toggleLikeRejectsMissingCsrfBeforeMutation() {
|
||||
GameController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "사용자");
|
||||
MockHttpServletRequest request = noCsrfPost(session);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.toggleLike(1L, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
assertThat(response.getBody()).containsEntry("status", 403);
|
||||
verifyNoInteractions(gameLikesMapper);
|
||||
verify(gamesMapper, never()).getGame(anyLong());
|
||||
verify(gamesMapper, never()).incrementLikeCount(anyLong());
|
||||
}
|
||||
|
||||
// ---- AC-5: 미로그인 → 401 ----
|
||||
|
||||
@Test
|
||||
void toggleLikeRequiresLogin() {
|
||||
GameController controller = controller();
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
CsrfTokens.getOrCreate(session);
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.toggleLike(1L, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
assertThat(response.getBody()).containsEntry("message", "로그인이 필요합니다.");
|
||||
verifyNoInteractions(gameLikesMapper);
|
||||
verify(gamesMapper, never()).getGame(anyLong());
|
||||
verify(gamesMapper, never()).incrementLikeCount(anyLong());
|
||||
verify(gamesMapper, never()).decrementLikeCount(anyLong());
|
||||
}
|
||||
|
||||
// ---- AC-6a: 게이트 순서 — CSRF 무효 + 미로그인 동시 → 403 우선(401 아님) ----
|
||||
|
||||
@Test
|
||||
void toggleLikePrefersCsrfFailureOverLoginFailure() {
|
||||
GameController controller = controller();
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
CsrfTokens.getOrCreate(session);
|
||||
MockHttpServletRequest request = noCsrfPost(session);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.toggleLike(1L, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
verifyNoInteractions(gameLikesMapper);
|
||||
}
|
||||
|
||||
// ---- AC-6b: 게임 존재 안 함 → 404 ----
|
||||
|
||||
@Test
|
||||
void toggleLikeReturnsNotFoundWhenGameMissing() {
|
||||
GameController controller = controller();
|
||||
MockHttpSession session = loginSession(7L, "USER", "사용자");
|
||||
MockHttpServletRequest request = csrfPost(session);
|
||||
when(gamesMapper.getGame(1L)).thenReturn(null);
|
||||
|
||||
ResponseEntity<Map<String, Object>> response =
|
||||
controller.toggleLike(1L, request, session);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
assertThat(response.getBody()).containsEntry("message", "게임을 찾을 수 없습니다.");
|
||||
verifyNoInteractions(gameLikesMapper);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private GameController controller() {
|
||||
return new GameController(
|
||||
gamesMapper,
|
||||
gameCommentsMapper,
|
||||
gameReviewsMapper,
|
||||
gameViewsMapper,
|
||||
gameLikesMapper,
|
||||
assetCleanupService,
|
||||
reputationService);
|
||||
}
|
||||
|
||||
private GameData game(long id) {
|
||||
GameData g = new GameData();
|
||||
g.setId(id);
|
||||
g.setUserId(1L);
|
||||
return g;
|
||||
}
|
||||
|
||||
private GameLikeData existingLike(long gameId, String userKey) {
|
||||
GameLikeData like = new GameLikeData();
|
||||
like.setId(99L);
|
||||
like.setGameId(gameId);
|
||||
like.setUserKey(userKey);
|
||||
return like;
|
||||
}
|
||||
|
||||
private MockHttpSession loginSession(long userId, String role, String displayName) {
|
||||
MockHttpSession session = new MockHttpSession();
|
||||
session.setAttribute("userId", userId);
|
||||
session.setAttribute("role", role);
|
||||
session.setAttribute("displayName", displayName);
|
||||
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