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:
2026-11-04 23:58:27 +09:00
parent ff56d78022
commit 785afcc53f
5 changed files with 76 additions and 21 deletions
+5 -21
View File
@@ -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 },
});
+34
View File
@@ -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();
});
});
+32
View File
@@ -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;
}
+4
View File
@@ -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) => {