API 통계 날짜 선택 로직 및 테스트 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- 최대 40일 범위 제한 및 1년 조회 가능 기간 검증
- Date-range Picker UI 추가 및 서버 동기화 로직 구현
- Controller, DTO 유효성 테스트 및 Playwright 테스트 작성
This commit is contained in:
Rinjae
2026-09-09 16:25:00 +09:00
parent d9373b00f0
commit a310b671e2
13 changed files with 519 additions and 27 deletions
@@ -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() {
@@ -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;
}
/**
+28
View File
@@ -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
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;
@@ -692,4 +722,4 @@
.stats-progress {
width: 100px;
}
}
}
@@ -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();
}
});
@@ -577,4 +636,4 @@
</body>
</html>
</html>
@@ -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 + "\"}";
}
}
@@ -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());
}
}
+11
View File
@@ -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)
+1 -1
View File
@@ -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,
+215
View File
@@ -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);
});