Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/util/parseDate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
15 changes: 15 additions & 0 deletions src/util/parseDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down