diff --git a/src/util/parseDate.test.ts b/src/util/parseDate.test.ts index ce6766a..5317a61 100644 --- a/src/util/parseDate.test.ts +++ b/src/util/parseDate.test.ts @@ -44,6 +44,52 @@ describe("parseDate", () => { expect(parseDate("20250101")).toEqual({ year: 2025, month: "01", day: "01" }); expect(parseDate("20251231")).toEqual({ year: 2025, month: "12", day: "31" }); }); + + it("rejects Feb 30 in any year", () => { + // Without the calendar probe, `new Date("2025-02-30")` silently rolls to + // March 2 — corrupting ChangeLog / spac_history point-in-time semantics. + expect(() => parseDate("2025-02-30")).toThrow(/Invalid calendar date/); + expect(() => parseDate("2024-02-30")).toThrow(/Invalid calendar date/); + }); + + it("rejects Feb 29 in a non-leap year but accepts it in a leap year", () => { + expect(() => parseDate("2025-02-29")).toThrow(/Invalid calendar date/); + expect(() => parseDate("2023-02-29")).toThrow(/Invalid calendar date/); + // 1900 is not a leap year (divisible by 100, not by 400). + expect(() => parseDate("1900-02-29")).toThrow(/Invalid calendar date/); + // 2024 is a leap year; 2000 is a leap year (divisible by 400). + expect(parseDate("2024-02-29")).toEqual({ year: 2024, month: "02", day: "29" }); + expect(parseDate("2000-02-29")).toEqual({ year: 2000, month: "02", day: "29" }); + }); + + it.each([ + ["2025-04-31", 4], + ["2025-06-31", 6], + ["2025-09-31", 9], + ["2025-11-31", 11], + ])("rejects the impossible 31st of a 30-day month (%s)", (input) => { + expect(() => parseDate(input)).toThrow(/Invalid calendar date/); + }); + + it.each([ + ["2025-00-10"], // month 0 + ["2025-13-10"], // month 13 + ["2025-01-00"], // day 0 + ["2025-01-32"], // day 32 + ])("rejects out-of-range month/day (%s)", (input) => { + expect(() => parseDate(input)).toThrow("Invalid date format"); + }); + + it("accepts all four supported formats for a valid date", () => { + // Regression fence: the calendar probe must not reject any of the + // recognised shapes for a well-formed real date. + const expected = { year: 2024, month: "02", day: "29" }; + expect(parseDate("2024-02-29")).toEqual(expected); + expect(parseDate("2024/02/29")).toEqual(expected); + expect(parseDate("02/29/2024")).toEqual(expected); + expect(parseDate("02-29-2024")).toEqual(expected); + expect(parseDate("20240229")).toEqual(expected); + }); }); describe("secDate", () => { diff --git a/src/util/parseDate.ts b/src/util/parseDate.ts index dd6088f..d686340 100644 --- a/src/util/parseDate.ts +++ b/src/util/parseDate.ts @@ -47,6 +47,21 @@ export function parseDate(dateStr: string): { year: number; month: string; day: throw new Error("Invalid date format"); } + // Range-valid but calendar-invalid dates (Feb 30, Feb 29 in non-leap + // years, Apr/Jun/Sep/Nov 31) would otherwise flow through as-is and + // silently roll forward when downstream code hands the string to + // `new Date(...)` — shifting point-in-time semantics of ChangeLog / + // spac_history / offering-history rows. Probe via a UTC Date and reject + // any input the calendar refused to preserve. + const probe = new Date(Date.UTC(year, month - 1, day)); + if ( + probe.getUTCFullYear() !== year || + probe.getUTCMonth() !== month - 1 || + probe.getUTCDate() !== day + ) { + throw new Error(`Invalid calendar date: ${dateStr}`); + } + return { year, month: month.toString().padStart(2, "0"),