diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java b/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java index e93fe14..49497ec 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java +++ b/src/main/java/com/eactive/apim/portal/apps/apis/controller/ApiController.java @@ -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 allApis = (List) searchResult.get("apis"); - Page apiPage = slicePage(allApis, pageable); + Pageable listPageable = PageRequest.of(pageable.getPageNumber(), apiListProperties.getPageSize(), pageable.getSort()); + Page apiPage = slicePage(allApis, listPageable); model.addAttribute("search", search); model.addAttribute("services", searchResult.get("services")); @@ -109,8 +113,8 @@ public class ApiController { */ private Page slicePage(List 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 content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex); return new PageImpl<>(content, pageable, total); diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/service/ApiListProperties.java b/src/main/java/com/eactive/apim/portal/apps/apis/service/ApiListProperties.java new file mode 100644 index 0000000..65dff0d --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/apps/apis/service/ApiListProperties.java @@ -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; + } +} diff --git a/src/main/resources/static/js/api-selector.js b/src/main/resources/static/js/api-selector.js index 507083b..e96c8ee 100644 --- a/src/main/resources/static/js/api-selector.js +++ b/src/main/resources/static/js/api-selector.js @@ -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'); diff --git a/src/main/resources/templates/views/fragment/api_selector.html b/src/main/resources/templates/views/fragment/api_selector.html index 93c3578..a6c6bd0 100644 --- a/src/main/resources/templates/views/fragment/api_selector.html +++ b/src/main/resources/templates/views/fragment/api_selector.html @@ -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 @@ diff --git a/src/test/java/com/eactive/apim/portal/apps/apis/controller/ApiControllerTest.java b/src/test/java/com/eactive/apim/portal/apps/apis/controller/ApiControllerTest.java new file mode 100644 index 0000000..31d46c5 --- /dev/null +++ b/src/test/java/com/eactive/apim/portal/apps/apis/controller/ApiControllerTest.java @@ -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 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 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; + } +} diff --git a/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafBootMvcCompatibilityTest.java b/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafBootMvcCompatibilityTest.java index c62dc3e..20e57bd 100644 --- a/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafBootMvcCompatibilityTest.java +++ b/src/test/java/com/eactive/apim/portal/common/compatibility/ThymeleafBootMvcCompatibilityTest.java @@ -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").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 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 model) throws Exception { context.getServletContext().setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, context); MockHttpServletRequest request = new MockHttpServletRequest(context.getServletContext());