feat(hub): W3-4 메인 허브 keyset 페이징 + 진행중 잼 배너 (단계1+2 통합)
- keyset 페이징: GamesMapper.listVisibleKeyset/searchVisibleKeyset 신규 — 커서 (sort_order,created_at,id) 3-튜플 OR 분해(혼합방향 ASC/DESC/DESC), LIMIT #{limit}, #{} only. 기존 getVisibleGames/searchVisibleGames 보존(회귀 0)
- WebMvcController: cursor 파라미터 + nextCursor 산정 + parseCursor/encodeCursor(형식오류→첫페이지 폴백, throw 0) + HubCursor record + HUB_PAGE_SIZE=24. 검색/비검색 동일 커서 규약(공통 헬퍼)
- 진행중 잼 배너(단계2): JamsMapper.listActive/countActive 신규(status IN RECRUIT/DEV/EVAL, is_visible). 최신 1건 + "외 N-1개" 라벨, N=0 미렌더. CTA → W3-1 /games/search?jam={slug}
- index.jsp: 더보기 링크(hasNextPage, q+cursor URLEncoder) + 잼 배너(title HtmlUtils.htmlEscape, slug URLEncoder). 기존 렌더루프+W3-1 태그 UI 보존
- docs/games-hub-ddl.sql: idx_games_visible_keyset 멱등(games 컬럼 0변경) + schema.sql 동기
- BibimbapApplicationTests 무변경(JamsMapper/GamesMapper @MockBean W2-1 기등록 재사용)
검증: 컨테이너 ./mvnw -o test 243/243 GREEN(신규 8: WebMvcControllerTest), 회귀 0. L2 격리 throwaway DB contract PASS(혼합방향 keyset 경계 무중복/무누락·동일 sort_order/created_at tie-break·검색 keyset·잼 status 필터). 정렬키 5곳 동일·${} 0. 실 dev DB 무접촉.
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
35f1dc3de9
commit
e28fa60ca6
|
|
@ -105,6 +105,9 @@ ALTER SEQUENCE "games_id_seq" OWNED BY "games"."id";
|
|||
CREATE INDEX IF NOT EXISTS "idx_games_visible_order"
|
||||
ON "games" ("is_visible", "is_delete", "sort_order", "created_at" DESC, "id" DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
|
||||
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- game_comments (비권위 복원본 — 매퍼 존재하나 컨트롤러 미연결)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
-- W3-4 게임 허브 keyset 페이징 성능 인덱스. games 컬럼 변경 0(인덱스만). 멱등.
|
||||
-- db/apply-local-ddl.sh 글롭 docs/*-ddl.sql 자동 적용 + db/schema.sql 동기 사본.
|
||||
|
||||
-- 정렬키 = sort_order ASC, created_at DESC, id DESC (GamesMapper.getVisibleGames 와 동일)
|
||||
-- 인덱스 없이도 keyset 정합은 WHERE 비교가 보장하며, 인덱스는 깊은 페이지/대량 seek 최적화용이다.
|
||||
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
|
||||
ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
import jakarta.servlet.RequestDispatcher;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpSession;
|
||||
|
|
@ -13,6 +16,10 @@ import org.springframework.web.bind.annotation.RequestParam;
|
|||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -20,10 +27,14 @@ import java.util.List;
|
|||
@Controller
|
||||
public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
||||
|
||||
private final GamesMapper gamesMapper;
|
||||
private static final int HUB_PAGE_SIZE = 24;
|
||||
|
||||
public WebMvcController(GamesMapper gamesMapper) {
|
||||
private final GamesMapper gamesMapper;
|
||||
private final JamsMapper jamsMapper;
|
||||
|
||||
public WebMvcController(GamesMapper gamesMapper, JamsMapper jamsMapper) {
|
||||
this.gamesMapper = gamesMapper;
|
||||
this.jamsMapper = jamsMapper;
|
||||
}
|
||||
|
||||
@RequestMapping("/error")
|
||||
|
|
@ -50,8 +61,9 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
}
|
||||
|
||||
@GetMapping("/")
|
||||
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query) {
|
||||
return indexModelAndView(query);
|
||||
public ModelAndView indexView(@RequestParam(name = "q", required = false) String query,
|
||||
@RequestParam(name = "cursor", required = false) String cursor) {
|
||||
return indexModelAndView(query, cursor);
|
||||
}
|
||||
|
||||
@GetMapping("/{pageName}")
|
||||
|
|
@ -95,22 +107,80 @@ public class WebMvcController implements WebMvcConfigurer, ErrorController {
|
|||
mv.setViewName("operation-policy");
|
||||
break;
|
||||
default:
|
||||
return indexModelAndView(query);
|
||||
return indexModelAndView(query, null);
|
||||
}
|
||||
|
||||
return mv;
|
||||
}
|
||||
|
||||
private ModelAndView indexModelAndView(String query) {
|
||||
private ModelAndView indexModelAndView(String query, String cursor) {
|
||||
String normalizedQuery = query == null ? "" : query.trim();
|
||||
HubCursor parsed = parseCursor(cursor); // null = 첫 페이지
|
||||
Integer cSort = parsed == null ? null : parsed.sortOrder();
|
||||
OffsetDateTime cCreated = parsed == null ? null : parsed.createdAt();
|
||||
Long cId = parsed == null ? null : parsed.id();
|
||||
|
||||
List<GameData> rows = normalizedQuery.isBlank()
|
||||
? gamesMapper.listVisibleKeyset(cSort, cCreated, cId, HUB_PAGE_SIZE + 1)
|
||||
: gamesMapper.searchVisibleKeyset(normalizedQuery, cSort, cCreated, cId, HUB_PAGE_SIZE + 1);
|
||||
|
||||
String nextCursor = null;
|
||||
if (rows.size() > HUB_PAGE_SIZE) {
|
||||
rows = new ArrayList<>(rows.subList(0, HUB_PAGE_SIZE));
|
||||
GameData last = rows.get(HUB_PAGE_SIZE - 1);
|
||||
nextCursor = encodeCursor(last);
|
||||
}
|
||||
|
||||
ModelAndView mv = new ModelAndView("index");
|
||||
mv.addObject("games", normalizedQuery.isBlank()
|
||||
? gamesMapper.getVisibleGames()
|
||||
: gamesMapper.searchVisibleGames(normalizedQuery));
|
||||
mv.addObject("games", rows);
|
||||
mv.addObject("searchQuery", normalizedQuery);
|
||||
mv.addObject("nextCursor", nextCursor); // nullable — JSP 가 null 시 더보기 미표시
|
||||
|
||||
// 진행중 잼 배너 (activeCount==0 → attr 미주입 → 배너 미렌더)
|
||||
int activeCount = jamsMapper.countActive();
|
||||
if (activeCount > 0) {
|
||||
List<JamData> active = jamsMapper.listActive(1);
|
||||
if (!active.isEmpty()) {
|
||||
mv.addObject("activeJam", active.get(0));
|
||||
mv.addObject("activeJamCount", activeCount);
|
||||
}
|
||||
}
|
||||
return mv;
|
||||
}
|
||||
|
||||
private String encodeCursor(GameData last) {
|
||||
if (last == null || last.getSortOrder() == null
|
||||
|| last.getCreatedAt() == null || last.getId() == null) {
|
||||
return null;
|
||||
}
|
||||
return last.getSortOrder() + "_"
|
||||
+ last.getCreatedAt().toInstant().toEpochMilli() + "_"
|
||||
+ last.getId();
|
||||
}
|
||||
|
||||
// "10_1718000000000_57" → HubCursor | null. 사용자 입력 신뢰 금지 — 형식오류는 첫 페이지 폴백.
|
||||
private HubCursor parseCursor(String cursor) {
|
||||
if (cursor == null || cursor.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = cursor.split("_");
|
||||
if (parts.length != 3) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
int sortOrder = Integer.parseInt(parts[0]);
|
||||
long millis = Long.parseLong(parts[1]);
|
||||
long id = Long.parseLong(parts[2]);
|
||||
OffsetDateTime createdAt = OffsetDateTime.ofInstant(
|
||||
Instant.ofEpochMilli(millis), ZoneOffset.UTC);
|
||||
return new HubCursor(sortOrder, createdAt, id);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private record HubCursor(Integer sortOrder, OffsetDateTime createdAt, Long id) {}
|
||||
|
||||
private boolean isLoggedIn(HttpSession session) {
|
||||
return session != null && session.getAttribute("userId") != null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -260,4 +260,84 @@ public interface GamesMapper {
|
|||
</script>
|
||||
""")
|
||||
List<GameData> searchGamesAdvanced(SearchCriteria c);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT
|
||||
g.id,
|
||||
g.user_id AS userId,
|
||||
g.name,
|
||||
u.display_name AS creator,
|
||||
g.creator_note AS creatorNote,
|
||||
g.git_url AS gitUrl,
|
||||
g.webgl_path AS webglPath,
|
||||
g.thumbnail_url AS thumbnailUrl,
|
||||
g.like_count AS likeCount,
|
||||
g.is_visible AS visible,
|
||||
g.sort_order AS sortOrder,
|
||||
g.created_at AS createdAt,
|
||||
g.updated_at AS updatedAt
|
||||
FROM games g
|
||||
JOIN users u ON u.id = g.user_id
|
||||
WHERE g.is_visible IS NOT FALSE
|
||||
AND g.is_delete IS NOT TRUE
|
||||
AND u.is_delete IS NOT TRUE
|
||||
<if test="cursorSortOrder != null and cursorCreatedAt != null and cursorId != null">
|
||||
AND (
|
||||
g.sort_order > #{cursorSortOrder}
|
||||
OR (g.sort_order = #{cursorSortOrder} AND g.created_at < #{cursorCreatedAt})
|
||||
OR (g.sort_order = #{cursorSortOrder} AND g.created_at = #{cursorCreatedAt} AND g.id < #{cursorId})
|
||||
)
|
||||
</if>
|
||||
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
|
||||
LIMIT #{limit}
|
||||
</script>
|
||||
""")
|
||||
List<GameData> listVisibleKeyset(@Param("cursorSortOrder") Integer cursorSortOrder,
|
||||
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
|
||||
@Param("cursorId") Long cursorId,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT
|
||||
g.id,
|
||||
g.user_id AS userId,
|
||||
g.name,
|
||||
u.display_name AS creator,
|
||||
g.creator_note AS creatorNote,
|
||||
g.git_url AS gitUrl,
|
||||
g.webgl_path AS webglPath,
|
||||
g.thumbnail_url AS thumbnailUrl,
|
||||
g.like_count AS likeCount,
|
||||
g.is_visible AS visible,
|
||||
g.sort_order AS sortOrder,
|
||||
g.created_at AS createdAt,
|
||||
g.updated_at AS updatedAt
|
||||
FROM games g
|
||||
JOIN users u ON u.id = g.user_id
|
||||
WHERE g.is_visible IS NOT FALSE
|
||||
AND g.is_delete IS NOT TRUE
|
||||
AND u.is_delete IS NOT TRUE
|
||||
AND (
|
||||
g.name ILIKE CONCAT('%', #{query}, '%')
|
||||
OR u.display_name ILIKE CONCAT('%', #{query}, '%')
|
||||
OR g.creator_note ILIKE CONCAT('%', #{query}, '%')
|
||||
)
|
||||
<if test="cursorSortOrder != null and cursorCreatedAt != null and cursorId != null">
|
||||
AND (
|
||||
g.sort_order > #{cursorSortOrder}
|
||||
OR (g.sort_order = #{cursorSortOrder} AND g.created_at < #{cursorCreatedAt})
|
||||
OR (g.sort_order = #{cursorSortOrder} AND g.created_at = #{cursorCreatedAt} AND g.id < #{cursorId})
|
||||
)
|
||||
</if>
|
||||
ORDER BY g.sort_order ASC, g.created_at DESC, g.id DESC
|
||||
LIMIT #{limit}
|
||||
</script>
|
||||
""")
|
||||
List<GameData> searchVisibleKeyset(@Param("query") String query,
|
||||
@Param("cursorSortOrder") Integer cursorSortOrder,
|
||||
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
|
||||
@Param("cursorId") Long cursorId,
|
||||
@Param("limit") int limit);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -181,4 +181,36 @@ public interface JamsMapper {
|
|||
AND is_delete IS NOT TRUE
|
||||
""")
|
||||
int softDelete(long jamId);
|
||||
|
||||
@Select("""
|
||||
SELECT id, slug, title, description, status,
|
||||
recruit_start_at AS recruitStartAt,
|
||||
dev_start_at AS devStartAt,
|
||||
eval_start_at AS evalStartAt,
|
||||
eval_end_at AS evalEndAt,
|
||||
discord_url AS discordUrl,
|
||||
prize_info AS prizeInfo,
|
||||
sponsor_info AS sponsorInfo,
|
||||
is_visible AS isVisible,
|
||||
created_by AS createdBy,
|
||||
created_at AS createdAt,
|
||||
updated_at AS updatedAt,
|
||||
is_delete AS isDelete
|
||||
FROM jams
|
||||
WHERE status IN ('RECRUIT', 'DEV', 'EVAL')
|
||||
AND is_visible IS NOT FALSE
|
||||
AND is_delete IS NOT TRUE
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT #{limit}
|
||||
""")
|
||||
List<JamData> listActive(@Param("limit") int limit);
|
||||
|
||||
@Select("""
|
||||
SELECT COUNT(*)
|
||||
FROM jams
|
||||
WHERE status IN ('RECRUIT', 'DEV', 'EVAL')
|
||||
AND is_visible IS NOT FALSE
|
||||
AND is_delete IS NOT TRUE
|
||||
""")
|
||||
int countActive();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,25 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
String nextCursor = "";
|
||||
Object nextCursorAttr = request.getAttribute("nextCursor");
|
||||
if (nextCursorAttr instanceof String) {
|
||||
nextCursor = ((String) nextCursorAttr).trim();
|
||||
}
|
||||
boolean hasNextPage = !nextCursor.isBlank();
|
||||
|
||||
com.pandoli365.bibimbap.data.JamData activeJam = null;
|
||||
Object activeJamAttr = request.getAttribute("activeJam");
|
||||
if (activeJamAttr instanceof com.pandoli365.bibimbap.data.JamData) {
|
||||
activeJam = (com.pandoli365.bibimbap.data.JamData) activeJamAttr;
|
||||
}
|
||||
int activeJamCount = 0;
|
||||
Object activeJamCountAttr = request.getAttribute("activeJamCount");
|
||||
if (activeJamCountAttr instanceof Integer) {
|
||||
activeJamCount = (Integer) activeJamCountAttr;
|
||||
}
|
||||
boolean showJamBanner = activeJamCount > 0 && activeJam != null && activeJam.getSlug() != null && !activeJam.getSlug().isBlank();
|
||||
%>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
|
|
@ -585,6 +604,83 @@
|
|||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.jam-banner {
|
||||
width: min(100%, 48rem);
|
||||
margin: 0 auto 1.25rem;
|
||||
}
|
||||
.jam-banner__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid rgba(232, 165, 75, 0.4);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
.jam-banner__link:hover {
|
||||
border-color: rgba(232, 165, 75, 0.7);
|
||||
box-shadow: 0 6px 16px var(--card-shadow);
|
||||
}
|
||||
.jam-banner__badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem 0.55rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
color: #1a1a1a;
|
||||
background: var(--accent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.jam-banner__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.jam-banner__title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.jam-banner__meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.jam-banner__arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
}
|
||||
.hub-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.hub-more__link {
|
||||
min-height: 3rem;
|
||||
padding: 0 1.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text);
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.hub-more__link:hover {
|
||||
border-color: rgba(232, 165, 75, 0.5);
|
||||
box-shadow: 0 6px 16px var(--card-shadow);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -670,6 +766,23 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<% if (showJamBanner) {
|
||||
String jamTitle = HtmlUtils.htmlEscape(activeJam.getTitle() == null || activeJam.getTitle().isBlank() ? "게임잼" : activeJam.getTitle());
|
||||
String jamSlugParam = java.net.URLEncoder.encode(activeJam.getSlug(), java.nio.charset.StandardCharsets.UTF_8);
|
||||
String jamCountLabel = activeJamCount > 1 ? ("외 " + (activeJamCount - 1) + "개 진행 중") : "진행 중";
|
||||
%>
|
||||
<section class="jam-banner" aria-label="진행 중인 게임잼">
|
||||
<a class="jam-banner__link" href="<%= ctx %>/games/search?jam=<%= jamSlugParam %>">
|
||||
<span class="jam-banner__badge">게임잼</span>
|
||||
<span class="jam-banner__text">
|
||||
<strong class="jam-banner__title"><%= jamTitle %></strong>
|
||||
<span class="jam-banner__meta"><%= jamCountLabel %> · 출품작 보러가기</span>
|
||||
</span>
|
||||
<span class="jam-banner__arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class="card-grid" aria-label="추천 목록">
|
||||
<% if (games.isEmpty()) { %>
|
||||
<% if (searching) { %>
|
||||
|
|
@ -717,6 +830,18 @@
|
|||
}
|
||||
%>
|
||||
</section>
|
||||
|
||||
<% if (hasNextPage) {
|
||||
String moreHref = ctx + "/?";
|
||||
if (searching) {
|
||||
moreHref += "q=" + java.net.URLEncoder.encode(searchQuery, java.nio.charset.StandardCharsets.UTF_8) + "&";
|
||||
}
|
||||
moreHref += "cursor=" + java.net.URLEncoder.encode(nextCursor, java.nio.charset.StandardCharsets.UTF_8);
|
||||
%>
|
||||
<nav class="hub-more" aria-label="더 많은 게임">
|
||||
<a class="hub-more__link" href="<%= moreHref %>">더 보기</a>
|
||||
</nav>
|
||||
<% } %>
|
||||
</main>
|
||||
<jsp:include page="/WEB-INF/views/footer.jsp"/>
|
||||
<script>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
package com.pandoli365.bibimbap.controller;
|
||||
|
||||
import com.pandoli365.bibimbap.data.GameData;
|
||||
import com.pandoli365.bibimbap.data.JamData;
|
||||
import com.pandoli365.bibimbap.mapper.GamesMapper;
|
||||
import com.pandoli365.bibimbap.mapper.JamsMapper;
|
||||
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.web.servlet.ModelAndView;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WebMvcControllerTest {
|
||||
|
||||
private static final int HUB_PAGE_SIZE = 24;
|
||||
private static final int FETCH_LIMIT = HUB_PAGE_SIZE + 1;
|
||||
|
||||
@Mock
|
||||
private GamesMapper gamesMapper;
|
||||
|
||||
@Mock
|
||||
private JamsMapper jamsMapper;
|
||||
|
||||
@Test
|
||||
void firstPage_returnsPageSizeAndNextCursor() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(games(FETCH_LIMIT));
|
||||
|
||||
ModelAndView mv = controller().indexView(null, null);
|
||||
|
||||
assertThat(mv.getViewName()).isEqualTo("index");
|
||||
List<?> games = (List<?>) mv.getModel().get("games");
|
||||
assertThat(games).hasSize(HUB_PAGE_SIZE);
|
||||
|
||||
// nextCursor 는 잘린 24건 중 마지막(index 23) 행 기준이어야 한다.
|
||||
GameData boundary = (GameData) games.get(HUB_PAGE_SIZE - 1);
|
||||
String expected = boundary.getSortOrder() + "_"
|
||||
+ boundary.getCreatedAt().toInstant().toEpochMilli() + "_"
|
||||
+ boundary.getId();
|
||||
assertThat(mv.getModel().get("nextCursor")).isEqualTo(expected);
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstPage_noNextCursor_whenResultsAtOrBelowPageSize() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(games(HUB_PAGE_SIZE));
|
||||
|
||||
ModelAndView mv = controller().indexView(null, null);
|
||||
|
||||
List<?> games = (List<?>) mv.getModel().get("games");
|
||||
assertThat(games).hasSize(HUB_PAGE_SIZE);
|
||||
assertThat(mv.getModel().get("nextCursor")).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondPage_parsesCursorAndPassesToMapper() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.listVisibleKeyset(any(), any(), any(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
controller().indexView(null, "10_1718000000000_57");
|
||||
|
||||
ArgumentCaptor<Integer> sortCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||
ArgumentCaptor<OffsetDateTime> createdCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
|
||||
ArgumentCaptor<Long> idCaptor = ArgumentCaptor.forClass(Long.class);
|
||||
ArgumentCaptor<Integer> limitCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||
verify(gamesMapper).listVisibleKeyset(
|
||||
sortCaptor.capture(), createdCaptor.capture(), idCaptor.capture(), limitCaptor.capture());
|
||||
|
||||
assertThat(sortCaptor.getValue()).isEqualTo(10);
|
||||
assertThat(idCaptor.getValue()).isEqualTo(57L);
|
||||
assertThat(createdCaptor.getValue().toInstant().toEpochMilli()).isEqualTo(1718000000000L);
|
||||
assertThat(limitCaptor.getValue()).isEqualTo(FETCH_LIMIT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedCursor_fallsBackToFirstPage() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
WebMvcController controller = controller();
|
||||
// 각각: 구분자 없음 / 컴포넌트 3 미만 / 숫자 파싱 실패 — 모두 첫 페이지 폴백, 예외 없음.
|
||||
controller.indexView(null, "garbage");
|
||||
controller.indexView(null, "1_2");
|
||||
controller.indexView(null, "x_y_z");
|
||||
|
||||
verify(gamesMapper, never()).listVisibleKeyset(
|
||||
any(Integer.class), any(OffsetDateTime.class), any(Long.class), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchQuery_usesSearchKeysetAndPreservesQuery() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.searchVisibleKeyset(eq("플랫폼"), isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(games(FETCH_LIMIT));
|
||||
|
||||
ModelAndView mv = controller().indexView("플랫폼", null);
|
||||
|
||||
assertThat(mv.getModel().get("searchQuery")).isEqualTo("플랫폼");
|
||||
assertThat(mv.getModel().get("nextCursor")).isNotNull();
|
||||
verify(gamesMapper).searchVisibleKeyset("플랫폼", null, null, null, FETCH_LIMIT);
|
||||
verify(gamesMapper, never()).listVisibleKeyset(any(), any(), any(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void searchQuery_withCursor_passesBoth() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.searchVisibleKeyset(eq("플랫폼"), any(), any(), any(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
controller().indexView("플랫폼", "10_1718000000000_57");
|
||||
|
||||
ArgumentCaptor<String> queryCaptor = ArgumentCaptor.forClass(String.class);
|
||||
ArgumentCaptor<Integer> sortCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||
ArgumentCaptor<OffsetDateTime> createdCaptor = ArgumentCaptor.forClass(OffsetDateTime.class);
|
||||
ArgumentCaptor<Long> idCaptor = ArgumentCaptor.forClass(Long.class);
|
||||
ArgumentCaptor<Integer> limitCaptor = ArgumentCaptor.forClass(Integer.class);
|
||||
verify(gamesMapper).searchVisibleKeyset(
|
||||
queryCaptor.capture(), sortCaptor.capture(), createdCaptor.capture(),
|
||||
idCaptor.capture(), limitCaptor.capture());
|
||||
|
||||
assertThat(queryCaptor.getValue()).isEqualTo("플랫폼");
|
||||
assertThat(sortCaptor.getValue()).isEqualTo(10);
|
||||
assertThat(idCaptor.getValue()).isEqualTo(57L);
|
||||
assertThat(limitCaptor.getValue()).isEqualTo(FETCH_LIMIT);
|
||||
}
|
||||
|
||||
@Test
|
||||
void banner_renderedWhenActiveJamExists() {
|
||||
when(jamsMapper.countActive()).thenReturn(3);
|
||||
when(jamsMapper.listActive(1)).thenReturn(List.of(jam("summer-jam", "여름 게임잼")));
|
||||
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
ModelAndView mv = controller().indexView(null, null);
|
||||
|
||||
assertThat(mv.getModel().get("activeJam")).isNotNull();
|
||||
assertThat(mv.getModel().get("activeJamCount")).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void banner_notRendered_whenNoActiveJam() {
|
||||
when(jamsMapper.countActive()).thenReturn(0);
|
||||
when(gamesMapper.listVisibleKeyset(isNull(), isNull(), isNull(), eq(FETCH_LIMIT)))
|
||||
.thenReturn(new ArrayList<>());
|
||||
|
||||
ModelAndView mv = controller().indexView(null, null);
|
||||
|
||||
assertThat(mv.getModel().get("activeJam")).isNull();
|
||||
assertThat(mv.getModel().get("activeJamCount")).isNull();
|
||||
verify(jamsMapper, never()).listActive(anyInt());
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private WebMvcController controller() {
|
||||
return new WebMvcController(gamesMapper, jamsMapper);
|
||||
}
|
||||
|
||||
private List<GameData> games(int count) {
|
||||
List<GameData> rows = new ArrayList<>(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
// id/sortOrder/createdAt 모두 유일한 값으로 채워 nextCursor 인코딩이 행마다 구분되게 한다.
|
||||
rows.add(game((long) (i + 1), i + 1, 1_700_000_000_000L + i));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private GameData game(long id, int sortOrder, long createdAtMillis) {
|
||||
GameData g = new GameData();
|
||||
g.setId(id);
|
||||
g.setSortOrder(sortOrder);
|
||||
g.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(createdAtMillis), ZoneOffset.UTC));
|
||||
g.setName("game-" + id);
|
||||
return g;
|
||||
}
|
||||
|
||||
private JamData jam(String slug, String title) {
|
||||
JamData j = new JamData();
|
||||
j.setSlug(slug);
|
||||
j.setTitle(title);
|
||||
j.setStatus("RECRUIT");
|
||||
j.setCreatedAt(OffsetDateTime.ofInstant(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC));
|
||||
return j;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue