generated from ttipo/project-base
fix(backend): 행사 목록에서 끝을 생략한 없는 시작일이 500 이 나던 것 수정
from=2030-13-01 처럼 없는 시작일에 to 를 생략하면 끝 날짜를 계산하다 RangeError 가 나서 VAL_001(400) 대신 SYS_001(500)이 나갔다. from=9999-12-31 은 DB 가 읽지 못하는 끝 날짜를 만들었다. 날짜 계산을 shared/utils/iso-date.ts 순수 함수로 옮기고, 없는 날짜와 네 자리 연도를 넘는 결과는 null 로 알려 VAL_001 로 응답한다. 앱은 항상 두 날짜를 보내므로 사용자에게는 보이지 않았다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
toPaginatedResult,
|
||||
} from '@/common/dto/pagination.dto';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { addDaysIso, daysBetweenIso } from '@/shared/utils/iso-date';
|
||||
import { kstDateString } from '@/shared/utils/kst';
|
||||
import { EventDetailDto, EventListItemDto } from './dto/event-list-item.dto';
|
||||
import {
|
||||
@@ -21,7 +22,6 @@ import type { EventSource } from './place.constants';
|
||||
import { decodePlaceCursor, encodePlaceCursor } from './place-cursor';
|
||||
import { boundingBox, sqlDistanceM } from './place.sql';
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||
|
||||
/** 출처 표기(행사 상세 하단·앱 정보 화면) */
|
||||
@@ -37,23 +37,6 @@ const MAX_SPAN_SQL = `CASE e.kind ${Object.entries(EVENT_MAX_SPAN_DAYS)
|
||||
.map(([kind, days]) => `WHEN '${kind}' THEN ${days}`)
|
||||
.join(' ')} END`;
|
||||
|
||||
/** 'YYYY-MM-DD' 두 날짜 사이 일수(b − a). 형식이 틀리거나 없는 날짜면 NaN */
|
||||
function daysBetween(a: string, b: string): number {
|
||||
const ta = Date.parse(`${a}T00:00:00Z`);
|
||||
const tb = Date.parse(`${b}T00:00:00Z`);
|
||||
// '2030-13-01' 처럼 해석이 안 되면 NaN — toISOString 이 예외를 던지지 않게 먼저 거른다
|
||||
if (Number.isNaN(ta) || Number.isNaN(tb)) return Number.NaN;
|
||||
const validA = new Date(ta).toISOString().startsWith(a);
|
||||
const validB = new Date(tb).toISOString().startsWith(b);
|
||||
return validA && validB ? Math.round((tb - ta) / DAY_MS) : Number.NaN;
|
||||
}
|
||||
|
||||
function addDays(iso: string, days: number): string {
|
||||
return new Date(Date.parse(`${iso}T00:00:00Z`) + days * DAY_MS)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 축제·공연·행사 조회 — 홈 '이번 주말 축제·행사', 장소 상세 '근처 축제·행사'.
|
||||
* 60일 넘는 상시 프로그램과 숨김 행사는 목록에서 뺀다.
|
||||
@@ -71,9 +54,10 @@ export class FestivalService {
|
||||
): Promise<PaginatedResult<EventListItemDto>> {
|
||||
const today = kstDateString(now);
|
||||
const from = query.from ?? today;
|
||||
const to = query.to ?? addDays(from, EVENT_RANGE_DEFAULT_DAYS);
|
||||
const span = daysBetween(from, to);
|
||||
if (!(span >= 0 && span <= EVENT_RANGE_MAX_DAYS)) {
|
||||
// 끝을 생략하면 시작일 + 기본 일수. 시작일이 없는 날짜("2030-13-01")면 null 이 되어 아래에서 VAL_001 로 거른다
|
||||
const to = query.to ?? addDaysIso(from, EVENT_RANGE_DEFAULT_DAYS);
|
||||
const span = to === null ? Number.NaN : daysBetweenIso(from, to);
|
||||
if (to === null || !(span >= 0 && span <= EVENT_RANGE_MAX_DAYS)) {
|
||||
throw new BusinessException('VAL_001', {
|
||||
context: { reason: '기간 오류', from, to },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { addDaysIso, daysBetweenIso } from './iso-date';
|
||||
|
||||
describe('daysBetweenIso', () => {
|
||||
it('두 날짜 사이 일수를 센다(달·윤년 경계 포함)', () => {
|
||||
expect(daysBetweenIso('2026-11-04', '2026-12-04')).toBe(30);
|
||||
expect(daysBetweenIso('2026-02-28', '2026-03-01')).toBe(1);
|
||||
expect(daysBetweenIso('2028-02-28', '2028-03-01')).toBe(2);
|
||||
expect(daysBetweenIso('2026-11-08', '2026-11-07')).toBe(-1);
|
||||
});
|
||||
|
||||
it.each(['2026-13-01', '2026-00-10', '2026-02-30', '2026-1-1', ''])(
|
||||
'없는 날짜 "%s" 가 끼면 NaN',
|
||||
(bad) => {
|
||||
expect(daysBetweenIso(bad, '2026-11-04')).toBeNaN();
|
||||
expect(daysBetweenIso('2026-11-04', bad)).toBeNaN();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('addDaysIso', () => {
|
||||
it('일수를 더한 날짜를 돌려준다', () => {
|
||||
expect(addDaysIso('2026-11-04', 30)).toBe('2026-12-04');
|
||||
expect(addDaysIso('2026-12-31', 1)).toBe('2027-01-01');
|
||||
});
|
||||
|
||||
it('없는 날짜는 예외 없이 null', () => {
|
||||
expect(addDaysIso('2026-13-01', 30)).toBeNull();
|
||||
expect(addDaysIso('2026-02-30', 1)).toBeNull();
|
||||
});
|
||||
|
||||
it('결과가 네 자리 연도를 넘으면 null — DB 가 읽지 못하는 날짜를 만들지 않는다', () => {
|
||||
expect(addDaysIso('9999-12-31', 30)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 'YYYY-MM-DD' 날짜 계산 (순수 함수) — 행사 기간 조건에 쓴다.
|
||||
* 없는 날짜("2030-13-01", "2030-02-30")는 예외를 던지지 않고 null·NaN 으로 알린다. 호출부가 VAL_001 로 응답한다.
|
||||
*/
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
/** 실제로 있는 날짜면 그날 0시(UTC) 시각, 아니면 null */
|
||||
function utcMidnight(iso: string): number | null {
|
||||
if (!ISO_DATE.test(iso)) return null;
|
||||
const time = Date.parse(`${iso}T00:00:00Z`);
|
||||
// '2030-02-30' 처럼 다른 날로 넘어가거나 해석이 안 되는 날짜를 거른다
|
||||
if (Number.isNaN(time)) return null;
|
||||
return new Date(time).toISOString().slice(0, 10) === iso ? time : null;
|
||||
}
|
||||
|
||||
/** 두 날짜 사이 일수(b − a). 어느 하나라도 없는 날짜면 NaN */
|
||||
export function daysBetweenIso(a: string, b: string): number {
|
||||
const ta = utcMidnight(a);
|
||||
const tb = utcMidnight(b);
|
||||
if (ta === null || tb === null) return Number.NaN;
|
||||
return Math.round((tb - ta) / DAY_MS);
|
||||
}
|
||||
|
||||
/** 날짜에 일수를 더한 'YYYY-MM-DD'. 없는 날짜이거나 결과가 네 자리 연도를 벗어나면 null */
|
||||
export function addDaysIso(iso: string, days: number): string | null {
|
||||
const time = utcMidnight(iso);
|
||||
if (time === null) return null;
|
||||
const next = new Date(time + days * DAY_MS).toISOString().slice(0, 10);
|
||||
return ISO_DATE.test(next) ? next : null;
|
||||
}
|
||||
@@ -214,6 +214,10 @@ describe('축제·행사 조회 API (e2e)', () => {
|
||||
['형식이 틀린 날짜', 'from=2030/05/01'],
|
||||
['없는 날짜', 'from=2030-02-30&to=2030-03-01'],
|
||||
['해석이 안 되는 날짜(13월)', 'from=2030-13-01&to=2030-12-31'],
|
||||
// 2026-11-04 회귀 점검: 끝을 생략하면 시작일로 끝 날짜를 계산하다 500(SYS_001)이 났다
|
||||
['끝을 생략한 없는 시작일(13월)', 'from=2030-13-01'],
|
||||
['끝을 생략한 없는 시작일(0월)', 'from=2030-00-10'],
|
||||
['끝을 생략해 끝 날짜가 네 자리 연도를 넘는 경우', 'from=9999-12-31'],
|
||||
['없는 분류', `${may}&kind=parade`],
|
||||
['lat 만 보낸 경우', `${may}&lat=36.6`],
|
||||
])('%s 는 VAL_001(400)', async (_, query) => {
|
||||
|
||||
Reference in New Issue
Block a user