diff --git a/backend/src/modules/place/festival.service.ts b/backend/src/modules/place/festival.service.ts index 93e6c7b..4d05384 100644 --- a/backend/src/modules/place/festival.service.ts +++ b/backend/src/modules/place/festival.service.ts @@ -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> { 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 }, }); diff --git a/backend/src/shared/utils/iso-date.spec.ts b/backend/src/shared/utils/iso-date.spec.ts new file mode 100644 index 0000000..284e1fd --- /dev/null +++ b/backend/src/shared/utils/iso-date.spec.ts @@ -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(); + }); +}); diff --git a/backend/src/shared/utils/iso-date.ts b/backend/src/shared/utils/iso-date.ts new file mode 100644 index 0000000..60fd21a --- /dev/null +++ b/backend/src/shared/utils/iso-date.ts @@ -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; +} diff --git a/backend/test/festival.e2e-spec.ts b/backend/test/festival.e2e-spec.ts index 1713fa7..78c2569 100644 --- a/backend/test/festival.e2e-spec.ts +++ b/backend/test/festival.e2e-spec.ts @@ -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) => { diff --git a/docs/design/place-db.md b/docs/design/place-db.md index 2339bc2..6ac134c 100644 --- a/docs/design/place-db.md +++ b/docs/design/place-db.md @@ -360,6 +360,7 @@ node dist/commands/place-sync.js tour-kor - 목록 항목의 `openNow` 는 한국 시간 기준 영업 중 여부다. 알 수 없으면 null → 화면은 "영업시간 미확인 · 방문 전 확인". - 사후면세점 브랜드 매장은 이름 검색일 때만 나온다(반려동물 필터 결과가 더현대 입점 매장으로 덮이던 문제). +- 행사 목록에서 끝(`to`)을 생략하고 없는 시작일(`from=2030-13-01`)을 보내면 500 이 나던 것을 VAL_001(400)로 고쳤다(2026-11-04). - 실패 코드: 검색 조건·커서·id 형식 오류 VAL_001(400), 없음 RES_001(404). 새 코드는 만들지 않았다. - 계약 테스트: `backend/test/place.e2e-spec.ts` — 시작할 때 마이그레이션을 적용하고 동해 좌표에 테스트 장소를 만들어 검증한다(16개). - 아직 없는 것: 영어 등 다국어 응답(`place_i18n`), 지도 화면용 사각형(bbox) 조회.