diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index a629981..3564717 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -24,6 +24,7 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
@@ -126,7 +127,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -457,7 +457,6 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -506,15 +505,35 @@
}
],
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19.0"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
+ "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
+ "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@emnapi/wasi-threads": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
- "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
+ "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"dependencies": {
@@ -1066,6 +1085,37 @@
"node": "^20.19.0 || >=22.12.0"
}
},
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
+ "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.1",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+ "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
+ "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz",
@@ -1466,6 +1516,20 @@
}
}
},
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1481,7 +1545,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/@types/chai": {
"version": "5.2.3",
@@ -1624,7 +1689,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1635,7 +1699,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1690,7 +1753,6 @@
"integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.10",
@@ -1835,7 +1897,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1888,6 +1949,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=8"
}
@@ -2043,7 +2105,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2542,7 +2603,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/dunder-proto": {
"version": "1.0.1",
@@ -2682,7 +2744,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3980,6 +4041,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -4895,7 +4957,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -4961,6 +5022,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -4976,6 +5038,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=10"
},
@@ -4988,7 +5051,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/property-information": {
"version": "7.1.0",
@@ -5024,7 +5088,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5034,7 +5097,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -5081,7 +5143,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -5186,8 +5247,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -5476,8 +5536,7 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
@@ -5835,7 +5894,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
"integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
@@ -5914,7 +5972,6 @@
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.1.10",
"@vitest/mocker": "4.1.10",
@@ -6133,7 +6190,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
- "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/frontend/package.json b/frontend/package.json
index 4a3f342..6133822 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -28,6 +28,7 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^7.0.0",
"@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
diff --git a/frontend/src/components/contests/ContestCountdown.test.jsx b/frontend/src/components/contests/ContestCountdown.test.jsx
new file mode 100644
index 0000000..d86de43
--- /dev/null
+++ b/frontend/src/components/contests/ContestCountdown.test.jsx
@@ -0,0 +1,39 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import ContestCountdown from "./ContestCountdown";
+
+describe("ContestCountdown", () => {
+ it("shows 'Live Now' when isRunning is true", () => {
+ render();
+ expect(screen.getByText("Live Now")).toBeInTheDocument();
+ });
+
+ it("shows 'System Testing' when isTesting is true and not running", () => {
+ render();
+ expect(screen.getByText("System Testing")).toBeInTheDocument();
+ });
+
+ it("prioritizes 'Live Now' over 'System Testing' if both were somehow true", () => {
+ render();
+ expect(screen.getByText("Live Now")).toBeInTheDocument();
+ expect(screen.queryByText("System Testing")).not.toBeInTheDocument();
+ });
+
+ it("shows 'Ended' when msUntilStart is <= 0 and not running or testing", () => {
+ render();
+ expect(screen.getByText("Ended")).toBeInTheDocument();
+ });
+
+ it("shows an hh:mm:ss countdown for an upcoming contest under a day away", () => {
+ const twoHours = 2 * 60 * 60 * 1000 + 30 * 60 * 1000 + 5 * 1000; // 2h 30m 5s
+ render();
+ expect(screen.getByText("02:30:05")).toBeInTheDocument();
+ });
+
+ it("prefixes with 'Nd' for a multi-day countdown", () => {
+ const threeDays = 3 * 86400 * 1000 + 1 * 60 * 60 * 1000; // 3d 1h 0m 0s
+ render();
+ expect(screen.getByText(/3d/)).toBeInTheDocument();
+ expect(screen.getByText(/01:00:00/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/contests/ContestReminderBell.jsx b/frontend/src/components/contests/ContestReminderBell.jsx
index 03b504c..69bfadd 100644
--- a/frontend/src/components/contests/ContestReminderBell.jsx
+++ b/frontend/src/components/contests/ContestReminderBell.jsx
@@ -1,31 +1,53 @@
+import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { Bell } from "lucide-react";
import { useAuth } from "../../context/AuthContext";
import { useReminders } from "../../context/ReminderContext";
const DUE_SOON_WINDOW_MS = 24 * 60 * 60 * 1000; // badge counts contests starting in <24h
+const NOW_REFRESH_INTERVAL_MS = 60_000; // due-soon badge only needs minute-level freshness
export default function ContestReminderBell() {
const { isAuthenticated } = useAuth();
const { reminders } = useReminders();
+ // Date.now() is an impure call — it can't be invoked directly in the
+ // render body (react-hooks/purity). Reading it once via useState's lazy
+ // initializer, then refreshing on an interval, matches the same pattern
+ // already used for "now" elsewhere in this codebase (see useContests.js).
+ const [now, setNow] = useState(() => Date.now());
+
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), NOW_REFRESH_INTERVAL_MS);
+ return () => clearInterval(id);
+ }, []);
+
+ // All hooks above must run unconditionally before this early return.
if (!isAuthenticated) return null;
- const now = Date.now(); // eslint-disable-line
const dueSoonCount = reminders.filter((c) => {
const msUntilStart = c.startTimeSeconds * 1000 - now;
return msUntilStart > 0 && msUntilStart < DUE_SOON_WINDOW_MS;
}).length;
+ const accessibleLabel =
+ dueSoonCount > 0
+ ? `Upcoming contest reminders, ${dueSoonCount} due within 24 hours`
+ : "Upcoming contest reminders";
+
return (
-
+
{dueSoonCount > 0 && (
-
+
{dueSoonCount > 9 ? "9+" : dueSoonCount}
)}
diff --git a/frontend/src/components/contests/ContestReminderBell.test.jsx b/frontend/src/components/contests/ContestReminderBell.test.jsx
new file mode 100644
index 0000000..44007c5
--- /dev/null
+++ b/frontend/src/components/contests/ContestReminderBell.test.jsx
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import userEvent from "@testing-library/user-event";
+import ContestReminderBell from "./ContestReminderBell";
+
+const mockUseAuth = vi.fn();
+vi.mock("../../context/AuthContext", () => ({
+ useAuth: () => mockUseAuth(),
+}));
+
+const mockUseReminders = vi.fn();
+vi.mock("../../context/ReminderContext", () => ({
+ useReminders: () => mockUseReminders(),
+}));
+
+const renderBell = () =>
+ render(
+
+
+
+ );
+
+const inHours = (h) => Date.now() + h * 60 * 60 * 1000;
+
+describe("ContestReminderBell accessibility", () => {
+ it("renders nothing when logged out", () => {
+ mockUseAuth.mockReturnValue({ isAuthenticated: false });
+ mockUseReminders.mockReturnValue({ reminders: [] });
+
+ const { container } = renderBell();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("has an accessible name (not just a visual title) with no due-soon reminders", () => {
+ mockUseAuth.mockReturnValue({ isAuthenticated: true });
+ mockUseReminders.mockReturnValue({ reminders: [] });
+
+ renderBell();
+ expect(screen.getByRole("link", { name: "Upcoming contest reminders" })).toBeInTheDocument();
+ });
+
+ it("includes the due-soon count in the accessible name when reminders are due within 24h", () => {
+ mockUseAuth.mockReturnValue({ isAuthenticated: true });
+ mockUseReminders.mockReturnValue({
+ reminders: [
+ { contestId: 1, startTimeSeconds: inHours(2) / 1000 },
+ { contestId: 2, startTimeSeconds: inHours(10) / 1000 },
+ ],
+ });
+
+ renderBell();
+ expect(
+ screen.getByRole("link", { name: "Upcoming contest reminders, 2 due within 24 hours" })
+ ).toBeInTheDocument();
+ });
+
+ it("excludes contests further than 24h out and already-started contests from the count", () => {
+ mockUseAuth.mockReturnValue({ isAuthenticated: true });
+ mockUseReminders.mockReturnValue({
+ reminders: [
+ { contestId: 1, startTimeSeconds: inHours(2) / 1000 }, // due soon — counted
+ { contestId: 2, startTimeSeconds: inHours(48) / 1000 }, // too far out — not counted
+ { contestId: 3, startTimeSeconds: inHours(-1) / 1000 }, // already started — not counted
+ ],
+ });
+
+ renderBell();
+ expect(
+ screen.getByRole("link", { name: "Upcoming contest reminders, 1 due within 24 hours" })
+ ).toBeInTheDocument();
+ });
+
+ it("marks the decorative bell icon and count badge as aria-hidden, so they aren't double-announced alongside the aria-label", () => {
+ mockUseAuth.mockReturnValue({ isAuthenticated: true });
+ mockUseReminders.mockReturnValue({
+ reminders: [{ contestId: 1, startTimeSeconds: inHours(1) / 1000 }],
+ });
+
+ const { container } = renderBell();
+ const hiddenElements = container.querySelectorAll('[aria-hidden="true"]');
+ expect(hiddenElements.length).toBeGreaterThanOrEqual(2); // icon + badge
+ });
+
+ it("is reachable via keyboard (native /Link, focusable by default, no positive tabIndex hacks)", async () => {
+ mockUseAuth.mockReturnValue({ isAuthenticated: true });
+ mockUseReminders.mockReturnValue({ reminders: [] });
+
+ const user = userEvent.setup();
+ renderBell();
+
+ await user.tab();
+ expect(screen.getByRole("link", { name: "Upcoming contest reminders" })).toHaveFocus();
+ });
+});
diff --git a/frontend/src/hooks/useContests.js b/frontend/src/hooks/useContests.js
index 73e8ead..e736d44 100644
--- a/frontend/src/hooks/useContests.js
+++ b/frontend/src/hooks/useContests.js
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useMemo } from "react";
+import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useAuth } from "../context/AuthContext";
import {
getUpcomingCodeforcesContests,
@@ -16,26 +16,40 @@ export const useContests = () => {
const [error, setError] = useState(null);
const [now, setNow] = useState(Date.now());
+ // Each fetch increments its own counter before firing; a response only
+ // gets applied if the counter still matches when it resolves. This
+ // guards against: (a) a slower earlier request resolving after a newer
+ // one (e.g. rapid refetch/retry), and (b) any response resolving after
+ // unmount (the cleanup effect below bumps both counters on unmount, so
+ // no in-flight request's captured id can ever match again).
+ const contestsRequestId = useRef(0);
+ const remindersRequestId = useRef(0);
+
const fetchContests = useCallback(async () => {
+ const requestId = ++contestsRequestId.current;
setLoading(true);
setError(null);
try {
const { data } = await getUpcomingCodeforcesContests();
+ if (requestId !== contestsRequestId.current) return; // superseded or unmounted
setContests(data.data || []);
} catch (err) {
+ if (requestId !== contestsRequestId.current) return;
setError(err.response?.data?.message || "Failed to load upcoming contests.");
} finally {
- setLoading(false);
+ if (requestId === contestsRequestId.current) setLoading(false);
}
}, []);
const fetchReminders = useCallback(async () => {
+ const requestId = ++remindersRequestId.current;
if (!isAuthenticated) {
setReminderIds([]);
return;
}
try {
const { data } = await getMyReminderIds();
+ if (requestId !== remindersRequestId.current) return;
setReminderIds(data.data || []);
} catch {
// Non-fatal
@@ -55,6 +69,15 @@ export const useContests = () => {
return () => clearInterval(id);
}, []);
+ // On unmount, invalidate any still-in-flight requests so their eventual
+ // resolution can never pass the requestId check above.
+ useEffect(() => {
+ return () => {
+ contestsRequestId.current += 1;
+ remindersRequestId.current += 1;
+ };
+ }, []);
+
const toggleReminder = async (contestId) => {
const hasReminder = reminderIds.includes(contestId);
setReminderIds((prev) =>
diff --git a/frontend/src/hooks/useContests.test.js b/frontend/src/hooks/useContests.test.js
new file mode 100644
index 0000000..3fd5337
--- /dev/null
+++ b/frontend/src/hooks/useContests.test.js
@@ -0,0 +1,131 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook, act, waitFor } from "@testing-library/react";
+import { useContests } from "./useContests";
+
+const mockUseAuth = vi.fn();
+vi.mock("../context/AuthContext", () => ({
+ useAuth: () => mockUseAuth(),
+}));
+
+const mockGetUpcomingCodeforcesContests = vi.fn();
+const mockGetMyReminderIds = vi.fn();
+const mockAddContestReminder = vi.fn();
+const mockRemoveContestReminder = vi.fn();
+
+vi.mock("../services/contestService", () => ({
+ getUpcomingCodeforcesContests: (...args) => mockGetUpcomingCodeforcesContests(...args),
+ getMyReminderIds: (...args) => mockGetMyReminderIds(...args),
+ addContestReminder: (...args) => mockAddContestReminder(...args),
+ removeContestReminder: (...args) => mockRemoveContestReminder(...args),
+}));
+
+const CONTEST_A = { contestId: 1, name: "Round A", phase: "BEFORE", startTimeSeconds: 9999999999, durationSeconds: 7200 };
+
+describe("useContests", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ mockUseAuth.mockReturnValue({ isAuthenticated: true });
+ mockGetUpcomingCodeforcesContests.mockResolvedValue({ data: { data: [CONTEST_A] } });
+ mockGetMyReminderIds.mockResolvedValue({ data: { data: [] } });
+ });
+
+ it("loads contests and reminder ids on mount", async () => {
+ const { result } = renderHook(() => useContests());
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(result.current.contests).toHaveLength(1);
+ expect(result.current.contests[0].hasReminder).toBe(false);
+ });
+
+ it("optimistically marks a reminder as set immediately, before the request resolves", async () => {
+ let resolveAdd;
+ mockAddContestReminder.mockReturnValue(new Promise((resolve) => { resolveAdd = resolve; }));
+
+ const { result } = renderHook(() => useContests());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ act(() => {
+ result.current.toggleReminder(1);
+ });
+
+ // Optimistic update should be visible immediately, without awaiting the request.
+ expect(result.current.contests[0].hasReminder).toBe(true);
+
+ await act(async () => {
+ resolveAdd({ data: {} });
+ });
+ });
+
+ it("rolls back the optimistic update if the add request fails", async () => {
+ mockAddContestReminder.mockRejectedValue(new Error("network error"));
+
+ const { result } = renderHook(() => useContests());
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ await act(async () => {
+ await expect(result.current.toggleReminder(1)).rejects.toThrow();
+ });
+
+ expect(result.current.contests[0].hasReminder).toBe(false);
+ });
+
+ it("rolls back the optimistic removal if the remove request fails", async () => {
+ mockGetMyReminderIds.mockResolvedValue({ data: { data: [1] } });
+ mockRemoveContestReminder.mockRejectedValue(new Error("network error"));
+
+ const { result } = renderHook(() => useContests());
+ await waitFor(() => expect(result.current.contests[0]?.hasReminder).toBe(true));
+
+ await act(async () => {
+ await expect(result.current.toggleReminder(1)).rejects.toThrow();
+ });
+
+ expect(result.current.contests[0].hasReminder).toBe(true);
+ });
+
+ it("a slower earlier fetch cannot overwrite a newer, faster refetch's result (stale-response guard)", async () => {
+ let resolveFirst;
+ const firstCall = new Promise((resolve) => { resolveFirst = resolve; });
+ const CONTEST_STALE = { ...CONTEST_A, contestId: 1, name: "Stale Data" };
+ const CONTEST_FRESH = { ...CONTEST_A, contestId: 2, name: "Fresh Data" };
+
+ mockGetUpcomingCodeforcesContests
+ .mockImplementationOnce(() => firstCall) // initial mount fetch — never resolves until we say so
+ .mockResolvedValueOnce({ data: { data: [CONTEST_FRESH] } }); // the refetch — resolves immediately
+
+ const { result } = renderHook(() => useContests());
+
+ // Fire a refetch before the initial (slower) request has resolved.
+ await act(async () => {
+ await result.current.refetch();
+ });
+
+ expect(result.current.contests[0]?.name).toBe("Fresh Data");
+
+ // Now let the original, slower request resolve late.
+ await act(async () => {
+ resolveFirst({ data: { data: [CONTEST_STALE] } });
+ });
+
+ // The stale response must NOT have overwritten the newer data.
+ expect(result.current.contests[0]?.name).toBe("Fresh Data");
+ });
+
+ it("does not crash or warn when a fetch resolves after unmount (React 18 already no-ops post-unmount setState silently; the requestId guard's actual regression coverage is the stale-response race test above)", async () => {
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ let resolveFetch;
+ mockGetUpcomingCodeforcesContests.mockReturnValue(
+ new Promise((resolve) => { resolveFetch = resolve; })
+ );
+
+ const { unmount } = renderHook(() => useContests());
+ unmount();
+
+ await act(async () => {
+ resolveFetch({ data: { data: [CONTEST_A] } });
+ });
+
+ expect(errorSpy).not.toHaveBeenCalled();
+ errorSpy.mockRestore();
+ });
+});
diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx
index 605d423..4bf8b1a 100644
--- a/frontend/src/pages/LandingPage.jsx
+++ b/frontend/src/pages/LandingPage.jsx
@@ -15,9 +15,11 @@ function useWordCycle(words) {
return () => clearTimeout(timeout);
}
if (subIndex === 0 && reverse) {
- setReverse(false);
- setIndex((prev) => (prev + 1) % words.length);
- return;
+ const timeout = setTimeout(() => {
+ setReverse(false);
+ setIndex((prev) => (prev + 1) % words.length);
+ }, 0);
+ return () => clearTimeout(timeout);
}
const timeout = setTimeout(() => {
diff --git a/server/models/Contest.js b/server/models/Contest.js
index d9b753d..395ea2e 100644
--- a/server/models/Contest.js
+++ b/server/models/Contest.js
@@ -7,14 +7,23 @@ import mongoose from "mongoose";
*/
const ContestSchema = new mongoose.Schema(
{
+ // NOTE: no standalone index on `platform` here. Every current query
+ // filters on platform ALONGSIDE contestId or phase in the same query
+ // (see repository.js) — never on platform alone. The compound unique
+ // index below already covers platform via its leftmost-prefix
+ // behavior for any query that touches it, so a separate index here
+ // would be redundant. See INDEXES.md for the full rationale.
platform: {
type: String,
enum: ["codeforces"],
default: "codeforces",
required: true,
- index: true,
},
- contestId: { type: Number, required: true, index: true },
+ // NOTE: no standalone index here either, for the same reason — every
+ // query that filters on contestId also filters on platform in the
+ // same query (findByContestId, bulkUpsertContests' upsert filter),
+ // which the compound unique index below already fully covers.
+ contestId: { type: Number, required: true },
name: { type: String, required: true },
type: { type: String, default: "CF" },
phase: { type: String, required: true, index: true },
diff --git a/server/modules/contests/INDEXES.md b/server/modules/contests/INDEXES.md
new file mode 100644
index 0000000..9f1d4c8
--- /dev/null
+++ b/server/modules/contests/INDEXES.md
@@ -0,0 +1,64 @@
+# Contest collection index rationale
+
+This documents why each index on the `contests` collection exists, so future
+changes can evaluate additions/removals against actual query patterns
+instead of adding indexes defensively.
+
+## Current indexes
+
+| Index | Backs |
+|---|---|
+| `{ platform: 1, contestId: 1 }` (unique) | `findByContestId` and `bulkUpsertContests`'s upsert filter (repository.js) always filter on `platform` + `contestId` together — this index serves both. It also enforces the one-document-per-(platform, contestId) invariant. Its leftmost prefix (`platform`) additionally covers any query that filters on `platform` alone, though no such query currently exists (see below). |
+| `{ phase: 1 }` | `getUpcomingContests`, `getNonFinishedContestIds`, `pruneStaleReminders`, `getActiveReminderContests` all filter on `phase`. Note: this is a single-field index, not part of a compound index with `platform` — `{ platform: 1, contestId: 1 }` does not include `phase`, so queries filtering on both `platform` and `phase` currently use the `phase` index (or the planner's choice between the two single-field indexes), not the compound one. See "Future consideration" below for a compound index that would serve this shape directly. |
+| `{ startTimeSeconds: 1 }` | `getUpcomingContests`'s sort, and range queries if added later (e.g. "contests starting in the next N hours"). |
+
+## What was removed and why
+
+Two standalone indexes previously existed alongside the compound unique
+`{ platform: 1, contestId: 1 }` index:
+
+- **`{ contestId: 1 }`** - no query in the codebase ever filters on
+ `contestId` without also filtering on `platform` in the same query. The
+ compound index already covers every such lookup.
+- **`{ platform: 1 }`** - likewise, no query filters on `platform` alone;
+ every `Contest.find`/`findOne` call in `repository.js` filters on
+ `platform` combined with either `contestId` or `phase`. The compound
+ index's leftmost-prefix property means MongoDB can already use it for a
+ platform-only query if one is ever added, without needing a dedicated
+ single-field index for it today.
+
+Both added write overhead (one more index entry to maintain per
+insert/upsert) without ever being an index the query planner would choose
+over the existing ones for the queries this codebase actually runs.
+
+## Applying this in an already-deployed environment
+
+Changing the Mongoose schema does **not** drop an index from an existing
+database, indexes are a database-level construct, and Mongoose's
+`autoIndex` (used in dev) only ever *adds* missing indexes, it never drops
+ones no longer declared in the schema. To remove both standalone indexes
+from a real deployment, run the migration script once against that
+environment, from the **repository root**:
+
+```bash
+node server/scripts/dropRedundantContestIndex.js
+```
+
+It's idempotent, safe to run again if the indexes are already gone. It
+also refuses to run (throws, does not drop anything) if the compound
+unique `{ platform: 1, contestId: 1 }` index isn't present, since dropping
+the standalone indexes without that replacement in place would leave
+`platform`/`contestId` lookups unindexed and remove the uniqueness
+guarantee.
+
+## Future consideration (not applied in this change)
+
+`getUpcomingContests` filters on `{ platform, phase }` and sorts on
+`startTimeSeconds`, a compound index `{ platform: 1, phase: 1,
+startTimeSeconds: 1 }` would let MongoDB satisfy that entire query
+(filter + sort) from a single index scan, rather than choosing between the
+separate `phase` and `startTimeSeconds` indexes. This wasn't rolled into
+this change since it requires validating against real query volume /
+`explain()` output in a representative environment rather than reasoning
+about it in the abstract, flagged here for whoever picks up index tuning
+next.
diff --git a/server/modules/contests/controller.js b/server/modules/contests/controller.js
index eb42339..6e7c371 100644
--- a/server/modules/contests/controller.js
+++ b/server/modules/contests/controller.js
@@ -43,11 +43,9 @@ class ContestController {
static async removeReminder(req, res, next) {
try {
- const contestId = parseInt(req.params.contestId, 10);
- if (Number.isNaN(contestId)) {
- throw new ApiError(400, "Invalid contest id.");
- }
- const result = await ContestService.removeReminder(req.user._id, contestId);
+ // req.params.contestId is already a validated, coerced number here —
+ // see validateParams(contestIdParamSchema) in routes.js.
+ const result = await ContestService.removeReminder(req.user._id, req.params.contestId);
res.status(200).json(ApiResponse.success(result.message, result));
} catch (err) {
next(err instanceof ApiError ? err : new ApiError(500, err.message));
@@ -56,11 +54,7 @@ class ContestController {
static async markNotified(req, res, next) {
try {
- const contestId = parseInt(req.params.contestId, 10);
- if (Number.isNaN(contestId)) {
- throw new ApiError(400, "Invalid contest id.");
- }
- const result = await ContestService.markReminderNotified(req.user._id, contestId);
+ const result = await ContestService.markReminderNotified(req.user._id, req.params.contestId);
res.status(200).json(ApiResponse.success(result.message, result));
} catch (err) {
next(err instanceof ApiError ? err : new ApiError(500, err.message));
diff --git a/server/modules/contests/repository.test.js b/server/modules/contests/repository.test.js
new file mode 100644
index 0000000..8534fd1
--- /dev/null
+++ b/server/modules/contests/repository.test.js
@@ -0,0 +1,117 @@
+import { test, describe, mock, beforeEach } from "node:test";
+import assert from "node:assert/strict";
+import Contest from "../../models/Contest.js";
+import ContestReminder from "../../models/ContestReminder.js";
+import ContestRepository from "./repository.js";
+
+describe("ContestRepository.addReminder idempotency", () => {
+ beforeEach(() => mock.restoreAll());
+
+ test("uses an upsert with $setOnInsert so calling it twice for the same reminder does not duplicate or overwrite", async () => {
+ let capturedFilter, capturedUpdate, capturedOptions;
+ mock.method(ContestReminder, "findOneAndUpdate", async (filter, update, options) => {
+ capturedFilter = filter;
+ capturedUpdate = update;
+ capturedOptions = options;
+ return { user: filter.user, platform: filter.platform, contestId: filter.contestId };
+ });
+
+ await ContestRepository.addReminder("user1", "codeforces", 42);
+
+ assert.deepEqual(capturedFilter, { user: "user1", platform: "codeforces", contestId: 42 });
+ assert.ok(capturedUpdate.$setOnInsert, "expected $setOnInsert so a repeat call is a no-op, not an overwrite");
+ assert.equal(capturedOptions.upsert, true);
+ });
+});
+
+describe("ContestRepository.getUpcomingContests", () => {
+ beforeEach(() => mock.restoreAll());
+
+ test("filters to BEFORE/CODING/testing phases and sorts ascending by start time", async () => {
+ let capturedFilter, capturedSort;
+ const chain = {
+ sort: (sortArg) => { capturedSort = sortArg; return chain; },
+ lean: async () => [],
+ };
+ mock.method(Contest, "find", (filter) => { capturedFilter = filter; return chain; });
+
+ await ContestRepository.getUpcomingContests("codeforces");
+
+ assert.equal(capturedFilter.platform, "codeforces");
+ assert.deepEqual(
+ capturedFilter.phase.$in.slice().sort(),
+ ["BEFORE", "CODING", "PENDING_SYSTEM_TEST", "SYSTEM_TEST"].sort()
+ );
+ assert.deepEqual(capturedSort, { startTimeSeconds: 1 });
+ });
+});
+
+describe("ContestRepository.getActiveReminderContests", () => {
+ beforeEach(() => mock.restoreAll());
+
+ test("joins reminders to their contest docs, attaches notifiedAt, and drops reminders for contests no longer BEFORE/CODING", async () => {
+ mock.method(ContestReminder, "find", () => ({
+ lean: async () => [
+ { contestId: 1, notifiedAt: null },
+ { contestId: 2, notifiedAt: new Date("2026-01-01") },
+ { contestId: 3, notifiedAt: null }, // this contest has since finished — should be excluded
+ ],
+ }));
+ mock.method(Contest, "find", () => ({
+ lean: async () => [
+ { contestId: 1, name: "Round A", startTimeSeconds: 200 },
+ { contestId: 2, name: "Round B", startTimeSeconds: 100 },
+ // contestId 3 intentionally absent — Contest.find's own phase
+ // filter already excludes it, simulating a since-finished contest.
+ ],
+ }));
+
+ const result = await ContestRepository.getActiveReminderContests("user1", "codeforces");
+
+ assert.equal(result.length, 2);
+ // Sorted ascending by startTimeSeconds.
+ assert.deepEqual(result.map((r) => r.contestId), [2, 1]);
+ assert.equal(result.find((r) => r.contestId === 2).notifiedAt.toISOString(), new Date("2026-01-01").toISOString());
+ assert.equal(result.find((r) => r.contestId === 1).notifiedAt, null);
+ });
+
+ test("returns an empty array without querying Contest at all when the user has no reminders", async () => {
+ mock.method(ContestReminder, "find", () => ({ lean: async () => [] }));
+ let contestFindCalled = false;
+ mock.method(Contest, "find", () => { contestFindCalled = true; return { lean: async () => [] }; });
+
+ const result = await ContestRepository.getActiveReminderContests("user1", "codeforces");
+
+ assert.deepEqual(result, []);
+ assert.equal(contestFindCalled, false);
+ });
+});
+
+describe("ContestRepository.pruneStaleReminders", () => {
+ beforeEach(() => mock.restoreAll());
+
+ test("deletes reminders for contests outside BEFORE/CODING", async () => {
+ mock.method(Contest, "find", () => ({
+ select: () => ({ lean: async () => [{ contestId: 7 }, { contestId: 9 }] }),
+ }));
+ let deleteFilter;
+ mock.method(ContestReminder, "deleteMany", async (filter) => { deleteFilter = filter; });
+
+ await ContestRepository.pruneStaleReminders("codeforces");
+
+ assert.equal(deleteFilter.platform, "codeforces");
+ assert.deepEqual(deleteFilter.contestId.$in.slice().sort(), [7, 9]);
+ });
+
+ test("does not call deleteMany at all when there are no stale contests", async () => {
+ mock.method(Contest, "find", () => ({
+ select: () => ({ lean: async () => [] }),
+ }));
+ let deleteManyCalled = false;
+ mock.method(ContestReminder, "deleteMany", async () => { deleteManyCalled = true; });
+
+ await ContestRepository.pruneStaleReminders("codeforces");
+
+ assert.equal(deleteManyCalled, false);
+ });
+});
diff --git a/server/modules/contests/routes.js b/server/modules/contests/routes.js
index 8f44ab1..9318b61 100644
--- a/server/modules/contests/routes.js
+++ b/server/modules/contests/routes.js
@@ -1,7 +1,7 @@
import { Router } from "express";
import authMiddleware from "../../middlewares/authMiddleware.js";
import ContestController from "./controller.js";
-import { validate, addReminderSchema } from "./validation.js";
+import { validate, validateParams, addReminderSchema, contestIdParamSchema } from "./validation.js";
const router = Router();
@@ -12,7 +12,15 @@ router.use(authMiddleware);
router.get("/reminders", ContestController.getReminderIds);
router.get("/reminders/active", ContestController.getActiveReminders);
router.post("/reminders", validate(addReminderSchema), ContestController.addReminder);
-router.delete("/reminders/:contestId", ContestController.removeReminder);
-router.post("/reminders/:contestId/notified", ContestController.markNotified);
+router.delete(
+ "/reminders/:contestId",
+ validateParams(contestIdParamSchema),
+ ContestController.removeReminder
+);
+router.post(
+ "/reminders/:contestId/notified",
+ validateParams(contestIdParamSchema),
+ ContestController.markNotified
+);
export default router;
diff --git a/server/modules/contests/service.test.js b/server/modules/contests/service.test.js
index 48382e9..6129f03 100644
--- a/server/modules/contests/service.test.js
+++ b/server/modules/contests/service.test.js
@@ -75,3 +75,89 @@ describe("ContestService.syncCodeforcesContests phase reconciliation", () => {
assert.equal(upsertedDocs[0].phase, "BEFORE");
});
});
+
+describe("ContestService reminder lifecycle", () => {
+ beforeEach(() => mock.restoreAll());
+
+ test("addReminder throws 404 when the contest does not exist", async () => {
+ mock.method(ContestRepository, "findByContestId", async () => null);
+
+ await assert.rejects(
+ ContestService.addReminder("user1", 999),
+ (err) => {
+ assert.equal(err.statusCode, 404);
+ return true;
+ }
+ );
+ });
+
+ test("addReminder throws 400 when the contest is not BEFORE/CODING (e.g. FINISHED)", async () => {
+ mock.method(ContestRepository, "findByContestId", async () => ({
+ name: "Old Round",
+ phase: "FINISHED",
+ }));
+
+ await assert.rejects(
+ ContestService.addReminder("user1", 1),
+ (err) => {
+ assert.equal(err.statusCode, 400);
+ return true;
+ }
+ );
+ });
+
+ test("addReminder throws 400 for a contest mid system-test, not just FINISHED", async () => {
+ mock.method(ContestRepository, "findByContestId", async () => ({
+ name: "Testing Round",
+ phase: "SYSTEM_TEST",
+ }));
+
+ await assert.rejects(
+ ContestService.addReminder("user1", 1),
+ (err) => {
+ assert.equal(err.statusCode, 400);
+ return true;
+ }
+ );
+ });
+
+ test("addReminder succeeds for a BEFORE-phase contest and persists it", async () => {
+ mock.method(ContestRepository, "findByContestId", async () => ({
+ name: "Upcoming Round",
+ phase: "BEFORE",
+ }));
+ let persistedArgs;
+ mock.method(ContestRepository, "addReminder", async (...args) => {
+ persistedArgs = args;
+ });
+
+ const result = await ContestService.addReminder("user1", 42);
+
+ assert.deepEqual(persistedArgs, ["user1", "codeforces", 42]);
+ assert.match(result.message, /Upcoming Round/);
+ });
+
+ test("removeReminder delegates to the repository with the correct arguments", async () => {
+ let calledWith;
+ mock.method(ContestRepository, "removeReminder", async (...args) => {
+ calledWith = args;
+ });
+
+ const result = await ContestService.removeReminder("user1", 42);
+
+ assert.deepEqual(calledWith, ["user1", "codeforces", 42]);
+ assert.equal(result.contestId, 42);
+ });
+
+ test("markReminderNotified delegates to the repository with the correct arguments", async () => {
+ let calledWith;
+ mock.method(ContestRepository, "markNotified", async (...args) => {
+ calledWith = args;
+ });
+
+ const result = await ContestService.markReminderNotified("user1", 42);
+
+ assert.deepEqual(calledWith, ["user1", "codeforces", 42]);
+ assert.equal(result.contestId, 42);
+ });
+});
diff --git a/server/modules/contests/validation.js b/server/modules/contests/validation.js
index 2a0ba05..d09e9d3 100644
--- a/server/modules/contests/validation.js
+++ b/server/modules/contests/validation.js
@@ -17,6 +17,45 @@ export const validate = (schema) => (req, res, next) => {
next();
};
+/** Validate req.params against a Zod schema and call next() or return 400 */
+export const validateParams = (schema) => (req, res, next) => {
+ const result = schema.safeParse(req.params);
+ if (!result.success) {
+ return res.status(400).json({
+ success: false,
+ message: "Validation error",
+ errors: result.error.issues.map((e) => ({
+ field: e.path.join("."),
+ message: e.message,
+ })),
+ });
+ }
+ // Route params always arrive as strings; overwrite with the coerced,
+ // validated values so downstream controllers get a real number.
+ req.params = { ...req.params, ...result.data };
+ next();
+};
+
+// z.coerce.number() alone uses JavaScript's Number() coercion, which
+// accepts inputs that should never count as a valid id — e.g. Number(true)
+// is 1, and Number([42]) is 42. Restricting the input to string | number
+// FIRST (before coercion runs) rejects booleans, arrays, objects, etc.
+// outright, while still allowing the numeric-string case route params
+// always arrive as ("42" -> 42).
+const contestIdValue = z
+ .union([z.string(), z.number()])
+ .pipe(z.coerce.number().int().positive());
+
export const addReminderSchema = z.object({
- contestId: z.coerce.number().int().positive(),
+ contestId: contestIdValue,
+});
+
+// Same positive-integer contract as addReminderSchema, applied at the
+// route boundary for :contestId params instead of a request body. Kept as
+// a separate exported schema (built from the same underlying
+// contestIdValue primitive) so the two remain independently evolvable if
+// param- and body-level rules ever need to diverge (e.g. bounds specific
+// to one context), without duplicating the coercion/type-guard logic.
+export const contestIdParamSchema = z.object({
+ contestId: contestIdValue,
});
diff --git a/server/modules/contests/validation.test.js b/server/modules/contests/validation.test.js
new file mode 100644
index 0000000..94c87e1
--- /dev/null
+++ b/server/modules/contests/validation.test.js
@@ -0,0 +1,125 @@
+import { test, describe } from "node:test";
+import assert from "node:assert/strict";
+import { addReminderSchema, contestIdParamSchema, validateParams } from "./validation.js";
+
+// Both schemas currently enforce the identical positive-integer contract,
+// so we test them together via a table — this also documents that they're
+// expected to accept/reject the exact same set of inputs.
+const schemas = {
+ addReminderSchema,
+ contestIdParamSchema,
+};
+
+describe("contest reminder id validation contract", () => {
+ for (const [schemaName, schema] of Object.entries(schemas)) {
+ describe(schemaName, () => {
+ test("accepts a valid positive integer", () => {
+ const result = schema.safeParse({ contestId: 2094 });
+ assert.equal(result.success, true);
+ assert.equal(result.data.contestId, 2094);
+ });
+
+ test("accepts a valid positive integer given as a string (route params arrive as strings)", () => {
+ const result = schema.safeParse({ contestId: "2094" });
+ assert.equal(result.success, true);
+ assert.equal(result.data.contestId, 2094);
+ assert.equal(typeof result.data.contestId, "number");
+ });
+
+ test("rejects zero", () => {
+ const result = schema.safeParse({ contestId: 0 });
+ assert.equal(result.success, false);
+ });
+
+ test("rejects negative numbers", () => {
+ const result = schema.safeParse({ contestId: -5 });
+ assert.equal(result.success, false);
+ });
+
+ test("rejects decimals", () => {
+ const result = schema.safeParse({ contestId: 12.5 });
+ assert.equal(result.success, false);
+ });
+
+ test("rejects non-numeric strings", () => {
+ const result = schema.safeParse({ contestId: "abc" });
+ assert.equal(result.success, false);
+ });
+
+ test("rejects permissive-looking strings like '12abc' (parseInt would have silently accepted this as 12)", () => {
+ const result = schema.safeParse({ contestId: "12abc" });
+ assert.equal(result.success, false);
+ });
+
+ test("rejects a missing contestId", () => {
+ const result = schema.safeParse({});
+ assert.equal(result.success, false);
+ });
+
+ test("rejects an empty string", () => {
+ const result = schema.safeParse({ contestId: "" });
+ assert.equal(result.success, false);
+ });
+
+ test("rejects a boolean (JS Number(true) === 1 would otherwise silently pass)", () => {
+ const result = schema.safeParse({ contestId: true });
+ assert.equal(result.success, false);
+ });
+
+ test("rejects an array (JS Number([42]) === 42 would otherwise silently pass)", () => {
+ const result = schema.safeParse({ contestId: [42] });
+ assert.equal(result.success, false);
+ });
+ });
+ }
+});
+
+describe("validateParams middleware", () => {
+ const makeMockRes = () => {
+ const res = {};
+ res.status = (code) => { res.statusCode = code; return res; };
+ res.json = (body) => { res.body = body; return res; };
+ return res;
+ };
+
+ test("returns 400 and does not call next for an invalid contestId param", () => {
+ const middleware = validateParams(contestIdParamSchema);
+ const req = { params: { contestId: "12abc" } };
+ const res = makeMockRes();
+ let nextCalled = false;
+ const next = () => { nextCalled = true; };
+
+ middleware(req, res, next);
+
+ assert.equal(nextCalled, false);
+ assert.equal(res.statusCode, 400);
+ assert.equal(res.body.success, false);
+ });
+
+ test("calls next and converts a valid string contestId param to a number", () => {
+ const middleware = validateParams(contestIdParamSchema);
+ const req = { params: { contestId: "2094" } };
+ const res = makeMockRes();
+ let nextCalled = false;
+ const next = () => { nextCalled = true; };
+
+ middleware(req, res, next);
+
+ assert.equal(nextCalled, true);
+ assert.equal(req.params.contestId, 2094);
+ assert.equal(typeof req.params.contestId, "number");
+ });
+
+ test("rejects a boolean contestId, which Number()-based coercion alone would have accepted as 1", () => {
+ const middleware = validateParams(contestIdParamSchema);
+ const req = { params: { contestId: true } };
+ const res = makeMockRes();
+ let nextCalled = false;
+ const next = () => { nextCalled = true; };
+
+ middleware(req, res, next);
+
+ assert.equal(nextCalled, false);
+ assert.equal(res.statusCode, 400);
+ });
+});
diff --git a/server/scripts/dropRedundantContestIndex.js b/server/scripts/dropRedundantContestIndex.js
new file mode 100644
index 0000000..06a4fe5
--- /dev/null
+++ b/server/scripts/dropRedundantContestIndex.js
@@ -0,0 +1,64 @@
+import mongoose from "mongoose";
+import "../config/env.js";
+import connectDB from "../config/db.js";
+
+/**
+ * One-off migration: drops redundant standalone indexes on the contests
+ * collection (`contestId_1` and `platform_1`), if present. Both are fully
+ * covered by the compound unique `{ platform: 1, contestId: 1 }` index via
+ * its leftmost-prefix behavior — see INDEXES.md for the full rationale.
+ *
+ * Mongoose schema changes never automatically drop existing indexes in
+ * production — this must be run explicitly against each environment
+ * (staging, production) after the schema change deploys.
+ *
+ * Safe to run multiple times — it's a no-op for any index already gone.
+ *
+ * Usage (run from the repository root):
+ * node server/scripts/dropRedundantContestIndex.js
+ */
+const REDUNDANT_INDEX_KEYS = [{ contestId: 1 }, { platform: 1 }];
+const REQUIRED_REPLACEMENT_KEY = { platform: 1, contestId: 1 };
+
+const run = async () => {
+ await connectDB();
+ const collection = mongoose.connection.collection("contests");
+ const indexes = await collection.indexes();
+
+ // Refuse to drop anything unless the compound unique index that's meant
+ // to replace these standalone ones is actually present. Without this
+ // guard, a drifted database (e.g. someone dropped the compound index
+ // manually, or it failed to build) could lose lookup coverage and the
+ // one-document-per-(platform, contestId) uniqueness guarantee entirely.
+ const compoundUnique = indexes.find(
+ (idx) =>
+ JSON.stringify(idx.key) === JSON.stringify(REQUIRED_REPLACEMENT_KEY) &&
+ idx.unique === true
+ );
+
+ if (!compoundUnique) {
+ throw new Error(
+ "Refusing to drop redundant contest indexes: the required compound " +
+ "unique index { platform: 1, contestId: 1 } was not found. Investigate " +
+ "before re-running this migration — dropping the standalone indexes " +
+ "without it in place would leave contestId/platform lookups unindexed."
+ );
+ }
+
+ for (const key of REDUNDANT_INDEX_KEYS) {
+ const match = indexes.find((idx) => JSON.stringify(idx.key) === JSON.stringify(key));
+ if (match) {
+ await collection.dropIndex(match.name);
+ console.log(`Dropped redundant index: ${match.name}`);
+ } else {
+ console.log(`No index found for ${JSON.stringify(key)} — nothing to do.`);
+ }
+ }
+
+ await mongoose.disconnect();
+};
+
+run().catch((err) => {
+ console.error("Failed to drop index:", err);
+ process.exit(1);
+});