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:
이정수 2026-06-29 16:09:54 +09:00
parent e1790423e4
commit fa6a301fb5
13 changed files with 534 additions and 49 deletions

View File

@ -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;

View File

@ -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";

View File

@ -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 참조.

View File

@ -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)

View File

@ -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.

View File

@ -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 스모크 항목.

View File

@ -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"` 인지 확인.

View File

@ -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. 의존성/세션/운영 하드닝

View File

@ -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) {

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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'];

View File

@ -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;
}
}