Просмотр исходного кода

Safer handling of backend date formats

and add frontend testing
Jamie Curnow 1 месяц назад
Родитель
Сommit
e4e3415120

+ 77 - 0
frontend/src/components/Table/Formatter/DateFormatter.test.tsx

@@ -0,0 +1,77 @@
+import { DateTimeFormat } from "src/locale";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+describe("DateFormatter", () => {
+	// Keep a reference to the real Intl to restore later
+	const RealIntl = global.Intl;
+	const desiredTimeZone = "Europe/London";
+	const desiredLocale = "en-GB";
+
+	beforeAll(() => {
+		// Ensure Node-based libs using TZ behave deterministically
+		try {
+			process.env.TZ = desiredTimeZone;
+		} catch {
+			// ignore if not available
+		}
+
+		// Mock Intl.DateTimeFormat so formatting is stable regardless of host
+		const MockedDateTimeFormat = class extends RealIntl.DateTimeFormat {
+			constructor(
+				_locales?: string | string[],
+				options?: Intl.DateTimeFormatOptions,
+			) {
+				super(desiredLocale, {
+					...options,
+					timeZone: desiredTimeZone,
+				});
+			}
+		} as unknown as typeof Intl.DateTimeFormat;
+
+		global.Intl = {
+			...RealIntl,
+			DateTimeFormat: MockedDateTimeFormat,
+		};
+	});
+
+	afterAll(() => {
+		// Restore original Intl after tests
+		global.Intl = RealIntl;
+	});
+
+	it("format date from iso date", () => {
+		const value = "2024-01-01T00:00:00.000Z";
+		const text = DateTimeFormat(value);
+		expect(text).toBe("Monday, 01/01/2024, 12:00:00 am");
+	});
+
+	it("format date from unix timestamp number", () => {
+		const value = 1762476112;
+		const text = DateTimeFormat(value);
+		expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
+	});
+
+	it("format date from unix timestamp string", () => {
+		const value = "1762476112";
+		const text = DateTimeFormat(value);
+		expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
+	});
+
+	it("catch bad format from string", () => {
+		const value = "this is not a good date";
+		const text = DateTimeFormat(value);
+		expect(text).toBe("this is not a good date");
+	});
+
+	it("catch bad format from number", () => {
+		const value = -100;
+		const text = DateTimeFormat(value);
+		expect(text).toBe("-100");
+	});
+
+	it("catch bad format from number as string", () => {
+		const value = "-100";
+		const text = DateTimeFormat(value);
+		expect(text).toBe("-100");
+	});
+});

+ 34 - 13
frontend/src/locale/DateTimeFormat.ts

@@ -1,15 +1,36 @@
-import { intlFormat, parseISO } from "date-fns";
-
-const DateTimeFormat = (isoDate: string) =>
-	intlFormat(parseISO(isoDate), {
-		weekday: "long",
-		year: "numeric",
-		month: "numeric",
-		day: "numeric",
-		hour: "numeric",
-		minute: "numeric",
-		second: "numeric",
-		hour12: true,
-	});
+import { fromUnixTime, intlFormat, parseISO } from "date-fns";
+
+const isUnixTimestamp = (value: unknown): boolean => {
+	if (typeof value !== "number" && typeof value !== "string") return false;
+	const num = Number(value);
+	if (!Number.isFinite(num)) return false;
+	// Check plausible Unix timestamp range: from 1970 to ~year 3000
+	// Support both seconds and milliseconds
+	if (num > 0 && num < 10000000000) return true; // seconds (<= 10 digits)
+	if (num >= 10000000000 && num < 32503680000000) return true; // milliseconds (<= 13 digits)
+	return false;
+};
+
+const DateTimeFormat = (value: string | number): string => {
+	if (typeof value !== "number" && typeof value !== "string") return `${value}`;
+
+	try {
+		const d = isUnixTimestamp(value)
+			? fromUnixTime(+value)
+			: parseISO(`${value}`);
+		return intlFormat(d, {
+			weekday: "long",
+			year: "numeric",
+			month: "numeric",
+			day: "numeric",
+			hour: "numeric",
+			minute: "numeric",
+			second: "numeric",
+			hour12: true,
+		});
+	} catch {
+		return `${value}`;
+	}
+};
 
 export { DateTimeFormat };

+ 17 - 2
frontend/src/locale/IntlProvider.tsx

@@ -30,13 +30,20 @@ const getLocale = (short = false) => {
 	if (short) {
 		return loc.slice(0, 2);
 	}
+	// finally, fallback
+	if (!loc) {
+		loc = "en";
+	}
 	return loc;
 };
 
 const cache = createIntlCache();
 
 const initialMessages = loadMessages(getLocale());
-let intl = createIntl({ locale: getLocale(), messages: initialMessages }, cache);
+let intl = createIntl(
+	{ locale: getLocale(), messages: initialMessages },
+	cache,
+);
 
 const changeLocale = (locale: string): void => {
 	const messages = loadMessages(locale);
@@ -76,4 +83,12 @@ const T = ({
 	);
 };
 
-export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };
+export {
+	localeOptions,
+	getFlagCodeForLocale,
+	getLocale,
+	createIntl,
+	changeLocale,
+	intl,
+	T,
+};

+ 1 - 1
scripts/ci/frontend-build

@@ -16,7 +16,7 @@ if hash docker 2>/dev/null; then
 		-e NODE_OPTIONS=--openssl-legacy-provider \
 		-v "$(pwd)/frontend:/app/frontend" \
 		-w /app/frontend "${DOCKER_IMAGE}" \
-		sh -c "yarn install && yarn lint && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
+		sh -c "yarn install && yarn lint && yarn vitest run && yarn build && chown -R $(id -u):$(id -g) /app/frontend"
 
 	echo -e "${BLUE}❯ ${GREEN}Building Frontend Complete${RESET}"
 else