diff --git a/db/migrations/20260629-game-likes-unique.sql b/db/migrations/20260629-game-likes-unique.sql new file mode 100644 index 0000000..e647523 --- /dev/null +++ b/db/migrations/20260629-game-likes-unique.sql @@ -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; diff --git a/db/schema.sql b/db/schema.sql index 0cf887f..c656d43 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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"; diff --git a/docs/analysis/2026-06-16-project-analysis.md b/docs/analysis/2026-06-16-project-analysis.md index 4acfd71..5146d79 100644 --- a/docs/analysis/2026-06-16-project-analysis.md +++ b/docs/analysis/2026-06-16-project-analysis.md @@ -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 참조. diff --git a/docs/changes/2026-06-29-game-like-server-persistence.md b/docs/changes/2026-06-29-game-like-server-persistence.md new file mode 100644 index 0000000..901b9a5 --- /dev/null +++ b/docs/changes/2026-06-29-game-like-server-persistence.md @@ -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:, likeCount: }`. 실패: 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) diff --git a/docs/changes/index.md b/docs/changes/index.md index d2934ae..ccc7512 100644 --- a/docs/changes/index.md +++ b/docs/changes/index.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. diff --git a/docs/maintenance/index.md b/docs/maintenance/index.md index 295a1d7..684e8d3 100644 --- a/docs/maintenance/index.md +++ b/docs/maintenance/index.md @@ -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 스모크 항목. diff --git a/docs/maintenance/post-deploy-verification-checklist.md b/docs/maintenance/post-deploy-verification-checklist.md index 5bc27b5..adfc9b0 100644 --- a/docs/maintenance/post-deploy-verification-checklist.md +++ b/docs/maintenance/post-deploy-verification-checklist.md @@ -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"` 인지 확인. diff --git a/docs/security/security-remediation-checklist.md b/docs/security/security-remediation-checklist.md index 5e7a0c2..21e1849 100644 --- a/docs/security/security-remediation-checklist.md +++ b/docs/security/security-remediation-checklist.md @@ -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) 확인: 댓글 ``·`` + 리뷰 본문 모두 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. 의존성/세션/운영 하드닝 diff --git a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java index cff2788..401c776 100644 --- a/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java +++ b/src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java @@ -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> 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 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) { diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java index fbd962c..df0325e 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java @@ -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); } diff --git a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java index b23d820..9b5c958 100644 --- a/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java +++ b/src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java @@ -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); } diff --git a/src/main/webapp/WEB-INF/views/game-detail.jsp b/src/main/webapp/WEB-INF/views/game-detail.jsp index c4dd758..3550f61 100644 --- a/src/main/webapp/WEB-INF/views/game-detail.jsp +++ b/src/main/webapp/WEB-INF/views/game-detail.jsp @@ -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']; diff --git a/src/test/java/com/pandoli365/bibimbap/controller/api/GameLikeControllerTest.java b/src/test/java/com/pandoli365/bibimbap/controller/api/GameLikeControllerTest.java new file mode 100644 index 0000000..5fdf7ea --- /dev/null +++ b/src/test/java/com/pandoli365/bibimbap/controller/api/GameLikeControllerTest.java @@ -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> 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> 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 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> 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> 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> 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> 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; + } +}