From 1f91822e96def814dec0c97f30f60d3c0a3f32ad Mon Sep 17 00:00:00 2001 From: Chiziterem Eze <122726814+chizzy192@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:01:22 +0000 Subject: [PATCH] feat: implement issues #547, #548, #549, #550 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resolves four tracked issues across testing, utilities, and observability. ## Changes ### #548 – Add parseISOTimestamp helper - Added `InvalidTimestamp` typed error class to `iso-timestamp.utils.ts` - Added `parseISOTimestamp(value: string): Date` that strictly validates ISO 8601 datetime strings (rejects bare dates, Unix timestamp strings, empty strings) and returns a UTC Date object - Extended `iso-timestamp.utils.test.ts` with 8 new unit tests covering all acceptance criteria ### #547 – Integration test: creator list returns empty array when no creators exist - Added `creator-list-empty-database.integration.test.ts` with 8 tests - Asserts HTTP 200 (not 404), empty `data.items` array, `meta.total === 0`, `meta.hasMore === false` when the DB has no creator records - Uses Jest mocks — no live database required ### #549 – Integration test: alert registration returns 400 for zero/negative target_price - Added `alert-invalid-target-price.integration.test.ts` with 8 tests - Asserts 400 for `target_price: 0` and `target_price: -1` - Asserts error body identifies the `target_price` field - Asserts `createAlert` is never called (no DB record created) - Uses Jest mocks — no live database required ### #550 – Structured debug log after every creator list query - Updated `fetchCreatorList` in `creators.utils.ts` to emit a `logger.debug` call after every query resolves - Log fields: `result_count`, `filters` (only active params), `sort`, `query_duration_ms`; cursor is intentionally excluded - Added `creator-list-query-log.test.ts` with 14 unit tests verifying log content, active-only filter keys, cursor absence, and timing Closes #547 Closes #548 Closes #549 Closes #550 --- ...t-invalid-target-price.integration.test.ts | 137 ++++++++++++ ...or-list-empty-database.integration.test.ts | 129 +++++++++++ .../creators/creator-list-query-log.test.ts | 203 ++++++++++++++++++ src/modules/creators/creators.utils.ts | 20 ++ src/utils/iso-timestamp.utils.test.ts | 60 +++++- src/utils/iso-timestamp.utils.ts | 58 +++++ 6 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 src/modules/alerts/__tests__/alert-invalid-target-price.integration.test.ts create mode 100644 src/modules/creators/creator-list-empty-database.integration.test.ts create mode 100644 src/modules/creators/creator-list-query-log.test.ts diff --git a/src/modules/alerts/__tests__/alert-invalid-target-price.integration.test.ts b/src/modules/alerts/__tests__/alert-invalid-target-price.integration.test.ts new file mode 100644 index 0000000..8e0e897 --- /dev/null +++ b/src/modules/alerts/__tests__/alert-invalid-target-price.integration.test.ts @@ -0,0 +1,137 @@ +// Integration test: POST /api/v1/alerts returns 400 when target_price is zero or +// negative (#549) +// +// A price alert with a zero or negative target price is meaningless. The +// registration endpoint must reject these values with a 400 *before* writing +// anything to the database. +// +// Uses Jest mocks — no live database connection is required. + +import { httpCreateAlert } from '../alert.controllers'; + +// ── Lightweight request / response mocks ────────────────────────────────────── + +const VALID_PAYLOAD = { + creator_id: 'creator-abc-123', + wallet_address: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF', + target_price: 100, + direction: 'above', + callback_url: 'https://example.com/webhook', +}; + +function makeReq(body: Record = {}): any { + return { body }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Mock the alert service so no DB writes occur ─────────────────────────────── + +jest.mock('../alert.service', () => ({ + createAlert: jest.fn(), + listAlerts: jest.fn(), + deleteAlert: jest.fn(), +})); + +import { createAlert } from '../alert.service'; + +const mockCreateAlert = createAlert as jest.Mock; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('POST /api/v1/alerts — zero or negative target_price (#549)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // Acceptance criterion: zero target_price returns 400 + it('returns 400 when target_price is zero', async () => { + const req = makeReq({ ...VALID_PAYLOAD, target_price: 0 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + // Acceptance criterion: negative target_price returns 400 + it('returns 400 when target_price is negative (-1)', async () => { + const req = makeReq({ ...VALID_PAYLOAD, target_price: -1 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when target_price is a large negative number', async () => { + const req = makeReq({ ...VALID_PAYLOAD, target_price: -9999 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + // Acceptance criterion: error body identifies the target_price field + it('error body identifies the target_price field when target_price is zero', async () => { + const req = makeReq({ ...VALID_PAYLOAD, target_price: 0 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toBeDefined(); + + // The response must reference 'target_price' either in a field array + // or in the top-level message. + const bodyStr = JSON.stringify(body); + expect(bodyStr).toMatch(/target_price/); + }); + + it('error body identifies the target_price field when target_price is negative', async () => { + const req = makeReq({ ...VALID_PAYLOAD, target_price: -1 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + const bodyStr = JSON.stringify(body); + expect(bodyStr).toMatch(/target_price/); + }); + + // Acceptance criterion: no alert record created after failed request + it('does not call createAlert when target_price is zero', async () => { + const req = makeReq({ ...VALID_PAYLOAD, target_price: 0 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + expect(mockCreateAlert).not.toHaveBeenCalled(); + }); + + it('does not call createAlert when target_price is negative', async () => { + const req = makeReq({ ...VALID_PAYLOAD, target_price: -1 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + expect(mockCreateAlert).not.toHaveBeenCalled(); + }); + + // Sanity check: a valid positive target_price still reaches createAlert + it('does not return 400 when target_price is a valid positive number', async () => { + mockCreateAlert.mockResolvedValue({ id: 'alert-1', ...VALID_PAYLOAD }); + + const req = makeReq({ ...VALID_PAYLOAD, target_price: 50 }); + const res = makeRes(); + await httpCreateAlert(req, res, makeNext()); + + expect(res.status).not.toHaveBeenCalledWith(400); + expect(mockCreateAlert).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/creators/creator-list-empty-database.integration.test.ts b/src/modules/creators/creator-list-empty-database.integration.test.ts new file mode 100644 index 0000000..c3db670 --- /dev/null +++ b/src/modules/creators/creator-list-empty-database.integration.test.ts @@ -0,0 +1,129 @@ +// Integration test: creator list returns empty array when no creators exist (#547) +// +// The creator list endpoint must return a 200 with an empty data array and +// accurate metadata when the database has no creator records — never a 404. +// +// Uses Jest mocks (isolated empty fixture) — no database connection required. + +import { httpListCreators } from './creators.controllers'; +import * as creatorsUtils from './creators.utils'; + +// ── Lightweight request / response mocks ────────────────────────────────────── + +function makeReq(query: Record = {}): any { + return { query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.set = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/v1/creators — empty database (#547)', () => { + beforeEach(() => { + // Simulate an empty database: no creator records exist. + jest.spyOn(creatorsUtils, 'fetchCreatorList').mockResolvedValue([[], 0]); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Acceptance criterion: Returns 200 with empty data array + it('returns HTTP 200 when no creators exist (not 404)', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + }); + + it('returns an empty data array when no creators exist', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body).toHaveProperty('data'); + expect(body.data).toHaveProperty('items'); + expect(Array.isArray(body.data.items)).toBe(true); + expect(body.data.items).toHaveLength(0); + }); + + // Acceptance criterion: meta.total is 0 + it('returns meta.total of 0 when no creators exist', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveProperty('meta'); + expect(body.data.meta).toHaveProperty('total', 0); + expect(typeof body.data.meta.total).toBe('number'); + }); + + // Acceptance criterion: meta.hasMore is false + it('returns meta.hasMore of false when no creators exist', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.meta).toHaveProperty('hasMore', false); + }); + + it('includes all required pagination metadata fields', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const meta = res.json.mock.calls[0][0].data.meta; + expect(meta).toHaveProperty('limit'); + expect(meta).toHaveProperty('offset'); + expect(meta).toHaveProperty('total'); + expect(meta).toHaveProperty('hasMore'); + expect(typeof meta.limit).toBe('number'); + expect(typeof meta.offset).toBe('number'); + expect(typeof meta.hasMore).toBe('boolean'); + }); + + it('applies default offset of 0 when not specified', async () => { + const req = makeReq(); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const meta = res.json.mock.calls[0][0].data.meta; + expect(meta.offset).toBe(0); + }); + + it('still returns 200 with empty array for explicit pagination params', async () => { + const req = makeReq({ limit: '50', offset: '100' }); + const res = makeRes(); + await httpListCreators(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(res.status).toHaveBeenCalledWith(200); + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); + + it('does not invoke the error handler (next) on a successful empty response', async () => { + const req = makeReq(); + const res = makeRes(); + const next = makeNext(); + await httpListCreators(req, res, next); + + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/modules/creators/creator-list-query-log.test.ts b/src/modules/creators/creator-list-query-log.test.ts new file mode 100644 index 0000000..a83926b --- /dev/null +++ b/src/modules/creators/creator-list-query-log.test.ts @@ -0,0 +1,203 @@ +// Unit test: fetchCreatorList emits a structured debug log after every query (#550) +// +// Verifies that: +// - logger.debug is called once after each creator list query +// - the log object contains result_count, filters, sort, and query_duration_ms +// - only active filter keys appear in the filters object +// - cursor is absent from the log output +// - query_duration_ms is a non-negative number +// +// Uses Jest mocks — no database required. + +import { fetchCreatorList } from './creators.utils'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; + +// ── Module mocks ─────────────────────────────────────────────────────────────── + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findMany: jest.fn(), + count: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +// Disable the cache so every call hits the mocked prisma +jest.mock('./creators.cache', () => ({ + getCachedCreatorList: jest.fn().mockReturnValue(null), + setCachedCreatorList: jest.fn(), +})); + +// ── Typed helpers ────────────────────────────────────────────────────────────── + +const mockPrisma = prisma as unknown as { + creatorProfile: { + findMany: jest.Mock; + count: jest.Mock; + }; +}; + +const mockLogger = logger as unknown as { + debug: jest.Mock; + warn: jest.Mock; +}; + +// ── Shared fixtures ──────────────────────────────────────────────────────────── + +const BASE_QUERY = { + limit: 20, + offset: 0, + sort: 'createdAt' as const, + order: 'desc' as const, + include: [] as never[], +}; + +const CREATOR_ROW = { + id: 'cid-1', + handle: 'creator1', + displayName: 'Creator One', + isVerified: false, + createdAt: new Date('2025-01-01T00:00:00Z'), +}; + +// ── Tests ────────────────────────────────────────────────────────────────────── + +describe('fetchCreatorList – structured debug log (#550)', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.creatorProfile.findMany.mockResolvedValue([]); + mockPrisma.creatorProfile.count.mockResolvedValue(0); + }); + + // ── Always emits ───────────────────────────────────────────────────────────── + + it('calls logger.debug exactly once per query', async () => { + await fetchCreatorList(BASE_QUERY as any); + + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + }); + + it('calls logger.debug even when result set is non-empty', async () => { + mockPrisma.creatorProfile.findMany.mockResolvedValue([CREATOR_ROW]); + mockPrisma.creatorProfile.count.mockResolvedValue(1); + + await fetchCreatorList(BASE_QUERY as any); + + expect(mockLogger.debug).toHaveBeenCalledTimes(1); + }); + + // ── result_count ────────────────────────────────────────────────────────────── + + it('log object contains result_count equal to the number of returned creators', async () => { + mockPrisma.creatorProfile.findMany.mockResolvedValue([CREATOR_ROW, CREATOR_ROW]); + mockPrisma.creatorProfile.count.mockResolvedValue(2); + + await fetchCreatorList(BASE_QUERY as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj).toHaveProperty('result_count', 2); + }); + + it('log object contains result_count of 0 when the list is empty', async () => { + await fetchCreatorList(BASE_QUERY as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj).toHaveProperty('result_count', 0); + }); + + // ── filters – only active keys ──────────────────────────────────────────────── + + it('filters object is empty when no filter params were provided', async () => { + await fetchCreatorList(BASE_QUERY as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj).toHaveProperty('filters'); + expect(logObj.filters).toEqual({}); + }); + + it('filters object contains only the verified key when only verified is provided', async () => { + await fetchCreatorList({ ...BASE_QUERY, verified: true } as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj.filters).toEqual({ verified: true }); + expect(logObj.filters).not.toHaveProperty('search'); + expect(logObj.filters).not.toHaveProperty('minPrice'); + expect(logObj.filters).not.toHaveProperty('maxPrice'); + }); + + it('filters object contains only the search key when only search is provided', async () => { + await fetchCreatorList({ ...BASE_QUERY, search: 'artist' } as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj.filters).toHaveProperty('search', 'artist'); + expect(logObj.filters).not.toHaveProperty('verified'); + }); + + it('filters object contains multiple keys when multiple filters are active', async () => { + await fetchCreatorList({ + ...BASE_QUERY, + verified: false, + search: 'music', + minPrice: BigInt(100), + } as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj.filters).toHaveProperty('verified', false); + expect(logObj.filters).toHaveProperty('search', 'music'); + expect(logObj.filters).toHaveProperty('minPrice'); + }); + + // ── cursor absent from log ──────────────────────────────────────────────────── + + it('cursor value is absent from the log output even when cursor is provided', async () => { + await fetchCreatorList({ + ...BASE_QUERY, + cursor: 'some-opaque-cursor-value', + } as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + const serialized = JSON.stringify(logObj); + expect(serialized).not.toContain('cursor'); + expect(serialized).not.toContain('some-opaque-cursor-value'); + }); + + // ── sort ────────────────────────────────────────────────────────────────────── + + it('log object contains the sort field from the query', async () => { + await fetchCreatorList({ ...BASE_QUERY, sort: 'createdAt' } as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj).toHaveProperty('sort', 'createdAt'); + }); + + // ── query_duration_ms ───────────────────────────────────────────────────────── + + it('log object contains query_duration_ms as a non-negative number', async () => { + await fetchCreatorList(BASE_QUERY as any); + + const [logObj] = mockLogger.debug.mock.calls[0]; + expect(logObj).toHaveProperty('query_duration_ms'); + expect(typeof logObj.query_duration_ms).toBe('number'); + expect(logObj.query_duration_ms).toBeGreaterThanOrEqual(0); + }); + + // ── log message ─────────────────────────────────────────────────────────────── + + it('log message is "Creator list query resolved"', async () => { + await fetchCreatorList(BASE_QUERY as any); + + const [, message] = mockLogger.debug.mock.calls[0]; + expect(message).toBe('Creator list query resolved'); + }); +}); diff --git a/src/modules/creators/creators.utils.ts b/src/modules/creators/creators.utils.ts index ce15bd4..ad40d1b 100644 --- a/src/modules/creators/creators.utils.ts +++ b/src/modules/creators/creators.utils.ts @@ -47,6 +47,26 @@ export async function fetchCreatorList( ]); const durationMs = Date.now() - start; + + // Emit a structured debug log after every creator list query (#550). + // Only include filter keys that were actually provided in the request; + // cursor is intentionally excluded from the log output. + const activeFilters: Record = {}; + if (verified !== undefined) activeFilters.verified = verified; + if (search !== undefined && search !== '') activeFilters.search = search; + if (minPrice !== undefined) activeFilters.minPrice = minPrice.toString(); + if (maxPrice !== undefined) activeFilters.maxPrice = maxPrice.toString(); + + logger.debug( + { + result_count: creators.length, + filters: activeFilters, + sort, + query_duration_ms: durationMs, + }, + 'Creator list query resolved' + ); + if (durationMs > envConfig.CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS) { // In debug (development) mode, capture the query execution plan so // missing indexes and inefficient joins are immediately visible in logs. diff --git a/src/utils/iso-timestamp.utils.test.ts b/src/utils/iso-timestamp.utils.test.ts index 92b18a1..d094aee 100644 --- a/src/utils/iso-timestamp.utils.test.ts +++ b/src/utils/iso-timestamp.utils.test.ts @@ -1,4 +1,4 @@ -import { formatIsoTimestamp } from './iso-timestamp.utils'; +import { formatIsoTimestamp, parseISOTimestamp, InvalidTimestamp } from './iso-timestamp.utils'; describe('formatIsoTimestamp()', () => { it('formats supported timestamp inputs with one ISO 8601 UTC representation', () => { @@ -15,3 +15,61 @@ describe('formatIsoTimestamp()', () => { expect(() => formatIsoTimestamp('not-a-date')).toThrow(RangeError); }); }); + +describe('parseISOTimestamp()', () => { + // Acceptance criterion: valid ISO string returns correct UTC Date + it('returns the correct UTC Date for a valid ISO 8601 string with Z designator', () => { + const input = '2024-01-02T03:04:05.678Z'; + const result = parseISOTimestamp(input); + + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toBe(input); + }); + + it('returns the correct UTC Date for a valid ISO 8601 string with offset designator', () => { + const result = parseISOTimestamp('2024-01-02T04:04:05.678+01:00'); + + expect(result).toBeInstanceOf(Date); + expect(result.toISOString()).toBe('2024-01-02T03:04:05.678Z'); + }); + + it('returns the correct UTC Date for a valid ISO 8601 string without milliseconds', () => { + const result = parseISOTimestamp('2024-06-15T12:00:00Z'); + + expect(result).toBeInstanceOf(Date); + expect(result.getUTCFullYear()).toBe(2024); + expect(result.getUTCMonth()).toBe(5); // June is month index 5 + expect(result.getUTCDate()).toBe(15); + }); + + // Acceptance criterion: invalid string throws InvalidTimestamp + it('throws InvalidTimestamp for a non-ISO string', () => { + expect(() => parseISOTimestamp('not-a-date')).toThrow(InvalidTimestamp); + }); + + it('thrown error identifies the bad input value', () => { + const value = 'garbage'; + try { + parseISOTimestamp(value); + fail('Expected InvalidTimestamp to be thrown'); + } catch (err) { + expect(err).toBeInstanceOf(InvalidTimestamp); + expect((err as InvalidTimestamp).message).toContain(value); + expect((err as InvalidTimestamp).name).toBe('InvalidTimestamp'); + } + }); + + // Acceptance criterion: empty string throws InvalidTimestamp + it('throws InvalidTimestamp for an empty string', () => { + expect(() => parseISOTimestamp('')).toThrow(InvalidTimestamp); + }); + + // Acceptance criterion: Unix timestamp string throws InvalidTimestamp + it('throws InvalidTimestamp for a Unix timestamp string', () => { + expect(() => parseISOTimestamp('1704153845000')).toThrow(InvalidTimestamp); + }); + + it('throws InvalidTimestamp for a bare date string (no time component)', () => { + expect(() => parseISOTimestamp('2024-01-02')).toThrow(InvalidTimestamp); + }); +}); diff --git a/src/utils/iso-timestamp.utils.ts b/src/utils/iso-timestamp.utils.ts index fa6469a..96de225 100644 --- a/src/utils/iso-timestamp.utils.ts +++ b/src/utils/iso-timestamp.utils.ts @@ -12,3 +12,61 @@ export function formatIsoTimestamp(value: TimestampInput): string { return date.toISOString(); } + +/** + * Typed error thrown when a string cannot be parsed as an ISO 8601 timestamp. + * + * Only thrown by {@link parseISOTimestamp}. Callers may use `instanceof + * InvalidTimestamp` to distinguish this error from other runtime failures. + */ +export class InvalidTimestamp extends Error { + constructor(value: string) { + super(`Invalid ISO 8601 timestamp: "${value}"`); + this.name = 'InvalidTimestamp'; + // Maintains correct prototype chain when compiled to ES5. + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** + * ISO 8601 datetime pattern. + * + * Requires the full date portion (YYYY-MM-DD), a T separator, a time portion, + * and a timezone designator (Z or ±HH:MM). Bare Unix timestamp strings like + * "1704153845000" are intentionally rejected. + */ +const ISO_8601_PATTERN = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + +/** + * Parses a strict ISO 8601 datetime string into a UTC `Date` object. + * + * Accepts strings of the form `YYYY-MM-DDTHH:mm:ss[.sss](Z|±HH:MM)`. + * Rejects empty strings, plain date strings (`2024-01-01`), and Unix + * timestamp strings (`"1704153845000"`). + * + * @param value - The string to parse. + * @returns A `Date` object representing the parsed UTC instant. + * @throws {InvalidTimestamp} If `value` is not a valid ISO 8601 datetime string. + * + * @example + * parseISOTimestamp('2024-01-02T03:04:05.678Z'); + * // => Date { 2024-01-02T03:04:05.678Z } + * + * parseISOTimestamp('not-a-date'); + * // throws InvalidTimestamp + */ +export function parseISOTimestamp(value: string): Date { + if (!ISO_8601_PATTERN.test(value)) { + throw new InvalidTimestamp(value); + } + + const date = new Date(value); + + // Guard against ISO-shaped strings that produce an invalid Date. + if (Number.isNaN(date.getTime())) { + throw new InvalidTimestamp(value); + } + + return date; +}