API 목록 페이징 로직 테스트 및 페이지 크기 설정 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 기본 페이지 크기 설정 ApiListProperties 도입
- Controller, 템플릿, JS에 설정값 연결 및 테스트 추가
This commit is contained in:
Rinjae
2026-09-09 12:02:08 +09:00
parent d4bafa706c
commit 1d81681e70
6 changed files with 196 additions and 6 deletions
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
@@ -17,6 +18,7 @@ import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.stereotype.Controller;
@@ -37,6 +39,7 @@ public class ApiController {
private final ApiServiceService apiServiceService;
private final ApiSearchFacade apiSearchFacade;
private final ApiStatusCatalogService apiStatusCatalogService;
private final ApiListProperties apiListProperties;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_TOKEN_API_NAME = "인증";
@@ -88,7 +91,8 @@ public class ApiController {
@SuppressWarnings("unchecked")
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort());
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, listPageable);
model.addAttribute("search", search);
model.addAttribute("services", searchResult.get("services"));
@@ -109,8 +113,8 @@ public class ApiController {
*/
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
int total = apis == null ? 0 : apis.size();
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
int fromIndex = (int) Math.min(pageable.getOffset(), total);
int toIndex = (int) Math.min((long) fromIndex + pageable.getPageSize(), total);
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
return new PageImpl<>(content, pageable, total);
@@ -0,0 +1,35 @@
package com.eactive.apim.portal.apps.apis.service;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/** OPEN API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 공통 페이지 크기. */
@Slf4j
@Service
@RequiredArgsConstructor
public class ApiListProperties {
private static final String PAGE_SIZE_PROPERTY = "api.list.page-size";
private static final int DEFAULT_PAGE_SIZE = 15;
private final PortalPropertyService portalPropertyService;
/** PTL_PROPERTY(Portal / api.list.page-size)를 조회하고, 없으면 기본값으로 생성한다. */
public int getPageSize() {
String value = portalPropertyService.getOrCreateProperty(
"Portal", PAGE_SIZE_PROPERTY, String.valueOf(DEFAULT_PAGE_SIZE),
"API 목록 및 앱/Webhook 신청·수정 API 선택 목록의 페이지당 노출 개수 (양의 정수, 기본 15)");
try {
int size = Integer.parseInt(value == null ? "" : value.trim());
if (size > 0) {
return size;
}
} catch (NumberFormatException ignored) {
// 비어 있거나 정수가 아닌 설정은 기본값을 사용한다.
}
log.warn("{} 값이 올바르지 않음('{}') - 기본값 {} 사용", PAGE_SIZE_PROPERTY, value, DEFAULT_PAGE_SIZE);
return DEFAULT_PAGE_SIZE;
}
}
+3 -2
View File
@@ -8,7 +8,7 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
* - 페이징: #apiPagination (window.API_SELECTOR_PAGE_SIZE 건/페이지, 기본 15) — 카테고리/검색은 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
@@ -22,7 +22,8 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
const configuredPageSize = Number(window.API_SELECTOR_PAGE_SIZE);
const PAGE_SIZE = Number.isInteger(configuredPageSize) && configuredPageSize > 0 ? configuredPageSize : 15;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
@@ -24,7 +24,7 @@
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
클라이언트에서 12건/페이지로 페이징(#apiPagination, api-selector.js PAGE_SIZE) — 전체선택/모달은 페이징과
PTL_PROPERTY(Portal / api.list.page-size, 기본 15) 기준으로 클라이언트 페이징 — 전체선택/모달은 페이징과
무관하게 필터된 전체 목록 기준으로 동작.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
*/-->
@@ -107,6 +107,7 @@
<script th:inline="javascript">
window.API_SELECTOR_SELECTED = /*[[${selectedApis}]]*/ [];
window.API_SELECTOR_LIST_URL = /*[[@{/apis/for_request}]]*/ '/apis/for_request';
window.API_SELECTOR_PAGE_SIZE = /*[[${@apiListProperties.pageSize}]]*/ 15;
</script>
<script th:src="@{/js/api-selector.js}"></script>
</th:block>
@@ -0,0 +1,119 @@
package com.eactive.apim.portal.apps.apis.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.apps.apis.service.ApiSearchFacade;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
import com.eactive.apim.portal.djb.apistatus.service.ApiStatusCatalogService;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.ui.ExtendedModelMap;
@ExtendWith(MockitoExtension.class)
class ApiControllerTest {
@Mock private ApiService apiService;
@Mock private ApiServiceService apiServiceService;
@Mock private ApiSearchFacade apiSearchFacade;
@Mock private ApiStatusCatalogService apiStatusCatalogService;
@Mock private PortalPropertyService portalPropertyService;
private ApiController controller;
private final List<ApiSpecInfoDto> apis = new ArrayList<>();
@BeforeEach
void setUp() {
controller = new ApiController(apiService, apiServiceService, apiSearchFacade,
apiStatusCatalogService, new ApiListProperties(portalPropertyService));
for (int i = 0; i < 32; i++) {
ApiSpecInfoDto api = new ApiSpecInfoDto();
api.setApiId("api-" + i);
apis.add(api);
}
Map<String, Object> result = new HashMap<>();
result.put("apis", apis);
result.put("totalApiCount", apis.size());
result.put("selectedApiCount", apis.size());
when(apiSearchFacade.searchApis(any(ApiGroupSearch.class))).thenReturn(result);
}
@Test
void defaultSizePaginatesByFifteen() {
stubPageSize("15");
Page<?> first = listPage(0);
assertEquals(15, first.getNumberOfElements());
assertEquals(3, first.getTotalPages());
assertEquals(32, first.getTotalElements());
assertEquals(apis.subList(0, 15), first.getContent());
assertEquals(apis.subList(15, 30), listPage(1).getContent());
assertEquals(apis.subList(30, 32), listPage(2).getContent());
}
@Test
void propertyChangesApplyOnNextRequestAndOverrideRequestSize() {
stubPageSize(" 6 ");
Page<?> page = listPage(1);
assertEquals(6, page.getSize());
assertEquals(apis.subList(6, 12), page.getContent());
stubPageSize("20");
assertEquals(apis.subList(20, 32), listPage(1).getContent());
}
@Test
void invalidPropertyFallsBackToFifteen() {
for (String value : new String[] {null, "", " ", "0", "-1", "invalid", "1.5", "2147483648"}) {
stubPageSize(value);
assertEquals(15, listPage(0).getSize(), "설정값: " + value);
}
}
@Test
void largePageOffsetDoesNotOverflow() {
stubPageSize("15");
assertTrue(listPage(Integer.MAX_VALUE - 1).isEmpty());
}
@Test
void emptySearchResultsProduceEmptyPage() {
stubPageSize("15");
apis.clear();
Page<?> page = listPage(0);
assertTrue(page.isEmpty());
assertEquals(0, page.getTotalElements());
}
private void stubPageSize(String value) {
when(portalPropertyService.getOrCreateProperty(
eq("Portal"), eq("api.list.page-size"), eq("15"), anyString())).thenReturn(value);
}
private Page<?> listPage(int pageNumber) {
ExtendedModelMap model = new ExtendedModelMap();
assertEquals("apps/apis/mainApiList", controller.apiList(
new ApiGroupSearch(), PageRequest.of(pageNumber, 10), model));
Page<?> page = (Page<?>) model.get("page");
assertEquals(page.getContent(), model.get("apis"));
return page;
}
}
@@ -1,5 +1,7 @@
package com.eactive.apim.portal.common.compatibility;
import com.eactive.apim.portal.apps.apis.service.ApiListProperties;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -37,6 +39,10 @@ import org.thymeleaf.spring5.view.ThymeleafView;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class ThymeleafBootMvcCompatibilityTest {
@@ -121,6 +127,30 @@ class ThymeleafBootMvcCompatibilityTest {
.contains("회원 메뉴", "관리자 메뉴", ">portal-admin</span>").doesNotContain("로그인 안내"));
}
@Test
void apiSelectorUsesSharedPageSizeForClientAndWebhookRegistrationAndModification() {
PortalPropertyService properties = mock(PortalPropertyService.class);
runner.withBean("apiListProperties", ApiListProperties.class, () -> new ApiListProperties(properties))
.run(context -> {
for (int pageSize : new int[]{15, 6}) {
when(properties.getOrCreateProperty(eq("Portal"), eq("api.list.page-size"),
eq("15"), anyString())).thenReturn(String.valueOf(pageSize));
for (String action : new String[]{"/clients/register/step2", "/clients/modify/step2",
"/webhook/register/step2", "/webhook/modify/step2"}) {
Map<String, Object> model = new HashMap<>();
model.put("apiServices", Collections.emptyList());
model.put("selectedApis", Collections.singletonList("selected-api"));
model.put("formAction", action);
model.put("saveAction", action + "/save");
String html = render(context, "views/fragment/api_selector :: apiSelector", model);
assertThat(html).contains("window.API_SELECTOR_PAGE_SIZE = " + pageSize + ";",
"action=\"/portal" + action + "\"",
"window.API_SELECTOR_SELECTED = [\"selected-api\"];");
}
}
});
}
private String render(WebApplicationContext context, String template, Map<String, Object> model) throws Exception {
context.getServletContext().setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, context);
MockHttpServletRequest request = new MockHttpServletRequest(context.getServletContext());