From 9af581e69cdaed8a0d8dedbaa3dcda86f6dafebf Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 16:33:27 +0200 Subject: [PATCH] fix(domain): add month range validation to parse and monthOf Shape-valid but out-of-range months (e.g., month 13 or 00) previously normalized silently through ordinal arithmetic, resulting in wrong months downstream. Now throw on invalid month values (< 1 or > 12), with the offending value in the error message. Co-Authored-By: Claude Opus 5 (1M context) --- src/domain/months.test.ts | 28 ++++++++++++++++++++++++++++ src/domain/months.ts | 7 ++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/domain/months.test.ts b/src/domain/months.test.ts index 7b32bd2..c3da79c 100644 --- a/src/domain/months.test.ts +++ b/src/domain/months.test.ts @@ -66,3 +66,31 @@ test("monthsOfYear returns twelve padded months", () => { test("yearOf parses the year", () => { expect(yearOf("2026-09")).toBe(2026); }); + +describe("month range validation", () => { + test("yearOf rejects out-of-range month 13", () => { + expect(() => yearOf("2026-13")).toThrow(/2026-13/); + }); + + test("yearOf rejects out-of-range month 00", () => { + expect(() => yearOf("2026-00")).toThrow(/2026-00/); + }); + + test("monthOf rejects out-of-range month 13 from IsoDate", () => { + expect(() => monthOf("2026-13-01")).toThrow(/2026-13-01/); + }); + + test("monthOf rejects out-of-range month 00 from IsoDate", () => { + expect(() => monthOf("2026-00-15")).toThrow(/2026-00-15/); + }); + + test("valid boundary month 01 works", () => { + expect(yearOf("2026-01")).toBe(2026); + expect(monthOf("2026-01-01")).toBe("2026-01"); + }); + + test("valid boundary month 12 works", () => { + expect(yearOf("2026-12")).toBe(2026); + expect(monthOf("2026-12-31")).toBe("2026-12"); + }); +}); diff --git a/src/domain/months.ts b/src/domain/months.ts index d586cfe..6fb0e2c 100644 --- a/src/domain/months.ts +++ b/src/domain/months.ts @@ -3,7 +3,10 @@ import type { IsoDate, MonthId } from "./types.ts"; function parse(id: MonthId): { year: number; month: number } { const match = /^(\d{4})-(\d{2})$/.exec(id); if (!match) throw new Error(`Invalid MonthId: ${id}`); - return { year: Number(match[1]), month: Number(match[2]) }; + const year = Number(match[1]); + const month = Number(match[2]); + if (month < 1 || month > 12) throw new Error(`Invalid MonthId: ${id}`); + return { year, month }; } function format(year: number, month: number): MonthId { @@ -23,6 +26,8 @@ function fromOrdinal(ordinal: number): MonthId { export function monthOf(date: IsoDate): MonthId { const match = /^(\d{4})-(\d{2})/.exec(date); if (!match) throw new Error(`Invalid IsoDate: ${date}`); + const month = Number(match[2]); + if (month < 1 || month > 12) throw new Error(`Invalid IsoDate: ${date}`); return `${match[1]}-${match[2]}`; }