Skip to content

Commit 9a1e862

Browse files
committed
fix(ingest): resolve date-only timestamps with server time
1 parent dcdbff6 commit 9a1e862

7 files changed

+89
-70
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { resolveDateOnlyWithServerTime } from './resolve-date-only-with-server-time';
3+
4+
describe('resolveDateOnlyWithServerTime', () => {
5+
it('should apply server time when the date matches today', () => {
6+
const now = new Date('2026-01-15T01:02:03.004Z');
7+
8+
const result = resolveDateOnlyWithServerTime({ year: 2026, month: 1, day: 15 }, now);
9+
10+
expect(result).toBe(now.toISOString());
11+
});
12+
13+
it('should return end-of-day for past dates', () => {
14+
const now = new Date('2026-01-15T01:02:03.004Z');
15+
16+
const result = resolveDateOnlyWithServerTime({ year: 2026, month: 1, day: 14 }, now);
17+
18+
expect(result).toBe(new Date('2026-01-14T23:59:59.999+09:00').toISOString());
19+
});
20+
21+
it('should return start-of-day for future dates', () => {
22+
const now = new Date('2026-01-15T01:02:03.004Z');
23+
24+
const result = resolveDateOnlyWithServerTime({ year: 2026, month: 1, day: 16 }, now);
25+
26+
expect(result).toBe(new Date('2026-01-16T00:00:00.000+09:00').toISOString());
27+
});
28+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
type DateOnlyParts = {
2+
year: number;
3+
month: number;
4+
day: number;
5+
};
6+
7+
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
8+
9+
export function resolveDateOnlyWithServerTime(parts: DateOnlyParts, now: Date): string | null {
10+
const target = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
11+
if (Number.isNaN(target.getTime())) {
12+
return null;
13+
}
14+
15+
const nowKst = new Date(now.getTime() + KST_OFFSET_MS);
16+
const todayTime = Date.UTC(nowKst.getUTCFullYear(), nowKst.getUTCMonth(), nowKst.getUTCDate());
17+
const targetTime = target.getTime();
18+
19+
const { hour, minute, second, millisecond } =
20+
targetTime === todayTime
21+
? {
22+
hour: nowKst.getUTCHours(),
23+
minute: nowKst.getUTCMinutes(),
24+
second: nowKst.getUTCSeconds(),
25+
millisecond: nowKst.getUTCMilliseconds(),
26+
}
27+
: targetTime < todayTime
28+
? { hour: 23, minute: 59, second: 59, millisecond: 999 }
29+
: { hour: 0, minute: 0, second: 0, millisecond: 0 };
30+
31+
const kstIso = buildKstIso(parts, hour, minute, second, millisecond);
32+
const resolved = new Date(kstIso);
33+
if (Number.isNaN(resolved.getTime())) {
34+
return null;
35+
}
36+
return resolved.toISOString();
37+
}
38+
39+
function buildKstIso(parts: DateOnlyParts, hour: number, minute: number, second: number, millisecond: number): string {
40+
const monthText = String(parts.month).padStart(2, '0');
41+
const dayText = String(parts.day).padStart(2, '0');
42+
const hourText = String(hour).padStart(2, '0');
43+
const minuteText = String(minute).padStart(2, '0');
44+
const secondText = String(second).padStart(2, '0');
45+
const millisecondText = String(millisecond).padStart(3, '0');
46+
return `${parts.year}-${monthText}-${dayText}T${hourText}:${minuteText}:${secondText}.${millisecondText}+09:00`;
47+
}

src/modules/ingest/app/sources/kasa-space-weather-crisis-alert.source.ts

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { Source, SourceEvent, SourceRunResult } from '../../domain/port/sou
88
import { isTooOld } from './_shared/is-too-old';
99
import { normalizeText } from './_shared/normalize';
1010
import { pruneTimedMap } from './_shared/prune-timed-map';
11+
import { resolveDateOnlyWithServerTime } from './_shared/resolve-date-only-with-server-time';
1112
import { shouldEmitEvent } from './_shared/should-emit-event';
1213

1314
const KASA_SPACE_WEATHER_CRISIS_ENDPOINT = 'https://spaceweather.kasa.go.kr/Alarm.do';
@@ -258,28 +259,7 @@ function parseIssuedAt(value: string, now: Date): string | null {
258259
return null;
259260
}
260261

261-
const dateOnly = new Date(yearNum, monthNum - 1, dayNum);
262-
if (Number.isNaN(dateOnly.getTime())) {
263-
return null;
264-
}
265-
266-
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
267-
const isPastDay = dateOnly.getTime() < today.getTime();
268-
const resolved = new Date(
269-
yearNum,
270-
monthNum - 1,
271-
dayNum,
272-
isPastDay ? 23 : now.getHours(),
273-
isPastDay ? 59 : now.getMinutes(),
274-
isPastDay ? 59 : now.getSeconds(),
275-
isPastDay ? 999 : now.getMilliseconds(),
276-
);
277-
278-
if (Number.isNaN(resolved.getTime())) {
279-
return null;
280-
}
281-
282-
return resolved.toISOString();
262+
return resolveDateOnlyWithServerTime({ year: yearNum, month: monthNum, day: dayNum }, now);
283263
}
284264

285265
type FetchInit = NonNullable<Parameters<typeof fetch>[1]>;

src/modules/ingest/app/sources/msit-press-release.source.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe('MsitPressReleaseSource', () => {
9393
buildItem({
9494
title: '사이버위기 경보 단계 관심 상향',
9595
link: 'https://www.msit.go.kr/bbs/view.do?sCode=user&bbsSeqNo=94&nttSeqNo=3186769',
96-
pubDate: '2026.01.16',
96+
pubDate: '2026.01.14',
9797
}),
9898
);
9999

@@ -107,6 +107,6 @@ describe('MsitPressReleaseSource', () => {
107107
const result = await source.run(null);
108108

109109
expect(result.events).toHaveLength(1);
110-
expect(result.events[0].occurredAt).toBe('2026-01-15T15:00:00.000Z');
110+
expect(result.events[0].occurredAt).toBe('2026-01-14T14:59:59.999Z');
111111
});
112112
});

src/modules/ingest/app/sources/msit-press-release.source.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,13 @@ import { fetchWithTimeout } from './_shared/fetch-with-timeout';
99
import { isTooOld } from './_shared/is-too-old';
1010
import { normalizeText } from './_shared/normalize';
1111
import { pruneTimedMap } from './_shared/prune-timed-map';
12+
import { resolveDateOnlyWithServerTime } from './_shared/resolve-date-only-with-server-time';
1213
import { shouldEmitEvent } from './_shared/should-emit-event';
1314

1415
const MSIT_PRESS_RSS_ENDPOINT = 'https://www.msit.go.kr/user/rss/rss.do?bbsSeqNo=94';
1516
const REQUEST_TIMEOUT_MS = 60000;
1617
const STATE_TTL_MS = 1000 * 60 * 60 * 24;
1718
const EVENT_MAX_AGE_MS = STATE_TTL_MS * 0.9;
18-
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
1919

2020
const REQUIRED_KEYWORD = '경보';
2121
const ACTION_KEYWORDS = ['발령', '상향', '하향', '격상'] as const;
@@ -293,26 +293,14 @@ function parseMsitDate(value: string | null, now: Date): string | null {
293293
}
294294

295295
const [, year, month, day] = matched;
296-
const targetDate = `${year}-${month}-${day}`;
297-
const nowKst = new Date(now.getTime() + KST_OFFSET_MS);
298-
if (targetDate === formatUtcDate(nowKst)) {
299-
return now.toISOString();
300-
}
301-
302-
const kstIso = `${year}-${month}-${day}T00:00:00+09:00`;
303-
const parsed = new Date(kstIso);
304-
if (Number.isNaN(parsed.getTime())) {
296+
const yearNum = Number(year);
297+
const monthNum = Number(month);
298+
const dayNum = Number(day);
299+
if (!Number.isFinite(yearNum) || !Number.isFinite(monthNum) || !Number.isFinite(dayNum)) {
305300
return null;
306301
}
307302

308-
return parsed.toISOString();
309-
}
310-
311-
function formatUtcDate(date: Date): string {
312-
const year = date.getUTCFullYear();
313-
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
314-
const day = String(date.getUTCDate()).padStart(2, '0');
315-
return `${year}-${month}-${day}`;
303+
return resolveDateOnlyWithServerTime({ year: yearNum, month: monthNum, day: dayNum }, now);
316304
}
317305

318306
function parseState(state: string | null): MsitPressState {

src/modules/ingest/app/sources/ncsc-cyber-crisis.source.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { fetchWithTimeout } from './_shared/fetch-with-timeout';
88
import { isTooOld } from './_shared/is-too-old';
99
import { normalizeText } from './_shared/normalize';
1010
import { pruneTimedMap } from './_shared/prune-timed-map';
11+
import { resolveDateOnlyWithServerTime } from './_shared/resolve-date-only-with-server-time';
1112
import { shouldEmitEvent } from './_shared/should-emit-event';
1213

1314
const NCSC_CYBER_CRISIS_ENDPOINT =
@@ -207,19 +208,5 @@ function parseKstDate(value: string, now: Date): string | null {
207208
const yearNum = Number(year);
208209
const monthNum = Number(month);
209210
const dayNum = Number(day);
210-
const isSameDay = yearNum === now.getFullYear() && monthNum === now.getMonth() + 1 && dayNum === now.getDate();
211-
const parsed = new Date(
212-
yearNum,
213-
monthNum - 1,
214-
dayNum,
215-
isSameDay ? now.getHours() : 0,
216-
isSameDay ? now.getMinutes() : 0,
217-
isSameDay ? now.getSeconds() : 0,
218-
isSameDay ? now.getMilliseconds() : 0,
219-
);
220-
if (Number.isNaN(parsed.getTime())) {
221-
return null;
222-
}
223-
224-
return parsed.toISOString();
211+
return resolveDateOnlyWithServerTime({ year: yearNum, month: monthNum, day: dayNum }, now);
225212
}

src/modules/ingest/app/sources/nctc-terror-alert.source.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { fetchWithTimeout } from './_shared/fetch-with-timeout';
99
import { isTooOld } from './_shared/is-too-old';
1010
import { normalizeText } from './_shared/normalize';
1111
import { pruneTimedMap } from './_shared/prune-timed-map';
12+
import { resolveDateOnlyWithServerTime } from './_shared/resolve-date-only-with-server-time';
1213
import { shouldEmitEvent } from './_shared/should-emit-event';
1314

1415
const NCTC_TERROR_ALERT_LIST_ENDPOINT =
@@ -400,17 +401,5 @@ function parseKstDate(value: string, now: Date): string | null {
400401
const yearNum = Number(year);
401402
const monthNum = Number(month);
402403
const dayNum = Number(day);
403-
const isSameDay = yearNum === now.getFullYear() && monthNum === now.getMonth() + 1 && dayNum === now.getDate();
404-
const parsed = new Date(
405-
yearNum,
406-
monthNum - 1,
407-
dayNum,
408-
isSameDay ? now.getHours() : 0,
409-
isSameDay ? now.getMinutes() : 0,
410-
);
411-
if (Number.isNaN(parsed.getTime())) {
412-
return null;
413-
}
414-
415-
return parsed.toISOString();
404+
return resolveDateOnlyWithServerTime({ year: yearNum, month: monthNum, day: dayNum }, now);
416405
}

0 commit comments

Comments
 (0)