API 통계 날짜 선택 로직 및 테스트 추가:
- 최대 40일 범위 제한 및 1년 조회 가능 기간 검증 - Date-range Picker UI 추가 및 서버 동기화 로직 구현 - Controller, DTO 유효성 테스트 및 Playwright 테스트 작성
This commit is contained in:
+8
-3
@@ -52,9 +52,10 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("appList", apiStatisticsService.getAppListByOrg(orgId));
|
||||
|
||||
// 기본 조회 (일별, 7일 전 ~ 오늘, 전체 앱)
|
||||
LocalDate today = LocalDate.now();
|
||||
ApiStatisticsSearchDto searchDto = new ApiStatisticsSearchDto();
|
||||
searchDto.setStartDate(LocalDate.now().minusDays(7));
|
||||
searchDto.setEndDate(LocalDate.now());
|
||||
searchDto.setStartDate(today.minusDays(7));
|
||||
searchDto.setEndDate(today);
|
||||
searchDto.setClientId(null);
|
||||
|
||||
ApiStatisticsResultDto result = apiStatisticsService.getStatistics(orgId, searchDto);
|
||||
@@ -62,6 +63,9 @@ public class ApiStatisticsController {
|
||||
model.addAttribute("details", result.getDetails());
|
||||
model.addAttribute("periods", result.getPeriods());
|
||||
model.addAttribute("searchDto", searchDto);
|
||||
// 브라우저 시간대/시계와 관계없이 서버와 동일한 조회 경계를 적용한다.
|
||||
model.addAttribute("statsMinDate", today.minusYears(1).toString());
|
||||
model.addAttribute("statsMaxDate", today.toString());
|
||||
|
||||
// 월별 선택 가능 월 + 집계 안내 문구 데이터
|
||||
model.addAttribute("availableMonths", apiStatisticsService.getAvailableMonths(orgId));
|
||||
@@ -124,7 +128,8 @@ public class ApiStatisticsController {
|
||||
private String rangeErrorMessage(ApiStatisticsSearchDto searchDto) {
|
||||
return searchDto.isMonthly()
|
||||
? "조회할 월이 올바르지 않습니다."
|
||||
: "조회 기간은 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS + "일까지 가능합니다.";
|
||||
: "조회 기간은 1년 전부터 오늘까지의 날짜 중 최대 " + ApiStatisticsSearchDto.MAX_DATE_RANGE_DAYS
|
||||
+ "일(시작일·종료일 포함)로 선택해주세요.";
|
||||
}
|
||||
|
||||
private PortalOrg getPortalOrg() {
|
||||
|
||||
+9
-4
@@ -15,7 +15,7 @@ import org.springframework.format.annotation.DateTimeFormat;
|
||||
public class ApiStatisticsSearchDto {
|
||||
|
||||
/**
|
||||
* 최대 조회 가능 일수 (일별 모드).
|
||||
* 한 번에 조회 가능한 일수 (일별 모드, 시작일·종료일 포함).
|
||||
*/
|
||||
public static final int MAX_DATE_RANGE_DAYS = 40;
|
||||
|
||||
@@ -59,17 +59,22 @@ public class ApiStatisticsSearchDto {
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜(일별) 범위가 유효한지 검증 (최대 40일).
|
||||
* 1년 전부터 오늘까지의 날짜 중 최대 40일인지 검증 (양 끝 포함).
|
||||
*/
|
||||
public boolean isValidDateRange() {
|
||||
return isValidDateRange(LocalDate.now());
|
||||
}
|
||||
|
||||
boolean isValidDateRange(LocalDate today) {
|
||||
if (startDate == null || endDate == null) {
|
||||
return false;
|
||||
}
|
||||
if (startDate.isAfter(endDate)) {
|
||||
return false;
|
||||
}
|
||||
long daysBetween = ChronoUnit.DAYS.between(startDate, endDate);
|
||||
return daysBetween <= MAX_DATE_RANGE_DAYS;
|
||||
LocalDate earliestDate = today.minusYears(1);
|
||||
return !startDate.isBefore(earliestDate) && !endDate.isAfter(today)
|
||||
&& ChronoUnit.DAYS.between(startDate, endDate) < MAX_DATE_RANGE_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25275,6 +25275,34 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.api-statistics-container .statistics-date-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.api-statistics-container .statistics-date-range:focus-visible {
|
||||
outline: 2px solid #0049B4;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.daterangepicker.statistics-date-picker {
|
||||
z-index: 1100;
|
||||
max-width: calc(100vw - 16px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.daterangepicker.statistics-date-picker .applyBtn {
|
||||
color: #fff;
|
||||
background: #0049B4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.daterangepicker.statistics-date-picker {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: #0049B4;
|
||||
border: none;
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -118,6 +118,36 @@
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.api-statistics-container .statistics-date-range {
|
||||
width: 260px;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #0049B4;
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
// 공통 달력 CSS의 z-index: 0을 이 페이지의 팝업에만 보정한다.
|
||||
.daterangepicker.statistics-date-picker {
|
||||
z-index: 1100;
|
||||
max-width: calc(100vw - 16px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
|
||||
.applyBtn {
|
||||
color: #fff;
|
||||
background: #0049B4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-search {
|
||||
background: #0049B4;
|
||||
border: none;
|
||||
|
||||
@@ -41,10 +41,11 @@
|
||||
<div class="search-filter-row">
|
||||
<!-- 일별: 날짜 범위 -->
|
||||
<div class="date-range-picker" id="dailyFilter">
|
||||
<input type="date" id="startDate" class="date-input"
|
||||
<input type="text" id="statisticsDateRange" class="date-input statistics-date-range"
|
||||
aria-label="일별 조회 기간" title="1년 전부터 오늘까지, 시작일·종료일을 포함해 최대 40일을 선택할 수 있습니다." readonly>
|
||||
<input type="hidden" id="startDate"
|
||||
th:value="${#temporals.format(searchDto.startDate, 'yyyy-MM-dd')}">
|
||||
<span class="date-separator">-</span>
|
||||
<input type="date" id="endDate" class="date-input"
|
||||
<input type="hidden" id="endDate"
|
||||
th:value="${#temporals.format(searchDto.endDate, 'yyyy-MM-dd')}">
|
||||
</div>
|
||||
|
||||
@@ -57,7 +58,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-search" id="btnSearch">
|
||||
<button type="button" class="btn-search" id="btnSearch" aria-label="통계 조회">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
@@ -335,6 +336,8 @@
|
||||
let currentMode = 'DAILY';
|
||||
const CIRCUMFERENCE = 439.8; // 2 * PI * 70
|
||||
const MAX_DATE_RANGE_DAYS = 40;
|
||||
const MIN_QUERY_DATE = moment([[${ statsMinDate }]], 'YYYY-MM-DD', true);
|
||||
const MAX_QUERY_DATE = moment([[${ statsMaxDate }]], 'YYYY-MM-DD', true);
|
||||
|
||||
// ── Donut (성공/타임아웃/실패 3세그먼트) ──
|
||||
function updateChart() {
|
||||
@@ -460,16 +463,70 @@
|
||||
return meta ? meta.getAttribute('content') : '';
|
||||
}
|
||||
|
||||
// Validate date range (max 40 days)
|
||||
// 서버 기준 1년 전 ~ 오늘 중 최대 40일만 조회한다 (양 끝 포함).
|
||||
function validateDateRange(startDate, endDate) {
|
||||
if (!startDate || !endDate) return { valid: false, message: '시작일과 종료일을 입력해주세요.' };
|
||||
var start = new Date(startDate), end = new Date(endDate);
|
||||
if (start > end) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
var diffDays = Math.floor((end - start) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays > MAX_DATE_RANGE_DAYS) return { valid: false, message: '조회 기간은 최대 ' + MAX_DATE_RANGE_DAYS + '일까지 가능합니다.' };
|
||||
var start = moment(startDate, 'YYYY-MM-DD', true), end = moment(endDate, 'YYYY-MM-DD', true);
|
||||
if (!start.isValid() || !end.isValid()) return { valid: false, message: '올바른 날짜를 선택해주세요.' };
|
||||
if (start.isAfter(end)) return { valid: false, message: '시작일이 종료일보다 늦을 수 없습니다.' };
|
||||
if (start.isBefore(MIN_QUERY_DATE, 'day') || end.isAfter(MAX_QUERY_DATE, 'day')) {
|
||||
return { valid: false, message: '조회 날짜는 1년 전부터 오늘까지만 선택할 수 있습니다.' };
|
||||
}
|
||||
if (end.diff(start, 'days') >= MAX_DATE_RANGE_DAYS) {
|
||||
return { valid: false, message: '조회 기간은 시작일·종료일을 포함해 최대 ' + MAX_DATE_RANGE_DAYS + '일로 선택해주세요.' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
function initializeDateRangePicker() {
|
||||
var input = $('#statisticsDateRange');
|
||||
function updateRangeText() {
|
||||
input.val(moment($('#startDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD') + ' - '
|
||||
+ moment($('#endDate').val(), 'YYYY-MM-DD').format('YYYY.MM.DD'));
|
||||
}
|
||||
|
||||
input.daterangepicker({
|
||||
startDate: moment($('#startDate').val(), 'YYYY-MM-DD', true),
|
||||
endDate: moment($('#endDate').val(), 'YYYY-MM-DD', true),
|
||||
minDate: MIN_QUERY_DATE.clone(),
|
||||
maxDate: MAX_QUERY_DATE.clone(),
|
||||
maxSpan: { days: MAX_DATE_RANGE_DAYS - 1 },
|
||||
autoUpdateInput: false,
|
||||
opens: 'left',
|
||||
drops: 'auto',
|
||||
locale: {
|
||||
format: 'YYYY.MM.DD',
|
||||
separator: ' - ',
|
||||
applyLabel: '조회',
|
||||
cancelLabel: '취소',
|
||||
daysOfWeek: ['일', '월', '화', '수', '목', '금', '토'],
|
||||
monthNames: ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'],
|
||||
firstDay: 0
|
||||
}
|
||||
});
|
||||
input.data('daterangepicker').container.addClass('statistics-date-picker');
|
||||
updateRangeText();
|
||||
|
||||
input.on('show.daterangepicker', function (event, picker) {
|
||||
// 조회하지 않고 바깥을 눌러 닫았다면 확정된 조회 조건으로 다시 연다.
|
||||
picker.setStartDate(moment($('#startDate').val(), 'YYYY-MM-DD', true));
|
||||
picker.setEndDate(moment($('#endDate').val(), 'YYYY-MM-DD', true));
|
||||
picker.updateView();
|
||||
});
|
||||
input.on('apply.daterangepicker', function (event, picker) {
|
||||
$('#startDate').val(picker.startDate.format('YYYY-MM-DD'));
|
||||
$('#endDate').val(picker.endDate.format('YYYY-MM-DD'));
|
||||
updateRangeText();
|
||||
searchStatistics();
|
||||
});
|
||||
input.on('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
input.data('daterangepicker').show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
mode: currentMode,
|
||||
@@ -505,8 +562,10 @@
|
||||
if (p.mode === 'MONTHLY') { $('#dailyFilter').hide(); $('#monthlyFilter').show(); }
|
||||
else { $('#monthlyFilter').hide(); $('#dailyFilter').show(); }
|
||||
}
|
||||
if (p.startDate) $('#startDate').val(p.startDate);
|
||||
if (p.endDate) $('#endDate').val(p.endDate);
|
||||
if (validateDateRange(p.startDate, p.endDate).valid) {
|
||||
$('#startDate').val(p.startDate);
|
||||
$('#endDate').val(p.endDate);
|
||||
}
|
||||
if (p.month) $('#month').val(p.month);
|
||||
if (p.clientId !== undefined && p.clientId !== null) { selectedClientId = p.clientId; $('#appSelect').val(p.clientId); }
|
||||
return true;
|
||||
@@ -563,12 +622,12 @@
|
||||
// Event Handlers
|
||||
$('#btnSearch').on('click', searchStatistics);
|
||||
$('#appSelect').on('change', function () { selectedClientId = $(this).val(); searchStatistics(); });
|
||||
$('#startDate, #endDate').on('change', searchStatistics);
|
||||
$('#month').on('change', searchStatistics);
|
||||
$('#startDate, #endDate').on('keypress', function (e) { if (e.which === 13) searchStatistics(); });
|
||||
|
||||
// 저장된 조회 파라미터가 있으면 복원 후 재조회 (없으면 서버 초기 데이터 유지)
|
||||
if (restoreParams()) {
|
||||
var restored = restoreParams();
|
||||
initializeDateRangePicker();
|
||||
if (restored) {
|
||||
searchStatistics();
|
||||
}
|
||||
});
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.eactive.apim.portal.apps.statistics.controller;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsResultDto;
|
||||
import com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
|
||||
class ApiStatisticsDateRangeTest {
|
||||
|
||||
private ApiStatisticsService service;
|
||||
private MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
service = mock(ApiStatisticsService.class);
|
||||
PortalOrg org = mock(PortalOrg.class);
|
||||
when(org.getId()).thenReturn("test-org");
|
||||
PortalAuthenticatedUser user = mock(PortalAuthenticatedUser.class);
|
||||
when(user.getPortalOrg()).thenReturn(org);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null));
|
||||
mvc = MockMvcBuilders.standaloneSetup(new ApiStatisticsController(service)).build();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanup() {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOutOfWindowAnd41DayRangesForSearchAndDownload() throws Exception {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate old = today.minusYears(1).minusDays(1);
|
||||
LocalDate future = today.plusDays(1);
|
||||
LocalDate[][] invalid = {{old, old}, {future, future}, {today.minusDays(40), today}};
|
||||
for (LocalDate[] range : invalid) {
|
||||
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload(range[0], range[1])))
|
||||
.andExpect(status().isBadRequest());
|
||||
mvc.perform(get("/statistics/api/download")
|
||||
.param("startDate", range[0].toString()).param("endDate", range[1].toString()))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
|
||||
@Test
|
||||
void accepts40InclusiveDaysAndInvokesStatisticsService() throws Exception {
|
||||
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
|
||||
LocalDate today = LocalDate.now();
|
||||
mvc.perform(post("/statistics/api/search").contentType(MediaType.APPLICATION_JSON)
|
||||
.content(payload(today.minusDays(39), today)))
|
||||
.andExpect(status().isOk());
|
||||
verify(service).getStatistics(eq("test-org"), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rendersCalendarBoundsFromServerDate() throws Exception {
|
||||
when(service.getStatistics(eq("test-org"), any())).thenReturn(ApiStatisticsResultDto.empty());
|
||||
LocalDate today = LocalDate.now();
|
||||
mvc.perform(get("/statistics/api"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(model().attribute("statsMinDate", today.minusYears(1).toString()))
|
||||
.andExpect(model().attribute("statsMaxDate", today.toString()));
|
||||
}
|
||||
|
||||
private String payload(LocalDate start, LocalDate end) {
|
||||
return "{\"mode\":\"DAILY\",\"startDate\":\"" + start + "\",\"endDate\":\"" + end + "\"}";
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.eactive.apim.portal.apps.statistics.dto;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ApiStatisticsSearchDtoTest {
|
||||
|
||||
private boolean valid(String start, String end, String today) {
|
||||
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
|
||||
dto.setStartDate(start == null ? null : LocalDate.parse(start));
|
||||
dto.setEndDate(end == null ? null : LocalDate.parse(end));
|
||||
return dto.isValidDateRange(LocalDate.parse(today));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsTodayAndExactlyOneYearAgoButRejectsOutsideDates() {
|
||||
assertTrue(valid("2026-09-09", "2026-09-09", "2026-09-09"));
|
||||
assertTrue(valid("2025-09-09", "2025-09-09", "2026-09-09"));
|
||||
assertFalse(valid("2025-09-08", "2025-09-08", "2026-09-09"));
|
||||
assertFalse(valid("2026-09-10", "2026-09-10", "2026-09-09"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsBothEndpointsAndHandlesLeapDays() {
|
||||
assertTrue(valid("2026-08-01", "2026-09-09", "2026-09-09"));
|
||||
assertFalse(valid("2026-07-31", "2026-09-09", "2026-09-09"));
|
||||
assertTrue(valid("2024-02-10", "2024-03-20", "2024-04-10"));
|
||||
assertFalse(valid("2024-02-09", "2024-03-20", "2024-04-10"));
|
||||
assertTrue(valid("2023-02-28", "2023-02-28", "2024-02-29"));
|
||||
assertFalse(valid("2023-02-27", "2023-02-27", "2024-02-29"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingOrReversedDates() {
|
||||
assertFalse(valid(null, "2026-09-09", "2026-09-09"));
|
||||
assertFalse(valid("2026-09-09", null, "2026-09-09"));
|
||||
assertFalse(valid("2026-09-09", "2026-09-08", "2026-09-09"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publicValidatorUsesCurrentServerDate() {
|
||||
ApiStatisticsSearchDto dto = new ApiStatisticsSearchDto();
|
||||
dto.setStartDate(LocalDate.now());
|
||||
dto.setEndDate(LocalDate.now());
|
||||
assertTrue(dto.isValidDateRange());
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,17 @@ CommonJS 격리 실행에서는 CVE-2022-24785의 경로 탐색 로케일이
|
||||
실패 시 스크린샷과 trace는 `build/playwright`에 저장된다.
|
||||
실제 서버 통합과 전체 페이지의 시각적 배치는 이 테스트 범위에 포함되지 않는다.
|
||||
|
||||
## APP 통계 페이지
|
||||
|
||||
`statistics-date-range.spec.js`는 `apiStatistics.html`의 실제 DOM과 인라인 스크립트를
|
||||
실행한다. Thymeleaf가 주입할 초기 데이터와 통계 검색 API 응답만 테스트 값으로 대체한다.
|
||||
일별 기간 선택/조회, 취소 및 바깥 클릭, 서버 기준 1년 전~오늘 제한,
|
||||
시작일·종료일을 포함한 최대 40일 제한, 범위를 벗어난 세션 조건 폐기 및 요청 차단,
|
||||
월별 전환, 앱 선택 유지, ISO 날짜와 CSRF 헤더 전송, 모바일 표시를 검증한다.
|
||||
서버의 Thymeleaf 렌더링과 실제 통계 집계는 별도 통합 검증 대상이다.
|
||||
|
||||
통계 페이지만 실행: `npm run test:moment -- --grep 'statistics:'`
|
||||
|
||||
## 배포 파일 출처
|
||||
|
||||
- 버전: 2.30.1 (이전 버전 2.24.0)
|
||||
|
||||
@@ -2,7 +2,7 @@ const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: __dirname,
|
||||
testMatch: 'moment-compatibility.spec.js',
|
||||
testMatch: ['moment-compatibility.spec.js', 'statistics-date-range.spec.js'],
|
||||
outputDir: '../../../build/playwright',
|
||||
fullyParallel: true,
|
||||
workers: 2,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { readFileSync, existsSync } = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const resources = path.resolve(__dirname, '../../main/resources');
|
||||
const staticRoot = path.join(resources, 'static');
|
||||
const template = readFileSync(path.join(resources, 'templates/views/apps/statistics/apiStatistics.html'), 'utf8');
|
||||
const summary = { totalCount: 123, successCount: 123, timeoutCount: 0, errorCount: 0,
|
||||
successRate: 100, timeoutRate: 0, errorRate: 0 };
|
||||
|
||||
function statisticsHtml() {
|
||||
// 실제 템플릿의 DOM/인라인 스크립트를 실행한다. 서버가 주입하는 데이터만 고정한다.
|
||||
const values = { details: [], periods: [], 'searchDto.clientId': null,
|
||||
statsMinDate: '2023-04-10', statsMaxDate: '2024-04-10' };
|
||||
for (const [key, value] of Object.entries(summary)) values[`summary.${key}`] = value;
|
||||
return template.replace(/\[\[\$\{\s*([^}]+?)\s*\}\]\]/g, (_, expression) => {
|
||||
if (!(expression in values)) throw new Error(`Missing server fixture value: ${expression}`);
|
||||
return JSON.stringify(values[expression]);
|
||||
}).replace('th:value="${#temporals.format(searchDto.startDate, \'yyyy-MM-dd\')}"', 'value="2024-02-28"')
|
||||
.replace('th:value="${#temporals.format(searchDto.endDate, \'yyyy-MM-dd\')}"', 'value="2024-03-02"')
|
||||
.replace('th:value="${m}"', 'value="202607"')
|
||||
.replace('th:value="${app.clientId}"', 'value="demo-app"')
|
||||
.replace('<body>', `<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="_csrf" content="test-csrf">
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/daterangepicker.css">
|
||||
<script src="/plugins/jquery/jquery-3.7.1.min.js"></script>
|
||||
<script src="/js/moment.min.js"></script>
|
||||
<script src="/js/daterangepicker.js"></script>
|
||||
</head><body>`);
|
||||
}
|
||||
|
||||
async function openStatistics(page, storedParams) {
|
||||
const searches = [];
|
||||
page.on('pageerror', error => { throw error; });
|
||||
if (storedParams) {
|
||||
await page.addInitScript(params => sessionStorage.setItem('apiStatsParams', JSON.stringify(params)), storedParams);
|
||||
}
|
||||
await page.route('**/*', async route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname === '/statistics/api/search') {
|
||||
searches.push({ payload: route.request().postDataJSON(), headers: route.request().headers() });
|
||||
return route.fulfill({ json: { summary, details: [], periods: [] } });
|
||||
}
|
||||
if (url.pathname === '/statistics/api') {
|
||||
return route.fulfill({ contentType: 'text/html', body: statisticsHtml() });
|
||||
}
|
||||
const asset = path.resolve(staticRoot, '.' + url.pathname);
|
||||
if (url.origin === 'http://portal.test' && asset.startsWith(staticRoot + path.sep) && existsSync(asset)) {
|
||||
return route.fulfill({ path: asset });
|
||||
}
|
||||
return route.abort();
|
||||
});
|
||||
await page.goto('http://portal.test/statistics/api');
|
||||
await expect(page.locator('#statisticsDateRange')).not.toHaveValue('');
|
||||
return searches;
|
||||
}
|
||||
|
||||
function day(page, side, date) {
|
||||
return page.locator(`.statistics-date-picker .${side} td.available:not(.off)`)
|
||||
.filter({ hasText: new RegExp(`^${date}$`) });
|
||||
}
|
||||
|
||||
async function selectLeapRange(page) {
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await day(page, 'left', 29).click();
|
||||
await day(page, 'right', 3).click();
|
||||
}
|
||||
|
||||
test('statistics: initializes from server dates without a redundant search', async ({ page }, testInfo) => {
|
||||
const searches = await openStatistics(page);
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
await page.getByLabel('일별 조회 기간').press('Enter');
|
||||
await expect(page.locator('.statistics-date-picker')).toBeVisible();
|
||||
await expect(page.locator('.statistics-date-picker .left .month')).toHaveText('2월 2024');
|
||||
await expect(page.locator('.statistics-date-picker .applyBtn')).toHaveText('조회');
|
||||
expect(searches).toHaveLength(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('statistics-calendar.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test('statistics: applies a leap-day range with one search and ISO payload', async ({ page }) => {
|
||||
const searches = await openStatistics(page);
|
||||
await selectLeapRange(page);
|
||||
expect(searches).toHaveLength(0);
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
expect(searches[0].payload).toMatchObject({ mode: 'DAILY', startDate: '2024-02-29', endDate: '2024-03-03' });
|
||||
expect(searches[0].headers['x-xsrf-token']).toBe('test-csrf');
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.29 - 2024.03.03');
|
||||
await expect(page.locator('#startDate')).toHaveValue('2024-02-29');
|
||||
await expect(page.locator('#endDate')).toHaveValue('2024-03-03');
|
||||
await expect(page.locator('#totalCount')).toHaveText('123');
|
||||
expect(await page.evaluate(() => JSON.parse(sessionStorage.getItem('apiStatsParams'))))
|
||||
.toMatchObject({ startDate: '2024-02-29', endDate: '2024-03-03' });
|
||||
});
|
||||
|
||||
for (const action of ['cancel', 'outside']) {
|
||||
test(`statistics: ${action} discards pending selection without searching`, async ({ page }) => {
|
||||
const searches = await openStatistics(page);
|
||||
await selectLeapRange(page);
|
||||
if (action === 'cancel') await page.locator('.statistics-date-picker .cancelBtn').click();
|
||||
else await page.locator('.statistics-notice').click();
|
||||
await expect(page.locator('.statistics-date-picker')).toBeHidden();
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
await expect(page.locator('#startDate')).toHaveValue('2024-02-28');
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await expect(page.locator('.statistics-date-picker .left .start-date')).toHaveText('28');
|
||||
await expect(page.locator('.statistics-date-picker .right .end-date')).toHaveText('2');
|
||||
expect(searches).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('statistics: restores saved dates and retains monthly mode and app selection', async ({ page }) => {
|
||||
const searches = await openStatistics(page, {
|
||||
mode: 'MONTHLY', startDate: '2024-03-09', endDate: '2024-03-11', month: '202607', clientId: 'demo-app'
|
||||
});
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await expect(page.locator('#dailyFilter')).toBeHidden();
|
||||
await expect(page.locator('#monthlyFilter')).toBeVisible();
|
||||
expect(searches[0].payload).toMatchObject({ mode: 'MONTHLY', month: '202607', clientId: 'demo-app' });
|
||||
await page.getByRole('button', { name: '일별', exact: true }).click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.03.09 - 2024.03.11');
|
||||
expect(searches[1].payload).toMatchObject({ mode: 'DAILY', startDate: '2024-03-09', endDate: '2024-03-11', clientId: 'demo-app' });
|
||||
await page.getByRole('button', { name: '통계 조회', exact: true }).click();
|
||||
await expect.poll(() => searches.length).toBe(3);
|
||||
expect(searches[2].payload).toEqual(searches[1].payload);
|
||||
});
|
||||
|
||||
test('statistics: limits selection to 40 inclusive days even across DST', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-03-01', endDate: '2024-03-02' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await day(page, 'left', 1).click();
|
||||
await expect(page.locator('.statistics-date-picker .right td.off.disabled:not(.ends)').filter({ hasText: /^10$/ })).toBeVisible();
|
||||
await day(page, 'right', 9).click();
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
expect(searches[1].payload).toMatchObject({ startDate: '2024-03-01', endDate: '2024-04-09' });
|
||||
});
|
||||
|
||||
test('statistics: disables dates before one year ago and allows the earliest day', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2023-04-10', endDate: '2023-04-10' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await expect(page.locator('.statistics-date-picker .left td.off.disabled:not(.ends)').filter({ hasText: /^9$/ })).toBeVisible();
|
||||
await day(page, 'left', 10).click();
|
||||
await day(page, 'left', 10).click();
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
expect(searches[1].payload).toMatchObject({ startDate: '2023-04-10', endDate: '2023-04-10' });
|
||||
});
|
||||
|
||||
test('statistics: allows server today and disables future dates regardless of browser timezone', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-03-20', endDate: '2024-04-10' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await page.getByLabel('일별 조회 기간').click();
|
||||
await expect(page.locator('.statistics-date-picker .right td.off.disabled:not(.ends)').filter({ hasText: /^11$/ })).toBeVisible();
|
||||
await day(page, 'right', 10).click();
|
||||
await page.locator('.statistics-date-picker td.start-date.available:not(.off)').click();
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(2);
|
||||
expect(searches[1].payload).toMatchObject({ startDate: '2024-04-10', endDate: '2024-04-10' });
|
||||
});
|
||||
|
||||
for (const [label, startDate, endDate] of [
|
||||
['before the earliest date', '2023-04-09', '2023-04-09'],
|
||||
['future date', '2024-04-11', '2024-04-11'],
|
||||
['41 inclusive days', '2024-03-01', '2024-04-10']
|
||||
]) {
|
||||
test(`statistics: discards cached ${label}`, async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate, endDate });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
expect(searches[0].payload).toMatchObject({ startDate: '2024-02-28', endDate: '2024-03-02' });
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
});
|
||||
|
||||
test(`statistics: blocks a forged ${label} before sending a request`, async ({ page }) => {
|
||||
const searches = await openStatistics(page);
|
||||
await page.evaluate(({ startDate, endDate }) => {
|
||||
document.querySelector('#startDate').value = startDate;
|
||||
document.querySelector('#endDate').value = endDate;
|
||||
}, { startDate, endDate });
|
||||
const messages = [];
|
||||
page.once('dialog', async dialog => {
|
||||
messages.push(dialog.message());
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByRole('button', { name: '통계 조회', exact: true }).click();
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toContain('조회');
|
||||
expect(searches).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('statistics: ignores invalid cached dates and keeps server defaults', async ({ page }) => {
|
||||
const searches = await openStatistics(page, { mode: 'DAILY', startDate: '2024-02-30', endDate: '2024-05-01' });
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
await expect(page.getByLabel('일별 조회 기간')).toHaveValue('2024.02.28 - 2024.03.02');
|
||||
expect(searches[0].payload).toMatchObject({ startDate: '2024-02-28', endDate: '2024-03-02' });
|
||||
});
|
||||
|
||||
test('statistics: calendar fits a mobile viewport and can apply a selection', async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
const searches = await openStatistics(page);
|
||||
await selectLeapRange(page);
|
||||
const bounds = await page.locator('.statistics-date-picker').boundingBox();
|
||||
expect(bounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(bounds.x + bounds.width).toBeLessThanOrEqual(390);
|
||||
await page.screenshot({ path: testInfo.outputPath('statistics-calendar-mobile.png'), fullPage: true });
|
||||
await page.locator('.statistics-date-picker .applyBtn').click();
|
||||
await expect.poll(() => searches.length).toBe(1);
|
||||
});
|
||||
Reference in New Issue
Block a user