From d8d2a501e0214aec6da21b3ea6e93c22eb5e4f75 Mon Sep 17 00:00:00 2001 From: Nathan Probert Date: Thu, 5 Feb 2026 21:03:50 -0500 Subject: [PATCH 1/7] Add get date parameter (and setup mongo) --- .github/workflows/deploy.yml | 10 ++ package-lock.json | 146 ++++++++++++++++++++++++++- package.json | 3 +- src/env.d.ts | 2 + src/handlers/get_players_for_date.ts | 84 +++++++++++++++ src/handlers/index.ts | 3 +- src/index.ts | 4 +- src/router.ts | 15 ++- 8 files changed, 258 insertions(+), 9 deletions(-) create mode 100644 src/handlers/get_players_for_date.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5cab4ec..0de46e6 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -111,6 +111,16 @@ jobs: run: echo "$API_AUTH_TOKEN" | npx wrangler secret put API_AUTH_TOKEN --env $ENV env: API_AUTH_TOKEN: ${{ secrets.API_AUTH_TOKEN }} + + - name: Set MongoDB URI secret + run: echo "$MONGODB_URI" | npx wrangler secret put MONGODB_URI --env $ENV + env: + MONGODB_URI: ${{ secrets.MONGODB_URI }} + + - name: Set MongoDB Database secret + run: echo "$MONGODB_DATABASE" | npx wrangler secret put MONGODB_DATABASE --env $ENV + env: + MONGODB_DATABASE: ${{ secrets.MONGODB_DATABASE }} - name: Deploy Worker run: | diff --git a/package-lock.json b/package-lock.json index d62fe53..19024bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,8 @@ "": { "name": "smartscore-api", "dependencies": { - "http-status-codes": "^2.3.0" + "http-status-codes": "^2.3.0", + "mongodb": "^7.1.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260205.0", @@ -1224,6 +1225,15 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.5.tgz", + "integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1709,6 +1719,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -2213,6 +2238,15 @@ "concat-map": "0.0.1" } }, + "node_modules/bson": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.2.0.tgz", + "integrity": "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -3038,6 +3072,12 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/miniflare": { "version": "4.20260131.0", "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260131.0.tgz", @@ -3073,6 +3113,65 @@ "node": "*" } }, + "node_modules/mongodb": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.0.tgz", + "integrity": "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.3.0", + "bson": "^7.1.1", + "mongodb-connection-string-url": "^7.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": ">=7.0.0 <7.1.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.1.tgz", + "integrity": "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3306,9 +3405,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -3546,6 +3643,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3654,6 +3760,18 @@ "node": ">=14.0.0" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", @@ -3901,6 +4019,28 @@ } } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index a359a18..f11bd62 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "wrangler": "^4.62.0" }, "dependencies": { - "http-status-codes": "^2.3.0" + "http-status-codes": "^2.3.0", + "mongodb": "^7.1.0" } } diff --git a/src/env.d.ts b/src/env.d.ts index 33b8b53..f326a32 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,4 +1,6 @@ export interface Env { API_AUTH_TOKEN: string; SHARED_SECRET: string; + MONGODB_URI: string; + MONGODB_DATABASE: string; } diff --git a/src/handlers/get_players_for_date.ts b/src/handlers/get_players_for_date.ts new file mode 100644 index 0000000..c655e93 --- /dev/null +++ b/src/handlers/get_players_for_date.ts @@ -0,0 +1,84 @@ +import { StatusCodes } from "http-status-codes"; +import { MongoClient } from "mongodb"; +import type { Env } from "../env"; + +export async function getPlayersForDate( + req: Request, + env: Env, + origin: string | null, + getCorsHeaders: (origin: string | null) => HeadersInit +): Promise { + const corsHeaders = getCorsHeaders(origin); + const url = new URL(req.url); + const date = url.searchParams.get("date"); + + if (!date) { + return new Response( + JSON.stringify({ error: "Date parameter is required" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Validate date format (YYYY-MM-DD) + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(date)) { + return new Response( + JSON.stringify({ error: "Invalid date format. Expected YYYY-MM-DD" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + let client: MongoClient | null = null; + + try { + // Connect to MongoDB + client = new MongoClient(env.MONGODB_URI); + await client.connect(); + + const db = client.db(env.MONGODB_DATABASE); + const playersCollection = db.collection("players"); + + // Query players for the specified date, excluding _id field + const players = await playersCollection + .find({ date }) + .project({ _id: 0 }) + .toArray(); + + return new Response(JSON.stringify({ date, players }), { + status: StatusCodes.OK, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("MongoDB error:", error); + return new Response( + JSON.stringify({ error: "Failed to fetch players from database" }), + { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } finally { + // Close the MongoDB connection + if (client) { + await client.close(); + } + } +} diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 5c1ea20..e4e8c4a 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -1,4 +1,5 @@ export { hello } from "./hello"; export { health } from "./health"; export { doThing } from "./doThing"; -export { notFound } from "./notFound"; \ No newline at end of file +export { notFound } from "./notFound"; +export { getPlayersForDate } from "./get_players_for_date"; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3860fdf..73ab16e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,9 +4,9 @@ import type { Env } from "./env"; import { StatusCodes } from "http-status-codes"; export default { - async fetch(req: Request, _env: Env, _ctx: ExecutionContext): Promise { + async fetch(req: Request, env: Env, _ctx: ExecutionContext): Promise { try { - return await route(req); + return await route(req, env); } catch (err) { console.error(err); return new Response("Internal Server Error", { status: StatusCodes.INTERNAL_SERVER_ERROR }); diff --git a/src/router.ts b/src/router.ts index b1fa441..1110158 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,7 +1,8 @@ -import { hello, health, notFound } from "./handlers"; +import { hello, health, notFound, getPlayersForDate } from "./handlers"; import { requireAuth, unauthorized } from "./auth"; import { StatusCodes } from "http-status-codes"; +import type { Env } from "./env"; // CORS configuration const ALLOWED_ORIGINS = [ @@ -22,7 +23,7 @@ function getCorsHeaders(origin: string | null): HeadersInit { return headers; } -export async function route(req: Request): Promise { +export async function route(req: Request, env?: Env): Promise { const url = new URL(req.url); const origin = req.headers.get("Origin"); @@ -49,5 +50,15 @@ export async function route(req: Request): Promise { return health(origin, getCorsHeaders); } + if (req.method === "GET" && url.pathname === "/players") { + if (!env) { + return new Response("Server configuration error", { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: getCorsHeaders(origin), + }); + } + return getPlayersForDate(req, env, origin, getCorsHeaders); + } + return notFound(origin, getCorsHeaders); } From 1e26b77429c85af4103847fe5d9d7eb99bea0d15 Mon Sep 17 00:00:00 2001 From: Nathan Probert Date: Thu, 5 Feb 2026 22:05:50 -0500 Subject: [PATCH 2/7] Add tests and more logic for getting players for given date --- .gitignore | 4 +- package-lock.json | 130 ++++++++++---- package.json | 10 +- scripts/config.example.ts | 16 ++ scripts/get-players.ts | 84 +++++++++ scripts/health-check.ts | 62 +++++++ scripts/test-hello.ts | 72 ++++++++ src/handlers/get_players_for_date.ts | 72 ++++---- src/shared/index.ts | 2 + src/shared/mongodb.ts | 37 ++++ src/shared/validators.ts | 24 +++ src/tests/get_players_for_date.test.ts | 230 +++++++++++++++++++++++++ src/tests/mongodb.test.ts | 151 ++++++++++++++++ src/tests/validators.test.ts | 114 ++++++++++++ wrangler.toml | 3 +- 15 files changed, 936 insertions(+), 75 deletions(-) create mode 100644 scripts/config.example.ts create mode 100644 scripts/get-players.ts create mode 100644 scripts/health-check.ts create mode 100644 scripts/test-hello.ts create mode 100644 src/shared/index.ts create mode 100644 src/shared/mongodb.ts create mode 100644 src/shared/validators.ts create mode 100644 src/tests/get_players_for_date.test.ts create mode 100644 src/tests/mongodb.test.ts create mode 100644 src/tests/validators.test.ts diff --git a/.gitignore b/.gitignore index 13dfa36..df7af62 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .env -node_modules/ \ No newline at end of file +node_modules/ +scripts/config.ts +.wrangler \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 19024bb..15744b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,12 @@ }, "devDependencies": { "@cloudflare/workers-types": "^4.20260205.0", + "@types/node": "^25.2.1", "@typescript-eslint/eslint-plugin": "^8.54.0", + "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18", - "wrangler": "^4.62.0" + "wrangler": "^4.63.0" } }, "node_modules/@cloudflare/kv-asset-handler": { @@ -44,9 +46,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260131.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260131.0.tgz", - "integrity": "sha512-+1X4qErc715NUhJZNhtlpuCxajhD5YNre7Cz50WPMmj+BMUrh9h7fntKEadtrUo5SM2YONY7CDzK7wdWbJJBVA==", + "version": "1.20260205.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260205.0.tgz", + "integrity": "sha512-ToOItqcirmWPwR+PtT+Q4bdjTn/63ZxhJKEfW4FNn7FxMTS1Tw5dml0T0mieOZbCpcvY8BdvPKFCSlJuI8IVHQ==", "cpu": [ "x64" ], @@ -61,9 +63,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260131.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260131.0.tgz", - "integrity": "sha512-M84mXR8WEMEBuX4/dL2IQ4wHV/ALwYjx9if5ePZR8rdbD7if/fkEEoMBq0bGS/1gMLRqqCZLstabxHV+g92NNg==", + "version": "1.20260205.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260205.0.tgz", + "integrity": "sha512-402ZqLz+LrG0NDXp7Hn7IZbI0DyhjNfjAlVenb0K3yod9KCuux0u3NksNBvqJx0mIGHvVR4K05h+jfT5BTHqGA==", "cpu": [ "arm64" ], @@ -78,9 +80,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260131.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260131.0.tgz", - "integrity": "sha512-SWzr48bCL9y5wjkj23tXS6t/6us99EAH9T5TAscMV0hfJFZQt97RY/gaHKyRRjFv6jfJZvk7d4g+OmGeYBnwcg==", + "version": "1.20260205.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260205.0.tgz", + "integrity": "sha512-rz9jBzazIA18RHY+osa19hvsPfr0LZI1AJzIjC6UqkKKphcTpHBEQ25Xt8cIA34ivMIqeENpYnnmpDFesLkfcQ==", "cpu": [ "x64" ], @@ -95,9 +97,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260131.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260131.0.tgz", - "integrity": "sha512-mL0kLPGIBJRPeHS3+erJ2t5dJT3ODhsKvR9aA4BcsY7M30/QhlgJIF6wsgwNisTJ23q8PbobZNHBUKIe8l/E9A==", + "version": "1.20260205.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260205.0.tgz", + "integrity": "sha512-jr6cKpMM/DBEbL+ATJ9rYue758CKp0SfA/nXt5vR32iINVJrb396ye9iat2y9Moa/PgPKnTrFgmT6urUmG3IUg==", "cpu": [ "arm64" ], @@ -112,9 +114,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260131.0", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260131.0.tgz", - "integrity": "sha512-hoQqTFBpP1zntP2OQSpt5dEWbd9vSBliK+G7LmDXjKitPkmkRFo2PB4P9aBRE1edPAIO/fpdoJv928k2HaAn4A==", + "version": "1.20260205.0", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260205.0.tgz", + "integrity": "sha512-SMPW5jCZYOG7XFIglSlsgN8ivcl0pCrSAYxCwxtWvZ88whhcDB/aISNtiQiDZujPH8tIo2hE5dEkxW7tGEwc3A==", "cpu": [ "x64" ], @@ -1719,6 +1721,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", + "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, "node_modules/@types/webidl-conversions": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", @@ -2779,6 +2791,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-tsconfig": { + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.3.tgz", + "integrity": "sha512-vp8Cj/+9Q/ibZUrq1rhy8mCTQpCk31A3uu9wc1C50yAb3x2pFHOsGdAZQ7jD86ARayyxZUViYeIztW+GE8dcrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3079,16 +3104,16 @@ "license": "MIT" }, "node_modules/miniflare": { - "version": "4.20260131.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260131.0.tgz", - "integrity": "sha512-CtObRzlAzOUpCFH+MgImykxmDNKthrgIYtC+oLC3UGpve6bGLomKUW4u4EorTvzlQFHe66/9m/+AYbBbpzG0mQ==", + "version": "4.20260205.0", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260205.0.tgz", + "integrity": "sha512-jG1TknEDeFqcq/z5gsOm1rKeg4cNG7ruWxEuiPxl3pnQumavxo8kFpeQC6XKVpAhh2PI9ODGyIYlgd77sTHl5g==", "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", - "workerd": "1.20260131.0", + "workerd": "1.20260205.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, @@ -3443,6 +3468,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -3793,6 +3828,26 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3845,6 +3900,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -4087,9 +4149,9 @@ } }, "node_modules/workerd": { - "version": "1.20260131.0", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260131.0.tgz", - "integrity": "sha512-4zZxOdWeActbRfydQQlj7vZ2ay01AjjNC4K3stjmWC3xZHeXeN3EAROwsWE83SZHhtw4rn18srrhtXoQvQMw3Q==", + "version": "1.20260205.0", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260205.0.tgz", + "integrity": "sha512-CcMH5clHwrH8VlY7yWS9C/G/C8g9czIz1yU3akMSP9Z3CkEMFSoC3GGdj5G7Alw/PHEeez1+1IrlYger4pwu+w==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -4100,17 +4162,17 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260131.0", - "@cloudflare/workerd-darwin-arm64": "1.20260131.0", - "@cloudflare/workerd-linux-64": "1.20260131.0", - "@cloudflare/workerd-linux-arm64": "1.20260131.0", - "@cloudflare/workerd-windows-64": "1.20260131.0" + "@cloudflare/workerd-darwin-64": "1.20260205.0", + "@cloudflare/workerd-darwin-arm64": "1.20260205.0", + "@cloudflare/workerd-linux-64": "1.20260205.0", + "@cloudflare/workerd-linux-arm64": "1.20260205.0", + "@cloudflare/workerd-windows-64": "1.20260205.0" } }, "node_modules/wrangler": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.62.0.tgz", - "integrity": "sha512-DogP9jifqw85g33BqwF6m21YBW5J7+Ep9IJLgr6oqHU0RkA79JMN5baeWXdmnIWZl+VZh6bmtNtR+5/Djd32tg==", + "version": "4.63.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.63.0.tgz", + "integrity": "sha512-+R04jF7Eb8K3KRMSgoXpcIdLb8GC62eoSGusYh1pyrSMm/10E0hbKkd7phMJO4HxXc6R7mOHC5SSoX9eof30Uw==", "dev": true, "license": "MIT OR Apache-2.0", "dependencies": { @@ -4118,10 +4180,10 @@ "@cloudflare/unenv-preset": "2.12.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", - "miniflare": "4.20260131.0", + "miniflare": "4.20260205.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20260131.0" + "workerd": "1.20260205.0" }, "bin": { "wrangler": "bin/wrangler.js", @@ -4134,7 +4196,7 @@ "fsevents": "~2.3.2" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.20260131.0" + "@cloudflare/workers-types": "^4.20260205.0" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { diff --git a/package.json b/package.json index f11bd62..ef51ba5 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,20 @@ "dev": "wrangler dev", "deploy": "wrangler deploy", "lint": "eslint . --ext .ts", - "test": "vitest run" + "test": "vitest run", + "script": "tsx", + "get-players": "tsx scripts/get-players.ts", + "health-check": "tsx scripts/health-check.ts", + "test-hello": "tsx scripts/test-hello.ts" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260205.0", + "@types/node": "^25.2.1", "@typescript-eslint/eslint-plugin": "^8.54.0", + "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18", - "wrangler": "^4.62.0" + "wrangler": "^4.63.0" }, "dependencies": { "http-status-codes": "^2.3.0", diff --git a/scripts/config.example.ts b/scripts/config.example.ts new file mode 100644 index 0000000..1e8cc1d --- /dev/null +++ b/scripts/config.example.ts @@ -0,0 +1,16 @@ +/** + * Example configuration for Node.js scripts + * Copy this to config.ts and update with your actual values + */ + +export const CONFIG = { + // Your API authentication token + authToken: "your-auth-token-here", + + // Base URLs for different environments + urls: { + dev: "https://smartscore-api-dev.your-worker.workers.dev", + prod: "https://smartscore-api-prod.your-worker.workers.dev", + local: "http://localhost:8787", + }, +}; diff --git a/scripts/get-players.ts b/scripts/get-players.ts new file mode 100644 index 0000000..bc621b3 --- /dev/null +++ b/scripts/get-players.ts @@ -0,0 +1,84 @@ +/** + * Script to fetch players for a specific date + * Usage: npm run script scripts/get-players.ts -- --date=2026-02-05 --env=dev + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + date: string; + env: "dev" | "prod" | "local"; + token?: string; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + env: (process.env.API_ENV as "dev" | "prod" | "local") || "local", + date: process.env.DATE || new Date().toISOString().split("T")[0], + }; + + args.forEach((arg) => { + if (arg.startsWith("--date=")) { + parsed.date = arg.split("=")[1]; + } else if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } else if (arg.startsWith("--token=")) { + parsed.token = arg.split("=")[1]; + } + }); + + // Also support environment variables + if (process.env.AUTH_TOKEN && !parsed.token) { + parsed.token = process.env.AUTH_TOKEN; + } + + return parsed as Args; +} + +async function getPlayersForDate(date: string, env: string, token?: string) { + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const url = `${baseUrl}/players?date=${date}`; + + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else if (process.env.API_AUTH_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.API_AUTH_TOKEN}`; + } else if (CONFIG.authToken) { + headers["Authorization"] = `Bearer ${CONFIG.authToken}`; + } + + console.log(`\nπŸ” Fetching players for date: ${date}`); + console.log(`πŸ“‘ URL: ${url}\n`); + + try { + const response = await fetch(url, { headers }); + + console.log(`Status: ${response.status} ${response.statusText}`); + + const data = await response.json(); + + if (response.ok) { + console.log("\nβœ… Success!\n"); + console.log(JSON.stringify(data, null, 2)); + console.log(`\nπŸ“Š Total players: ${data.players?.length || 0}`); + } else { + console.log("\n❌ Error:\n"); + console.log(JSON.stringify(data, null, 2)); + } + } catch (error) { + console.error("\n❌ Request failed:"); + console.error(error); + } +} + +// Run the script +const args = parseArgs(); +getPlayersForDate(args.date, args.env, args.token); diff --git a/scripts/health-check.ts b/scripts/health-check.ts new file mode 100644 index 0000000..53d9f6c --- /dev/null +++ b/scripts/health-check.ts @@ -0,0 +1,62 @@ +/** + * Script to check API health + * Usage: npm run script scripts/health-check.ts -- --env=dev + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + env: "dev" | "prod" | "local"; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + env: "local", + }; + + args.forEach((arg) => { + if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } + }); + + return parsed as Args; +} + +async function checkHealth(env: string) { + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const url = `${baseUrl}/health`; + + console.log(`\nπŸ₯ Health check for: ${env}`); + console.log(`πŸ“‘ URL: ${url}\n`); + + try { + const startTime = Date.now(); + const response = await fetch(url); + const duration = Date.now() - startTime; + + console.log(`Status: ${response.status} ${response.statusText}`); + console.log(`⏱️ Response time: ${duration}ms`); + + const text = await response.text(); + + if (response.ok) { + console.log("\nβœ… API is healthy!\n"); + console.log(`Response: ${text}`); + } else { + console.log("\n❌ API health check failed:\n"); + console.log(text); + } + } catch (error) { + console.error("\n❌ Health check failed:"); + console.error(error); + } +} + +// Run the script +const args = parseArgs(); +checkHealth(args.env); diff --git a/scripts/test-hello.ts b/scripts/test-hello.ts new file mode 100644 index 0000000..44325c1 --- /dev/null +++ b/scripts/test-hello.ts @@ -0,0 +1,72 @@ +/** + * Script to test the hello endpoint (requires auth) + * Usage: npm run script scripts/test-hello.ts -- --env=dev --token=YOUR_TOKEN + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + env: "dev" | "prod" | "local"; + token?: string; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + env: "local", + }; + + args.forEach((arg) => { + if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } else if (arg.startsWith("--token=")) { + parsed.token = arg.split("=")[1]; + } + }); + + return parsed as Args; +} + +async function testHello(env: string, token?: string) { + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const url = `${baseUrl}/`; + + const headers: HeadersInit = {}; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else if (process.env.API_AUTH_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.API_AUTH_TOKEN}`; + } else if (CONFIG.authToken) { + headers["Authorization"] = `Bearer ${CONFIG.authToken}`; + } + + console.log(`\nπŸ‘‹ Testing hello endpoint`); + console.log(`πŸ“‘ URL: ${url}\n`); + + try { + const response = await fetch(url, { headers }); + + console.log(`Status: ${response.status} ${response.statusText}`); + + const text = await response.text(); + + if (response.ok) { + console.log("\nβœ… Success!\n"); + console.log(`Response: ${text}`); + } else { + console.log("\n❌ Error:\n"); + console.log(text); + } + } catch (error) { + console.error("\n❌ Request failed:"); + console.error(error); + } +} + +// Run the script +const args = parseArgs(); +testHello(args.env, args.token); diff --git a/src/handlers/get_players_for_date.ts b/src/handlers/get_players_for_date.ts index c655e93..d46dae9 100644 --- a/src/handlers/get_players_for_date.ts +++ b/src/handlers/get_players_for_date.ts @@ -1,7 +1,32 @@ import { StatusCodes } from "http-status-codes"; -import { MongoClient } from "mongodb"; +import type { MongoClient } from "mongodb"; import type { Env } from "../env"; +import { + withMongoClient, + getPlayersCollection, + validateDateParameter, +} from "../shared"; +interface Player { + date: string; + [key: string]: unknown; +} + +/** + * Fetches players from the database for a specific date + */ +async function fetchPlayersForDate( + client: MongoClient, + date: string +): Promise { + const playersCollection = getPlayersCollection(client); + const players = await playersCollection.find({ date }).toArray(); + return players as unknown as Player[]; +} + +/** + * Handler for GET /players endpoint + */ export async function getPlayersForDate( req: Request, env: Env, @@ -10,26 +35,12 @@ export async function getPlayersForDate( ): Promise { const corsHeaders = getCorsHeaders(origin); const url = new URL(req.url); - const date = url.searchParams.get("date"); - - if (!date) { - return new Response( - JSON.stringify({ error: "Date parameter is required" }), - { - status: StatusCodes.BAD_REQUEST, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - } - ); - } - // Validate date format (YYYY-MM-DD) - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - if (!dateRegex.test(date)) { + // Validate date parameter + const validation = validateDateParameter(url); + if (!validation.valid) { return new Response( - JSON.stringify({ error: "Invalid date format. Expected YYYY-MM-DD" }), + JSON.stringify({ error: validation.error }), { status: StatusCodes.BAD_REQUEST, headers: { @@ -40,21 +51,13 @@ export async function getPlayersForDate( ); } - let client: MongoClient | null = null; + const { date } = validation; try { - // Connect to MongoDB - client = new MongoClient(env.MONGODB_URI); - await client.connect(); - - const db = client.db(env.MONGODB_DATABASE); - const playersCollection = db.collection("players"); - - // Query players for the specified date, excluding _id field - const players = await playersCollection - .find({ date }) - .project({ _id: 0 }) - .toArray(); + // Query players using shared MongoDB utilities + const players = await withMongoClient(env, (client) => + fetchPlayersForDate(client, date) + ); return new Response(JSON.stringify({ date, players }), { status: StatusCodes.OK, @@ -75,10 +78,5 @@ export async function getPlayersForDate( }, } ); - } finally { - // Close the MongoDB connection - if (client) { - await client.close(); - } } } diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..28d6b3d --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,2 @@ +export * from "./mongodb"; +export * from "./validators"; diff --git a/src/shared/mongodb.ts b/src/shared/mongodb.ts new file mode 100644 index 0000000..8dbf9d2 --- /dev/null +++ b/src/shared/mongodb.ts @@ -0,0 +1,37 @@ +import { MongoClient, Collection } from "mongodb"; +import type { Env } from "../env"; + +/** + * Creates and connects a MongoDB client + */ +export async function connectToMongoDB(env: Env): Promise { + const client = new MongoClient(env.MONGODB_URI); + await client.connect(); + return client; +} + +/** + * Gets the SmartScore players collection + */ +export function getPlayersCollection(client: MongoClient): Collection { + const db = client.db("players"); + return db.collection("SmartScore"); +} + +/** + * Executes a database operation with automatic connection management + */ +export async function withMongoClient( + env: Env, + operation: (client: MongoClient) => Promise +): Promise { + let client: MongoClient | null = null; + try { + client = await connectToMongoDB(env); + return await operation(client); + } finally { + if (client) { + await client.close(); + } + } +} diff --git a/src/shared/validators.ts b/src/shared/validators.ts new file mode 100644 index 0000000..6ae7f64 --- /dev/null +++ b/src/shared/validators.ts @@ -0,0 +1,24 @@ +/** + * Validates date format (YYYY-MM-DD) + */ +export function isValidDateFormat(date: string): boolean { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + return dateRegex.test(date); +} + +/** + * Validates and extracts the date parameter from a URL + */ +export function validateDateParameter(url: URL): { valid: true; date: string } | { valid: false; error: string } { + const date = url.searchParams.get("date"); + + if (!date) { + return { valid: false, error: "Date parameter is required" }; + } + + if (!isValidDateFormat(date)) { + return { valid: false, error: "Invalid date format. Expected YYYY-MM-DD" }; + } + + return { valid: true, date }; +} diff --git a/src/tests/get_players_for_date.test.ts b/src/tests/get_players_for_date.test.ts new file mode 100644 index 0000000..b022eb2 --- /dev/null +++ b/src/tests/get_players_for_date.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getPlayersForDate } from '../handlers/get_players_for_date'; +import type { Env } from '../env'; +import * as mongodb from '../shared/mongodb'; + +// Mock the MongoDB utilities +vi.mock('../shared/mongodb', () => ({ + withMongoClient: vi.fn(), + getPlayersCollection: vi.fn(), +})); + +const getCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', +}); + +const mockEnv: Env = { + API_AUTH_TOKEN: 'test-token', + SHARED_SECRET: 'test-secret', + MONGODB_URI: 'mongodb://localhost:27017', + MONGODB_DATABASE: 'test-db', +}; + +interface ErrorResponse { + error: string; +} + +interface Player { + name?: string; + score?: number; + date?: string; + [key: string]: unknown; +} + +interface SuccessResponse { + date: string; + players: Player[]; +} + +describe('getPlayersForDate', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Date validation', () => { + it('should return 400 when date parameter is missing', async () => { + const request = new Request('https://api.example.com/players'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Date parameter is required'); + }); + + it('should return 400 when date format is invalid', async () => { + const request = new Request('https://api.example.com/players?date=2026-1-5'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Invalid date format. Expected YYYY-MM-DD'); + }); + + it('should return 400 when date contains letters', async () => { + const request = new Request('https://api.example.com/players?date=20ab-01-05'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Invalid date format. Expected YYYY-MM-DD'); + }); + + it('should accept valid date format', async () => { + const mockPlayers = [ + { name: 'Player 1', score: 100 }, + { name: 'Player 2', score: 95 }, + ]; + + vi.mocked(mongodb.withMongoClient).mockImplementation(async (_env, operation) => { + const mockClient = {} as Awaited>; + return operation(mockClient); + }); + + // Mock the operation to return players + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockPlayers); + + const request = new Request('https://api.example.com/players?date=2026-01-05'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + }); + }); + + describe('Successful requests', () => { + it('should return players for valid date', async () => { + const mockPlayers = [ + { name: 'Player 1', score: 100, date: '2026-01-05' }, + { name: 'Player 2', score: 95, date: '2026-01-05' }, + ]; + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockPlayers); + + const request = new Request('https://api.example.com/players?date=2026-01-05'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-01-05'); + expect(body.players).toEqual(mockPlayers); + expect(body.players).toHaveLength(2); + }); + + it('should return empty array when no players found', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/players?date=2026-12-31'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-12-31'); + expect(body.players).toEqual([]); + }); + + it('should include correct Content-Type header', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/players?date=2026-01-05'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + }); + }); + + describe('CORS headers', () => { + it('should include CORS headers with default origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/players?date=2026-01-05'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,OPTIONS'); + }); + + it('should include CORS headers with specific origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/players?date=2026-01-05'); + const response = await getPlayersForDate( + request, + mockEnv, + 'https://example.com', + getCorsHeaders + ); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + + it('should include CORS headers even on error responses', async () => { + const request = new Request('https://api.example.com/players'); + const response = await getPlayersForDate( + request, + mockEnv, + 'https://example.com', + getCorsHeaders + ); + + expect(response.status).toBe(400); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + }); + + describe('Error handling', () => { + it('should return 500 on database error', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue( + new Error('Database connection failed') + ); + + const request = new Request('https://api.example.com/players?date=2026-01-05'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Failed to fetch players from database'); + }); + + it('should include CORS headers on database error', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue( + new Error('Database error') + ); + + const request = new Request('https://api.example.com/players?date=2026-01-05'); + const response = await getPlayersForDate( + request, + mockEnv, + 'https://example.com', + getCorsHeaders + ); + + expect(response.status).toBe(500); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + }); + + describe('Edge cases', () => { + it('should handle leap year dates', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/players?date=2024-02-29'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + }); + + it('should handle date with leading zeros', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/players?date=2026-01-01'); + const response = await getPlayersForDate(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-01-01'); + }); + }); +}); diff --git a/src/tests/mongodb.test.ts b/src/tests/mongodb.test.ts new file mode 100644 index 0000000..0e25c60 --- /dev/null +++ b/src/tests/mongodb.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { connectToMongoDB, getPlayersCollection, withMongoClient } from '../shared/mongodb'; +import type { Env } from '../env'; + +// Mock MongoDB +vi.mock('mongodb', () => { + const mockCollection = { + find: vi.fn(), + insertOne: vi.fn(), + deleteOne: vi.fn(), + updateOne: vi.fn(), + }; + + const mockDb = { + collection: vi.fn().mockReturnValue(mockCollection), + }; + + class MockMongoClient { + connect = vi.fn().mockResolvedValue(undefined); + close = vi.fn().mockResolvedValue(undefined); + db = vi.fn().mockReturnValue(mockDb); + } + + return { + MongoClient: MockMongoClient, + }; +}); + +const mockEnv: Env = { + API_AUTH_TOKEN: 'test-token', + SHARED_SECRET: 'test-secret', + MONGODB_URI: 'mongodb://localhost:27017', + MONGODB_DATABASE: 'test-db', +}; + +describe('MongoDB Utilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('connectToMongoDB', () => { + it('should create and connect a MongoDB client', async () => { + const client = await connectToMongoDB(mockEnv); + + expect(client.connect).toBeDefined(); + expect(client.close).toBeDefined(); + expect(client.db).toBeDefined(); + }); + + it('should use the URI from environment', async () => { + const customEnv = { ...mockEnv, MONGODB_URI: 'mongodb://custom:27017' }; + const client = await connectToMongoDB(customEnv); + + expect(client).toBeDefined(); + expect(client.connect).toBeDefined(); + }); + }); + + describe('getPlayersCollection', () => { + it('should return the SmartScore collection from players database', async () => { + const client = await connectToMongoDB(mockEnv); + const collection = getPlayersCollection(client); + + expect(client.db).toHaveBeenCalledWith('players'); + expect(collection).toBeDefined(); + }); + + it('should call db.collection with SmartScore', async () => { + const client = await connectToMongoDB(mockEnv); + const mockDb = client.db('players'); + + getPlayersCollection(client); + + expect(mockDb.collection).toHaveBeenCalledWith('SmartScore'); + }); + }); + + describe('withMongoClient', () => { + it('should execute operation with connected client', async () => { + const mockOperation = vi.fn().mockResolvedValue({ result: 'success' }); + + const result = await withMongoClient(mockEnv, mockOperation); + + expect(result).toEqual({ result: 'success' }); + expect(mockOperation).toHaveBeenCalled(); + }); + + it('should close client after successful operation', async () => { + const mockOperation = vi.fn().mockImplementation(async (client) => { + // Verify client has close method + expect(client.close).toBeDefined(); + return 'data'; + }); + + await withMongoClient(mockEnv, mockOperation); + + expect(mockOperation).toHaveBeenCalled(); + }); + + it('should close client even if operation throws error', async () => { + const mockOperation = vi.fn().mockRejectedValue(new Error('Operation failed')); + + await expect(withMongoClient(mockEnv, mockOperation)).rejects.toThrow('Operation failed'); + + expect(mockOperation).toHaveBeenCalled(); + }); + + it('should pass client to operation callback', async () => { + const mockOperation = vi.fn().mockImplementation(async (client) => { + expect(client).toBeDefined(); + expect(client.connect).toBeDefined(); + return 'result'; + }); + + await withMongoClient(mockEnv, mockOperation); + + expect(mockOperation).toHaveBeenCalledWith(expect.objectContaining({ + connect: expect.any(Function), + close: expect.any(Function), + db: expect.any(Function), + })); + }); + + it('should handle multiple sequential operations', async () => { + const operation1 = vi.fn().mockResolvedValue('result1'); + const operation2 = vi.fn().mockResolvedValue('result2'); + + const result1 = await withMongoClient(mockEnv, operation1); + const result2 = await withMongoClient(mockEnv, operation2); + + expect(result1).toBe('result1'); + expect(result2).toBe('result2'); + }); + + it('should connect client before executing operation', async () => { + let connectCalled = false; + + const mockOperation = vi.fn().mockImplementation(async (client) => { + // By the time operation is called, connect should have been called + expect(client.connect).toBeDefined(); + connectCalled = true; + return 'result'; + }); + + await withMongoClient(mockEnv, mockOperation); + + expect(connectCalled).toBe(true); + expect(mockOperation).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/tests/validators.test.ts b/src/tests/validators.test.ts new file mode 100644 index 0000000..624f2ea --- /dev/null +++ b/src/tests/validators.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import { isValidDateFormat, validateDateParameter } from '../shared/validators'; + +describe('Validators', () => { + describe('isValidDateFormat', () => { + it('should return true for valid date format', () => { + expect(isValidDateFormat('2026-01-05')).toBe(true); + expect(isValidDateFormat('2026-12-31')).toBe(true); + expect(isValidDateFormat('2000-01-01')).toBe(true); + expect(isValidDateFormat('9999-12-31')).toBe(true); + }); + + it('should return false for invalid date format', () => { + expect(isValidDateFormat('2026-1-5')).toBe(false); + expect(isValidDateFormat('2026-01-5')).toBe(false); + expect(isValidDateFormat('26-01-05')).toBe(false); + expect(isValidDateFormat('2026/01/05')).toBe(false); + expect(isValidDateFormat('01-05-2026')).toBe(false); + }); + + it('should return false for empty or invalid strings', () => { + expect(isValidDateFormat('')).toBe(false); + expect(isValidDateFormat('abc')).toBe(false); + expect(isValidDateFormat('not-a-date')).toBe(false); + }); + + it('should return false for dates with extra characters', () => { + expect(isValidDateFormat('2026-01-05 ')).toBe(false); + expect(isValidDateFormat(' 2026-01-05')).toBe(false); + expect(isValidDateFormat('2026-01-05T00:00:00')).toBe(false); + }); + }); + + describe('validateDateParameter', () => { + it('should return valid result for correct date parameter', () => { + const url = new URL('https://api.example.com/players?date=2026-01-05'); + const result = validateDateParameter(url); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.date).toBe('2026-01-05'); + } + }); + + it('should return error when date parameter is missing', () => { + const url = new URL('https://api.example.com/players'); + const result = validateDateParameter(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Date parameter is required'); + } + }); + + it('should return error for invalid date format', () => { + const url = new URL('https://api.example.com/players?date=2026-1-5'); + const result = validateDateParameter(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Invalid date format. Expected YYYY-MM-DD'); + } + }); + + it('should return error for malformed dates', () => { + const invalidDates = [ + '26-01-05', + '2026/01/05', + 'January 5, 2026', + 'abc', + ]; + + invalidDates.forEach((invalidDate) => { + const url = new URL(`https://api.example.com/players?date=${invalidDate}`); + const result = validateDateParameter(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Invalid date format. Expected YYYY-MM-DD'); + } + }); + }); + + it('should handle URL with multiple query parameters', () => { + const url = new URL('https://api.example.com/players?foo=bar&date=2026-01-05&baz=qux'); + const result = validateDateParameter(url); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.date).toBe('2026-01-05'); + } + }); + + it('should handle empty date parameter value', () => { + const url = new URL('https://api.example.com/players?date='); + const result = validateDateParameter(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Date parameter is required'); + } + }); + + it('should accept leap year dates', () => { + const url = new URL('https://api.example.com/players?date=2024-02-29'); + const result = validateDateParameter(url); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.date).toBe('2024-02-29'); + } + }); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index d94a62f..a06549a 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,7 @@ name = "smartscore-api" main = "src/index.ts" -compatibility_date = "2026-01-01" +compatibility_flags = ["nodejs_compat"] +compatibility_date = "2026-01-31" [env.dev] name = "smartscore-api-dev" From 2446c45cd683ec034fee9e96bbdfdfcd58d5cd99 Mon Sep 17 00:00:00 2001 From: Nathan Probert Date: Thu, 5 Feb 2026 22:53:53 -0500 Subject: [PATCH 3/7] Add functionality for deleting a specific game --- package-lock.json | 14 ++ package.json | 3 + scripts/config.example.ts | 4 +- scripts/copy-collection.ts | 125 ++++++++++++ scripts/delete-game.ts | 128 ++++++++++++ scripts/get-players.ts | 2 +- src/env.d.ts | 1 + src/handlers/delete_game.ts | 92 +++++++++ src/handlers/get_players_for_date.ts | 5 +- src/handlers/index.ts | 3 +- src/router.ts | 14 +- src/shared/mongodb.ts | 5 +- src/shared/validators.ts | 29 +++ src/tests/delete_game.test.ts | 282 +++++++++++++++++++++++++++ src/tests/mongodb.test.ts | 7 +- src/tests/router.test.ts | 2 +- src/tests/validators.test.ts | 90 ++++++++- wrangler.toml | 4 + 18 files changed, 795 insertions(+), 15 deletions(-) create mode 100644 scripts/copy-collection.ts create mode 100644 scripts/delete-game.ts create mode 100644 src/handlers/delete_game.ts create mode 100644 src/tests/delete_game.test.ts diff --git a/package-lock.json b/package-lock.json index 15744b8..1a40bbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@cloudflare/workers-types": "^4.20260205.0", "@types/node": "^25.2.1", "@typescript-eslint/eslint-plugin": "^8.54.0", + "dotenv": "^17.2.4", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18", @@ -2408,6 +2409,19 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "17.2.4", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz", + "integrity": "sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/error-stack-parser-es": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", diff --git a/package.json b/package.json index ef51ba5..b8f049c 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "test": "vitest run", "script": "tsx", "get-players": "tsx scripts/get-players.ts", + "delete-game": "tsx scripts/delete-game.ts", + "copy-collection": "tsx scripts/copy-collection.ts", "health-check": "tsx scripts/health-check.ts", "test-hello": "tsx scripts/test-hello.ts" }, @@ -15,6 +17,7 @@ "@cloudflare/workers-types": "^4.20260205.0", "@types/node": "^25.2.1", "@typescript-eslint/eslint-plugin": "^8.54.0", + "dotenv": "^17.2.4", "tsx": "^4.21.0", "typescript": "^5.9.3", "vitest": "^4.0.18", diff --git a/scripts/config.example.ts b/scripts/config.example.ts index 1e8cc1d..a66145d 100644 --- a/scripts/config.example.ts +++ b/scripts/config.example.ts @@ -9,8 +9,8 @@ export const CONFIG = { // Base URLs for different environments urls: { - dev: "https://smartscore-api-dev.your-worker.workers.dev", - prod: "https://smartscore-api-prod.your-worker.workers.dev", + dev: "https://smartscore-api-dev.nathanprobert.workers.dev", + prod: "https://smartscore-api-prod.nathanprobert.workers.dev", local: "http://localhost:8787", }, }; diff --git a/scripts/copy-collection.ts b/scripts/copy-collection.ts new file mode 100644 index 0000000..00a1b5f --- /dev/null +++ b/scripts/copy-collection.ts @@ -0,0 +1,125 @@ +/** + * Script to copy all documents from SmartScore collection to SmartScoreDev + * Usage: npm run script scripts/copy-collection.ts -- --confirm + * + * WARNING: This will overwrite all data in SmartScoreDev! + */ + +import { MongoClient } from "mongodb"; +import { config } from "dotenv"; + +// Load environment variables from .env file +config(); + +export {}; + +interface Args { + confirm?: boolean; + clear?: boolean; +} + +function parseArgs(): Args { + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Args = { + confirm: false, + clear: false, + }; + + args.forEach((arg) => { + if (arg === "--confirm" || arg === "--yes" || arg === "-y") { + parsed.confirm = true; + } else if (arg === "--clear") { + parsed.clear = true; + } + }); + + return parsed; +} + +async function copyCollection(confirm: boolean, clearFirst: boolean) { + const MONGODB_URI = process.env.MONGODB_URI; + + if (!MONGODB_URI) { + console.error("\n❌ Error: MONGODB_URI environment variable is required\n"); + console.log("Set it in your environment or .env file\n"); + return; + } + + console.log("\n⚠️ WARNING: This will copy SmartScore β†’ SmartScoreDev"); + if (clearFirst) { + console.log(" AND clear all existing data in SmartScoreDev first!\n"); + } else { + console.log(" This may create duplicates if data already exists!\n"); + } + + // Safety check - require confirmation flag + if (!confirm) { + console.log("❌ Operation cancelled!"); + console.log("To confirm, add the --confirm flag:"); + console.log(" npm run script scripts/copy-collection.ts -- --confirm"); + if (!clearFirst) { + console.log("\nTo clear SmartScoreDev before copying, add --clear:"); + console.log(" npm run script scripts/copy-collection.ts -- --confirm --clear\n"); + } + return; + } + + let client: MongoClient | null = null; + + try { + console.log("πŸ“‘ Connecting to MongoDB..."); + client = new MongoClient(MONGODB_URI); + await client.connect(); + console.log("βœ… Connected!\n"); + + const db = client.db("players"); + const sourceCollection = db.collection("SmartScore"); + const targetCollection = db.collection("SmartScoreDev"); + + // Optional: Clear target collection first + if (clearFirst) { + console.log("πŸ—‘οΈ Clearing SmartScoreDev..."); + const deleteResult = await targetCollection.deleteMany({}); + console.log(` Deleted ${deleteResult.deletedCount} existing documents\n`); + } + + // Count source documents + console.log("πŸ“Š Counting source documents..."); + const count = await sourceCollection.countDocuments(); + console.log(` Found ${count} documents in SmartScore\n`); + + if (count === 0) { + console.log("⚠️ No documents to copy!\n"); + return; + } + + // Copy all documents + console.log("πŸ“‹ Copying documents..."); + const documents = await sourceCollection.find({}).toArray(); + + if (documents.length > 0) { + const insertResult = await targetCollection.insertMany(documents); + console.log(`βœ… Successfully copied ${insertResult.insertedCount} documents!\n`); + + // Show some stats + const targetCount = await targetCollection.countDocuments(); + console.log("πŸ“Š Final stats:"); + console.log(` SmartScore: ${count} documents`); + console.log(` SmartScoreDev: ${targetCount} documents\n`); + } + + } catch (error) { + console.error("\n❌ Error occurred:"); + console.error(error); + console.log(); + } finally { + if (client) { + await client.close(); + console.log("πŸ”Œ Disconnected from MongoDB\n"); + } + } +} + +// Run the script +const args = parseArgs(); +copyCollection(args.confirm ?? false, args.clear ?? false); diff --git a/scripts/delete-game.ts b/scripts/delete-game.ts new file mode 100644 index 0000000..ee52968 --- /dev/null +++ b/scripts/delete-game.ts @@ -0,0 +1,128 @@ +/** + * Script to delete a specific game + * Usage: npm run script scripts/delete-game.ts -- --date=2026-02-05 --home=Lakers --away=Warriors --env=dev + * + * WARNING: This will permanently delete all players for the specified game! + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + date: string; + home: string; + away: string; + env: "dev" | "prod" | "local"; + token?: string; + confirm?: boolean; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + env: (process.env.API_ENV as "dev" | "prod" | "local") || "local", + date: process.env.DATE || new Date().toISOString().split("T")[0], + confirm: false, + }; + + args.forEach((arg) => { + if (arg.startsWith("--date=")) { + parsed.date = arg.split("=")[1]; + } else if (arg.startsWith("--home=")) { + parsed.home = arg.split("=")[1]; + } else if (arg.startsWith("--away=")) { + parsed.away = arg.split("=")[1]; + } else if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } else if (arg.startsWith("--token=")) { + parsed.token = arg.split("=")[1]; + } else if (arg === "--confirm" || arg === "--yes" || arg === "-y") { + parsed.confirm = true; + } + }); + + // Also support environment variables + if (process.env.AUTH_TOKEN && !parsed.token) { + parsed.token = process.env.AUTH_TOKEN; + } + + return parsed as Args; +} + +async function deleteGame( + date: string, + home: string, + away: string, + env: string, + token?: string, + confirm?: boolean +) { + if (!home || !away) { + console.error("\n❌ Error: Both --home and --away parameters are required\n"); + console.log("Usage: npm run delete-game -- --date=2026-02-05 --home=Lakers --away=Warriors --env=dev --confirm\n"); + return; + } + + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const params = new URLSearchParams({ date, home, away }); + const url = `${baseUrl}/game?${params}`; + + console.log(`\n⚠️ WARNING: This will delete the game:`); + console.log(` ${home} vs ${away} on ${date}`); + console.log(`πŸ“‘ URL: ${url}`); + console.log(`🌍 Environment: ${env}\n`); + + // Safety check - require confirmation flag + if (!confirm) { + console.log("❌ Operation cancelled!"); + console.log("To confirm deletion, add the --confirm flag:"); + console.log(` npm run delete-game -- --date=${date} --home="${home}" --away="${away}" --env=${env} --confirm\n`); + return; + } + + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else if (process.env.API_AUTH_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.API_AUTH_TOKEN}`; + } else if (CONFIG.authToken) { + headers["Authorization"] = `Bearer ${CONFIG.authToken}`; + } + + console.log("πŸ—‘οΈ Deleting game...\n"); + + try { + const response = await fetch(url, { + method: "DELETE", + headers, + }); + + console.log(`Status: ${response.status} ${response.statusText}`); + + const data = await response.json(); + + if (response.ok) { + console.log("\nβœ… Success!\n"); + console.log(`πŸ“Š Deleted ${data.deletedCount} player(s)`); + console.log(`πŸ“… Date: ${data.date}`); + console.log(`🏠 Home: ${data.home}`); + console.log(`✈️ Away: ${data.away}`); + console.log(`πŸ“ Message: ${data.message}\n`); + } else { + console.log("\n❌ Error:\n"); + console.log(JSON.stringify(data, null, 2)); + } + } catch (error) { + console.error("\n❌ Request failed:"); + console.error(error); + } +} + +// Run the script +const args = parseArgs(); +deleteGame(args.date, args.home, args.away, args.env, args.token, args.confirm); diff --git a/scripts/get-players.ts b/scripts/get-players.ts index bc621b3..fbf7767 100644 --- a/scripts/get-players.ts +++ b/scripts/get-players.ts @@ -67,7 +67,7 @@ async function getPlayersForDate(date: string, env: string, token?: string) { if (response.ok) { console.log("\nβœ… Success!\n"); - console.log(JSON.stringify(data, null, 2)); + console.log(JSON.stringify(data.players?.slice(0, 3), null, 2)); console.log(`\nπŸ“Š Total players: ${data.players?.length || 0}`); } else { console.log("\n❌ Error:\n"); diff --git a/src/env.d.ts b/src/env.d.ts index f326a32..15c4eef 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -3,4 +3,5 @@ export interface Env { SHARED_SECRET: string; MONGODB_URI: string; MONGODB_DATABASE: string; + ENVIRONMENT?: string; // 'dev' or 'prod', defaults to 'dev' } diff --git a/src/handlers/delete_game.ts b/src/handlers/delete_game.ts new file mode 100644 index 0000000..c643c8b --- /dev/null +++ b/src/handlers/delete_game.ts @@ -0,0 +1,92 @@ +import { StatusCodes } from "http-status-codes"; +import type { MongoClient } from "mongodb"; +import type { Env } from "../env"; +import { + withMongoClient, + getPlayersCollection, + validateGameParameters, +} from "../shared"; + +/** + * Deletes a specific game from the database + */ +async function deleteGame( + client: MongoClient, + env: Env, + date: string, + home: string, + away: string +): Promise { + const playersCollection = getPlayersCollection(client, env); + const result = await playersCollection.deleteMany({ + date, + team_abbr: { $in: [home, away] } + }); + return result.deletedCount; +} + +/** + * Handler for DELETE /game endpoint + */ +export async function deleteGameHandler( + req: Request, + env: Env, + origin: string | null, + getCorsHeaders: (origin: string | null) => HeadersInit +): Promise { + const corsHeaders = getCorsHeaders(origin); + const url = new URL(req.url); + + // Validate game parameters + const validation = validateGameParameters(url); + if (!validation.valid) { + return new Response( + JSON.stringify({ error: validation.error }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + const { date, home, away } = validation; + + try { + // Delete game using shared MongoDB utilities + const deletedCount = await withMongoClient(env, (client) => + deleteGame(client, env, date, home, away) + ); + + return new Response( + JSON.stringify({ + date, + home, + away, + deletedCount, + message: `Deleted ${deletedCount} player(s) for game ${home} vs ${away} on ${date}`, + }), + { + status: StatusCodes.OK, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } catch (error) { + console.error("MongoDB error:", error); + return new Response( + JSON.stringify({ error: "Failed to delete game from database" }), + { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } +} diff --git a/src/handlers/get_players_for_date.ts b/src/handlers/get_players_for_date.ts index d46dae9..1aa8331 100644 --- a/src/handlers/get_players_for_date.ts +++ b/src/handlers/get_players_for_date.ts @@ -17,9 +17,10 @@ interface Player { */ async function fetchPlayersForDate( client: MongoClient, + env: Env, date: string ): Promise { - const playersCollection = getPlayersCollection(client); + const playersCollection = getPlayersCollection(client, env); const players = await playersCollection.find({ date }).toArray(); return players as unknown as Player[]; } @@ -56,7 +57,7 @@ export async function getPlayersForDate( try { // Query players using shared MongoDB utilities const players = await withMongoClient(env, (client) => - fetchPlayersForDate(client, date) + fetchPlayersForDate(client, env, date) ); return new Response(JSON.stringify({ date, players }), { diff --git a/src/handlers/index.ts b/src/handlers/index.ts index e4e8c4a..4db2ea5 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -2,4 +2,5 @@ export { hello } from "./hello"; export { health } from "./health"; export { doThing } from "./doThing"; export { notFound } from "./notFound"; -export { getPlayersForDate } from "./get_players_for_date"; \ No newline at end of file +export { getPlayersForDate } from "./get_players_for_date"; +export { deleteGameHandler } from "./delete_game"; \ No newline at end of file diff --git a/src/router.ts b/src/router.ts index 1110158..a1af334 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ -import { hello, health, notFound, getPlayersForDate } from "./handlers"; +import { hello, health, notFound, getPlayersForDate, deleteGameHandler } from "./handlers"; import { requireAuth, unauthorized } from "./auth"; import { StatusCodes } from "http-status-codes"; import type { Env } from "./env"; @@ -12,7 +12,7 @@ const ALLOWED_ORIGINS = [ function getCorsHeaders(origin: string | null): HeadersInit { const headers: HeadersInit = { - "Access-Control-Allow-Methods": "GET,POST,OPTIONS", + "Access-Control-Allow-Methods": "GET,POST,DELETE,OPTIONS", "Access-Control-Allow-Headers": "Authorization,Content-Type", }; if (origin && ALLOWED_ORIGINS.includes(origin)) { @@ -60,5 +60,15 @@ export async function route(req: Request, env?: Env): Promise { return getPlayersForDate(req, env, origin, getCorsHeaders); } + if (req.method === "DELETE" && url.pathname === "/game") { + if (!env) { + return new Response("Server configuration error", { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: getCorsHeaders(origin), + }); + } + return deleteGameHandler(req, env, origin, getCorsHeaders); + } + return notFound(origin, getCorsHeaders); } diff --git a/src/shared/mongodb.ts b/src/shared/mongodb.ts index 8dbf9d2..333be49 100644 --- a/src/shared/mongodb.ts +++ b/src/shared/mongodb.ts @@ -13,9 +13,10 @@ export async function connectToMongoDB(env: Env): Promise { /** * Gets the SmartScore players collection */ -export function getPlayersCollection(client: MongoClient): Collection { +export function getPlayersCollection(client: MongoClient, env: Env): Collection { const db = client.db("players"); - return db.collection("SmartScore"); + const collection = env.ENVIRONMENT === "prod" ? "SmartScore" : "SmartScoreDev"; + return db.collection(collection); } /** diff --git a/src/shared/validators.ts b/src/shared/validators.ts index 6ae7f64..1d05081 100644 --- a/src/shared/validators.ts +++ b/src/shared/validators.ts @@ -22,3 +22,32 @@ export function validateDateParameter(url: URL): { valid: true; date: string } | return { valid: true, date }; } + +/** + * Validates and extracts game parameters (date, home, away) from a URL + */ +export function validateGameParameters(url: URL): + | { valid: true; date: string; home: string; away: string } + | { valid: false; error: string } { + const date = url.searchParams.get("date"); + const home = url.searchParams.get("home"); + const away = url.searchParams.get("away"); + + if (!date) { + return { valid: false, error: "Date parameter is required" }; + } + + if (!home) { + return { valid: false, error: "Home parameter is required" }; + } + + if (!away) { + return { valid: false, error: "Away parameter is required" }; + } + + if (!isValidDateFormat(date)) { + return { valid: false, error: "Invalid date format. Expected YYYY-MM-DD" }; + } + + return { valid: true, date, home, away }; +} diff --git a/src/tests/delete_game.test.ts b/src/tests/delete_game.test.ts new file mode 100644 index 0000000..5e01a12 --- /dev/null +++ b/src/tests/delete_game.test.ts @@ -0,0 +1,282 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { deleteGameHandler } from '../handlers/delete_game'; +import type { Env } from '../env'; +import * as mongodb from '../shared/mongodb'; + +// Mock the MongoDB utilities +vi.mock('../shared/mongodb', () => ({ + withMongoClient: vi.fn(), + getPlayersCollection: vi.fn(), +})); + +const getCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', +}); + +const mockEnv: Env = { + API_AUTH_TOKEN: 'test-token', + SHARED_SECRET: 'test-secret', + MONGODB_URI: 'mongodb://localhost:27017', + MONGODB_DATABASE: 'test-db', +}; + +interface ErrorResponse { + error: string; +} + +interface SuccessResponse { + date: string; + home: string; + away: string; + deletedCount: number; + message: string; +} + +describe('deleteGameHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Parameter validation', () => { + it('should return 400 when date parameter is missing', async () => { + const request = new Request('https://api.example.com/game?home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Date parameter is required'); + }); + + it('should return 400 when home parameter is missing', async () => { + const request = new Request('https://api.example.com/game?date=2026-01-05&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Home parameter is required'); + }); + + it('should return 400 when away parameter is missing', async () => { + const request = new Request('https://api.example.com/game?date=2026-01-05&home=TeamA', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Away parameter is required'); + }); + + it('should return 400 when date format is invalid', async () => { + const request = new Request('https://api.example.com/game?date=2026-1-5&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Invalid date format. Expected YYYY-MM-DD'); + }); + + it('should accept valid parameters', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(1); + + const request = new Request('https://api.example.com/game?date=2026-01-05&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + }); + }); + + describe('Successful requests', () => { + it('should delete game for valid parameters', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(25); + + const request = new Request('https://api.example.com/game?date=2026-01-05&home=Lakers&away=Warriors', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-01-05'); + expect(body.home).toBe('Lakers'); + expect(body.away).toBe('Warriors'); + expect(body.deletedCount).toBe(25); + expect(body.message).toBe('Deleted 25 player(s) for game Lakers vs Warriors on 2026-01-05'); + }); + + it('should return zero deletedCount when game not found', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(0); + + const request = new Request('https://api.example.com/game?date=2026-12-31&home=TeamX&away=TeamY', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-12-31'); + expect(body.home).toBe('TeamX'); + expect(body.away).toBe('TeamY'); + expect(body.deletedCount).toBe(0); + expect(body.message).toBe('Deleted 0 player(s) for game TeamX vs TeamY on 2026-12-31'); + }); + + it('should include correct Content-Type header', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(1); + + const request = new Request('https://api.example.com/game?date=2026-01-05&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + }); + + it('should handle team names with spaces', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(20); + + const request = new Request('https://api.example.com/game?date=2026-01-15&home=Los%20Angeles%20Lakers&away=Golden%20State%20Warriors', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + const body = await response.json() as SuccessResponse; + expect(body.home).toBe('Los Angeles Lakers'); + expect(body.away).toBe('Golden State Warriors'); + expect(body.deletedCount).toBe(20); + }); + + it('should handle different teams on same date', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(15); + + const request = new Request('https://api.example.com/game?date=2026-01-20&home=Celtics&away=Heat', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + const body = await response.json() as SuccessResponse; + expect(body.deletedCount).toBe(15); + }); + }); + + describe('CORS headers', () => { + it('should include CORS headers with default origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(0); + + const request = new Request('https://api.example.com/game?date=2026-01-05&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,DELETE,OPTIONS'); + }); + + it('should include CORS headers with specific origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(0); + + const request = new Request('https://api.example.com/game?date=2026-01-05&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler( + request, + mockEnv, + 'https://example.com', + getCorsHeaders + ); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + + it('should include CORS headers even on error responses', async () => { + const request = new Request('https://api.example.com/game?home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler( + request, + mockEnv, + 'https://example.com', + getCorsHeaders + ); + + expect(response.status).toBe(400); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + }); + + describe('Error handling', () => { + it('should return 500 on database error', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue( + new Error('Database connection failed') + ); + + const request = new Request('https://api.example.com/game?date=2026-01-05&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Failed to delete game from database'); + }); + + it('should include CORS headers on database error', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue( + new Error('Database error') + ); + + const request = new Request('https://api.example.com/game?date=2026-01-05&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler( + request, + mockEnv, + 'https://example.com', + getCorsHeaders + ); + + expect(response.status).toBe(500); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + }); + + describe('Edge cases', () => { + it('should handle leap year dates', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(0); + + const request = new Request('https://api.example.com/game?date=2024-02-29&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + }); + + it('should handle date with leading zeros', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(0); + + const request = new Request('https://api.example.com/game?date=2026-01-01&home=TeamA&away=TeamB', { + method: 'DELETE', + }); + const response = await deleteGameHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-01-01'); + }); + }); +}); diff --git a/src/tests/mongodb.test.ts b/src/tests/mongodb.test.ts index 0e25c60..c444954 100644 --- a/src/tests/mongodb.test.ts +++ b/src/tests/mongodb.test.ts @@ -59,17 +59,18 @@ describe('MongoDB Utilities', () => { describe('getPlayersCollection', () => { it('should return the SmartScore collection from players database', async () => { const client = await connectToMongoDB(mockEnv); - const collection = getPlayersCollection(client); + const collection = getPlayersCollection(client, mockEnv); expect(client.db).toHaveBeenCalledWith('players'); expect(collection).toBeDefined(); }); it('should call db.collection with SmartScore', async () => { - const client = await connectToMongoDB(mockEnv); + const prodEnv = { ...mockEnv, ENVIRONMENT: 'prod' }; + const client = await connectToMongoDB(prodEnv); const mockDb = client.db('players'); - getPlayersCollection(client); + getPlayersCollection(client, prodEnv); expect(mockDb.collection).toHaveBeenCalledWith('SmartScore'); }); diff --git a/src/tests/router.test.ts b/src/tests/router.test.ts index a5f52a8..d8d3b2c 100644 --- a/src/tests/router.test.ts +++ b/src/tests/router.test.ts @@ -20,7 +20,7 @@ describe('Router', () => { const response = await route(req); expect(response.status).toBe(204); expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://smartscore.nathanprobert.ca'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,OPTIONS'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,DELETE,OPTIONS'); }); it('should return wildcard CORS for requests without Origin', async () => { diff --git a/src/tests/validators.test.ts b/src/tests/validators.test.ts index 624f2ea..76759e7 100644 --- a/src/tests/validators.test.ts +++ b/src/tests/validators.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { isValidDateFormat, validateDateParameter } from '../shared/validators'; +import { isValidDateFormat, validateDateParameter, validateGameParameters } from '../shared/validators'; describe('Validators', () => { describe('isValidDateFormat', () => { @@ -111,4 +111,92 @@ describe('Validators', () => { } }); }); + + describe('validateGameParameters', () => { + it('should return valid result for correct game parameters', () => { + const url = new URL('https://api.example.com/game?date=2026-01-05&home=Lakers&away=Warriors'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.date).toBe('2026-01-05'); + expect(result.home).toBe('Lakers'); + expect(result.away).toBe('Warriors'); + } + }); + + it('should return error when date parameter is missing', () => { + const url = new URL('https://api.example.com/game?home=Lakers&away=Warriors'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Date parameter is required'); + } + }); + + it('should return error when home parameter is missing', () => { + const url = new URL('https://api.example.com/game?date=2026-01-05&away=Warriors'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Home parameter is required'); + } + }); + + it('should return error when away parameter is missing', () => { + const url = new URL('https://api.example.com/game?date=2026-01-05&home=Lakers'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Away parameter is required'); + } + }); + + it('should return error for invalid date format', () => { + const url = new URL('https://api.example.com/game?date=2026-1-5&home=Lakers&away=Warriors'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Invalid date format. Expected YYYY-MM-DD'); + } + }); + + it('should handle team names with spaces', () => { + const url = new URL('https://api.example.com/game?date=2026-01-05&home=Los%20Angeles%20Lakers&away=Golden%20State%20Warriors'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.date).toBe('2026-01-05'); + expect(result.home).toBe('Los Angeles Lakers'); + expect(result.away).toBe('Golden State Warriors'); + } + }); + + it('should handle URL with additional query parameters', () => { + const url = new URL('https://api.example.com/game?foo=bar&date=2026-01-05&home=Lakers&away=Warriors&baz=qux'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(true); + if (result.valid) { + expect(result.date).toBe('2026-01-05'); + expect(result.home).toBe('Lakers'); + expect(result.away).toBe('Warriors'); + } + }); + + it('should handle empty parameter values', () => { + const url = new URL('https://api.example.com/game?date=&home=Lakers&away=Warriors'); + const result = validateGameParameters(url); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toBe('Date parameter is required'); + } + }); + }); }); diff --git a/wrangler.toml b/wrangler.toml index a06549a..ced739f 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -5,6 +5,10 @@ compatibility_date = "2026-01-31" [env.dev] name = "smartscore-api-dev" +[env.dev.vars] +ENVIRONMENT = "dev" [env.prod] name = "smartscore-api-prod" +[env.prod.vars] +ENVIRONMENT = "prod" From 13bfe637f8d36db4597cf0261f7f19a3e8d56f94 Mon Sep 17 00:00:00 2001 From: Nathan Probert Date: Thu, 5 Feb 2026 23:10:56 -0500 Subject: [PATCH 4/7] Add get unscored dates endpoint --- src/handlers/index.ts | 3 ++- src/router.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 4db2ea5..3c7a190 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -3,4 +3,5 @@ export { health } from "./health"; export { doThing } from "./doThing"; export { notFound } from "./notFound"; export { getPlayersForDate } from "./get_players_for_date"; -export { deleteGameHandler } from "./delete_game"; \ No newline at end of file +export { deleteGameHandler } from "./delete_game"; +export { getUnscoredDates } from "./get_unscored_dates"; \ No newline at end of file diff --git a/src/router.ts b/src/router.ts index a1af334..d9f7f2c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ -import { hello, health, notFound, getPlayersForDate, deleteGameHandler } from "./handlers"; +import { hello, health, notFound, getPlayersForDate, deleteGameHandler, getUnscoredDates } from "./handlers"; import { requireAuth, unauthorized } from "./auth"; import { StatusCodes } from "http-status-codes"; import type { Env } from "./env"; @@ -70,5 +70,15 @@ export async function route(req: Request, env?: Env): Promise { return deleteGameHandler(req, env, origin, getCorsHeaders); } + if (req.method === "GET" && url.pathname === "/unscored-dates") { + if (!env) { + return new Response("Server configuration error", { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: getCorsHeaders(origin), + }); + } + return getUnscoredDates(req, env, origin, getCorsHeaders); + } + return notFound(origin, getCorsHeaders); } From 6301ab26243eb39c688f84200c77983f2f2ebacf Mon Sep 17 00:00:00 2001 From: Nathan Probert Date: Thu, 5 Feb 2026 23:34:47 -0500 Subject: [PATCH 5/7] Add backfill --- .../adding_route.md.instructions.md | 46 +++ scripts/backfill-scored.ts | 143 +++++++++ scripts/get-unscored-dates.ts | 80 ++++++ src/handlers/backfill_scored.ts | 171 +++++++++++ src/handlers/get_unscored_dates.ts | 58 ++++ src/handlers/index.ts | 3 +- src/router.ts | 12 +- src/tests/backfill_scored.test.ts | 272 ++++++++++++++++++ src/tests/get_unscored_dates.test.ts | 184 ++++++++++++ 9 files changed, 967 insertions(+), 2 deletions(-) create mode 100644 .github/instructions/adding_route.md.instructions.md create mode 100644 scripts/backfill-scored.ts create mode 100644 scripts/get-unscored-dates.ts create mode 100644 src/handlers/backfill_scored.ts create mode 100644 src/handlers/get_unscored_dates.ts create mode 100644 src/tests/backfill_scored.test.ts create mode 100644 src/tests/get_unscored_dates.test.ts diff --git a/.github/instructions/adding_route.md.instructions.md b/.github/instructions/adding_route.md.instructions.md new file mode 100644 index 0000000..4bd3e54 --- /dev/null +++ b/.github/instructions/adding_route.md.instructions.md @@ -0,0 +1,46 @@ +# Checklist for Adding a New Route + +## Core Handler & Logic +- [ ] Create handler file in `src/handlers/` (e.g., `delete_game.ts`) + - Import required dependencies (StatusCodes, MongoClient, Env, shared utilities) + - Create database operation function + - Create handler function with proper signature: `(req: Request, env: Env, origin: string | null, getCorsHeaders: (...) => HeadersInit)` + - Add parameter validation + - Implement try-catch with proper error handling + - Return JSON responses with appropriate status codes and CORS headers + +## Tests +- [ ] Create test file in `src/tests/` (e.g., `delete_game.test.ts`) + - Mock MongoDB utilities + - Define getCorsHeaders and mockEnv + - Add parameter validation tests + - Add successful request tests + - Add CORS header tests + - Add error handling tests + - Add edge case tests + +## Validators (if needed) +- [ ] Add validation function to `src/shared/validators.ts` (e.g., `validateGameParameters`) +- [ ] Add validator tests to `src/tests/validators.test.ts` +- [ ] Export validator from `src/shared/index.ts` + +## Routing +- [ ] Export handler from `src/handlers/index.ts` +- [ ] Import handler in `src/router.ts` +- [ ] Add route handler in `src/router.ts` with proper method and path +- [ ] Update CORS methods in `getCorsHeaders` if using new HTTP method +- [ ] Update router tests in `src/tests/router.test.ts` (especially CORS tests) + +## Scripts (optional) +- [ ] Create script file in `scripts/` (e.g., `delete-game.ts`) + - Add argument parsing + - Add safety confirmations for destructive operations + - Include error handling and connection cleanup +- [ ] Add script to `package.json` scripts section +- [ ] Load environment variables with dotenv if needed + +## Verification +- [ ] Run `npm test` - all tests passing +- [ ] Run `npm run lint` - no linting errors +- [ ] Test manually with script or API client +- [ ] Verify CORS headers work correctly diff --git a/scripts/backfill-scored.ts b/scripts/backfill-scored.ts new file mode 100644 index 0000000..e72c858 --- /dev/null +++ b/scripts/backfill-scored.ts @@ -0,0 +1,143 @@ +/** + * Script to backfill scored status for players on a specific date + * Usage: npx tsx scripts/backfill-scored.ts --date=2026-02-05 --scoredIds="player1,player2,player3" --env=dev --confirm + * + * This will set scored=true for the specified players and scored=false for all others on that date. + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + date: string; + scoredIds: string; + env: "dev" | "prod" | "local"; + token?: string; + confirm?: boolean; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + env: (process.env.API_ENV as "dev" | "prod" | "local") || "local", + date: process.env.DATE || new Date().toISOString().split("T")[0], + confirm: false, + }; + + args.forEach((arg) => { + if (arg.startsWith("--date=")) { + parsed.date = arg.split("=")[1]; + } else if (arg.startsWith("--scoredIds=")) { + parsed.scoredIds = arg.split("=")[1]; + } else if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } else if (arg.startsWith("--token=")) { + parsed.token = arg.split("=")[1]; + } else if (arg === "--confirm" || arg === "--yes" || arg === "-y") { + parsed.confirm = true; + } + }); + + // Also support environment variables + if (process.env.AUTH_TOKEN && !parsed.token) { + parsed.token = process.env.AUTH_TOKEN; + } + + return parsed as Args; +} + +async function backfillScored( + date: string, + scoredIds: string, + env: string, + token?: string, + confirm?: boolean +) { + if (!date || !scoredIds) { + console.error("\n❌ Error: Both --date and --scoredIds parameters are required\n"); + console.log("Usage: npx tsx scripts/backfill-scored.ts --date=YYYY-MM-DD --scoredIds=\"id1,id2,id3\" --env=dev --confirm\n"); + return; + } + + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const url = `${baseUrl}/backfill-scored`; + + // Parse the player IDs + const scoredPlayerIds = scoredIds.split(",").map(id => id.trim()).filter(id => id.length > 0); + + if (scoredPlayerIds.length === 0) { + console.error("\n❌ Error: No valid player IDs provided\n"); + return; + } + + console.log(`\n⚠️ WARNING: This will backfill scored status for:`); + console.log(` Date: ${date}`); + console.log(` Scored players: ${scoredPlayerIds.join(", ")}`); + console.log(`πŸ“‘ URL: ${url}`); + console.log(`🌍 Environment: ${env}\n`); + + // Safety check - require confirmation flag for prod + if (env === "prod" && !confirm) { + console.log("❌ Operation cancelled!"); + console.log("To confirm changes to production data, add the --confirm flag:"); + console.log(` npx tsx scripts/backfill-scored.ts --date=${date} --scoredIds="${scoredIds}" --env=${env} --confirm\n`); + return; + } + + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else if (process.env.API_AUTH_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.API_AUTH_TOKEN}`; + } else if (CONFIG.authToken) { + headers["Authorization"] = `Bearer ${CONFIG.authToken}`; + } + + console.log("πŸ”„ Backfilling scored status...\n"); + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + date: date, + scoredPlayerIds: scoredPlayerIds, + }), + }); + + console.log(`Status: ${response.status} ${response.statusText}`); + + // Get response body as text first, then parse + const responseText = await response.text(); + + if (response.ok) { + const data = JSON.parse(responseText); + console.log("\nβœ… Success!\n"); + console.log(`πŸ“… Date: ${data.date}`); + console.log(`βœ“ Scored players updated: ${data.scoredCount}`); + console.log(`βœ— Unscored players updated: ${data.unscoredCount}`); + console.log(`πŸ“ Message: ${data.message}\n`); + } else { + let errorData; + try { + errorData = JSON.parse(responseText); + } catch { + errorData = { error: responseText }; + } + console.log("\n❌ Error:\n"); + console.log(JSON.stringify(errorData, null, 2)); + } + } catch (error) { + console.error("\n❌ Request failed:"); + console.error(error); + } +} + +// Run the script +const args = parseArgs(); +backfillScored(args.date, args.scoredIds, args.env, args.token, args.confirm); diff --git a/scripts/get-unscored-dates.ts b/scripts/get-unscored-dates.ts new file mode 100644 index 0000000..1aca82e --- /dev/null +++ b/scripts/get-unscored-dates.ts @@ -0,0 +1,80 @@ +/** + * Script to fetch dates with unscored games + * Usage: npm run script scripts/get-unscored-dates.ts -- --env=dev + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + env: "dev" | "prod" | "local"; + token?: string; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + env: (process.env.API_ENV as "dev" | "prod" | "local") || "local", + }; + + args.forEach((arg) => { + if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } else if (arg.startsWith("--token=")) { + parsed.token = arg.split("=")[1]; + } + }); + + // Also support environment variables + if (process.env.AUTH_TOKEN && !parsed.token) { + parsed.token = process.env.AUTH_TOKEN; + } + + return parsed as Args; +} + +async function getUnscoredDates(env: string, token?: string) { + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const url = `${baseUrl}/unscored-dates`; + + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else if (process.env.API_AUTH_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.API_AUTH_TOKEN}`; + } else if (CONFIG.authToken) { + headers["Authorization"] = `Bearer ${CONFIG.authToken}`; + } + + console.log(`\nπŸ” Fetching dates with unscored games`); + console.log(`πŸ“‘ URL: ${url}\n`); + + try { + const response = await fetch(url, { headers }); + + console.log(`Status: ${response.status} ${response.statusText}`); + + const data = await response.json(); + + if (response.ok) { + console.log("\nβœ… Success!\n"); + console.log(JSON.stringify(data.dates, null, 2)); + console.log(`\nπŸ“Š Total unscored dates: ${data.dates?.length || 0}`); + } else { + console.log("\n❌ Error:\n"); + console.log(JSON.stringify(data, null, 2)); + } + } catch (error) { + console.error("\n❌ Request failed:"); + console.error(error); + } +} + +// Run the script +const args = parseArgs(); +getUnscoredDates(args.env, args.token); diff --git a/src/handlers/backfill_scored.ts b/src/handlers/backfill_scored.ts new file mode 100644 index 0000000..316af62 --- /dev/null +++ b/src/handlers/backfill_scored.ts @@ -0,0 +1,171 @@ +import { StatusCodes } from "http-status-codes"; +import type { MongoClient } from "mongodb"; +import type { Env } from "../env"; +import { + withMongoClient, + getPlayersCollection, +} from "../shared"; + +/** + * Request body for backfill scored endpoint + */ +interface BackfillScoredRequest { + date: string; + scoredPlayerIds: string[]; +} + +/** + * Backfills scored status for players on a specific date + */ +async function backfillScored( + client: MongoClient, + env: Env, + date: string, + scoredPlayerIds: string[] +): Promise<{ scoredCount: number; unscoredCount: number }> { + const playersCollection = getPlayersCollection(client, env); + + // Convert string IDs to numbers for MongoDB query + const numericIds = scoredPlayerIds.map(id => parseInt(id, 10)); + + // Set scored=true for players in the list + const scoredResult = await playersCollection.updateMany( + { date: date, id: { $in: numericIds } }, + { $set: { scored: true } } + ); + + // Set scored=false for all other players on this date + const unscoredResult = await playersCollection.updateMany( + { date: date, id: { $nin: numericIds } }, + { $set: { scored: false } } + ); + + return { + scoredCount: scoredResult.modifiedCount, + unscoredCount: unscoredResult.modifiedCount, + }; +} + +/** + * Handler for POST /backfill-scored endpoint + */ +export async function backfillScoredHandler( + req: Request, + env: Env, + origin: string | null, + getCorsHeaders: (origin: string | null) => HeadersInit +): Promise { + const corsHeaders = getCorsHeaders(origin); + + // Parse request body + let body: BackfillScoredRequest; + try { + body = await req.json() as BackfillScoredRequest; + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON in request body" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Validate required fields + if (!body.date) { + return new Response( + JSON.stringify({ error: "Date field is required" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + if (!body.scoredPlayerIds || !Array.isArray(body.scoredPlayerIds)) { + return new Response( + JSON.stringify({ error: "scoredPlayerIds field is required and must be an array" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Validate date format + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(body.date)) { + return new Response( + JSON.stringify({ error: "Invalid date format. Expected YYYY-MM-DD" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Validate player IDs + if (!body.scoredPlayerIds.every(id => typeof id === "string" && id.length > 0)) { + return new Response( + JSON.stringify({ error: "All player IDs must be non-empty strings" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + try { + // Backfill scored status using shared MongoDB utilities + const result = await withMongoClient(env, (client) => + backfillScored(client, env, body.date, body.scoredPlayerIds) + ); + + return new Response( + JSON.stringify({ + date: body.date, + scoredPlayerIds: body.scoredPlayerIds, + scoredCount: result.scoredCount, + unscoredCount: result.unscoredCount, + message: `Updated ${result.scoredCount} player(s) to scored=true and ${result.unscoredCount} player(s) to scored=false for date ${body.date}`, + }), + { + status: StatusCodes.OK, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } catch (error) { + console.error("MongoDB error:", error); + return new Response( + JSON.stringify({ + error: "Failed to backfill scored status", + details: error instanceof Error ? error.message : "Unknown error", + }), + { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } +} diff --git a/src/handlers/get_unscored_dates.ts b/src/handlers/get_unscored_dates.ts new file mode 100644 index 0000000..43e04bf --- /dev/null +++ b/src/handlers/get_unscored_dates.ts @@ -0,0 +1,58 @@ +import { StatusCodes } from "http-status-codes"; +import type { MongoClient } from "mongodb"; +import type { Env } from "../env"; +import { + withMongoClient, + getPlayersCollection, +} from "../shared"; + +/** + * Fetches distinct dates where the scored column is null/undefined + */ +async function fetchUnscoredDates( + client: MongoClient, + env: Env +): Promise { + const playersCollection = getPlayersCollection(client, env); + const dates = await playersCollection.distinct("date", { scored: null }); + return dates as string[]; +} + +/** + * Handler for GET /unscored-dates endpoint + */ +export async function getUnscoredDates( + req: Request, + env: Env, + origin: string | null, + getCorsHeaders: (origin: string | null) => HeadersInit +): Promise { + const corsHeaders = getCorsHeaders(origin); + + try { + // Query unscored dates using shared MongoDB utilities + const dates = await withMongoClient(env, (client) => + fetchUnscoredDates(client, env) + ); + + return new Response(JSON.stringify({ dates }), { + status: StatusCodes.OK, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("MongoDB error:", error); + return new Response( + JSON.stringify({ error: "Failed to fetch unscored dates from database" }), + { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } +} diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 3c7a190..0ef6c16 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -4,4 +4,5 @@ export { doThing } from "./doThing"; export { notFound } from "./notFound"; export { getPlayersForDate } from "./get_players_for_date"; export { deleteGameHandler } from "./delete_game"; -export { getUnscoredDates } from "./get_unscored_dates"; \ No newline at end of file +export { getUnscoredDates } from "./get_unscored_dates"; +export { backfillScoredHandler } from "./backfill_scored"; \ No newline at end of file diff --git a/src/router.ts b/src/router.ts index d9f7f2c..5436a4f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ -import { hello, health, notFound, getPlayersForDate, deleteGameHandler, getUnscoredDates } from "./handlers"; +import { hello, health, notFound, getPlayersForDate, deleteGameHandler, getUnscoredDates, backfillScoredHandler } from "./handlers"; import { requireAuth, unauthorized } from "./auth"; import { StatusCodes } from "http-status-codes"; import type { Env } from "./env"; @@ -80,5 +80,15 @@ export async function route(req: Request, env?: Env): Promise { return getUnscoredDates(req, env, origin, getCorsHeaders); } + if (req.method === "POST" && url.pathname === "/backfill-scored") { + if (!env) { + return new Response("Server configuration error", { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: getCorsHeaders(origin), + }); + } + return backfillScoredHandler(req, env, origin, getCorsHeaders); + } + return notFound(origin, getCorsHeaders); } diff --git a/src/tests/backfill_scored.test.ts b/src/tests/backfill_scored.test.ts new file mode 100644 index 0000000..8843630 --- /dev/null +++ b/src/tests/backfill_scored.test.ts @@ -0,0 +1,272 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { backfillScoredHandler } from '../handlers/backfill_scored'; +import type { Env } from '../env'; +import * as mongodb from '../shared/mongodb'; + +// Mock the MongoDB utilities +vi.mock('../shared/mongodb', () => ({ + withMongoClient: vi.fn(), + getPlayersCollection: vi.fn(), +})); + +const getCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', +}); + +const mockEnv: Env = { + API_AUTH_TOKEN: 'test-token', + SHARED_SECRET: 'test-secret', + MONGODB_URI: 'mongodb://localhost:27017', + MONGODB_DATABASE: 'test-db', +}; + +interface ErrorResponse { + error: string; + details?: string; +} + +interface SuccessResponse { + date: string; + scoredPlayerIds: string[]; + scoredCount: number; + unscoredCount: number; + message: string; +} + +describe('backfillScoredHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Request body validation', () => { + it('should return 400 when request body is not valid JSON', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: 'invalid json', + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Invalid JSON in request body'); + }); + + it('should return 400 when date field is missing', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ scoredPlayerIds: ['player1', 'player2'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Date field is required'); + }); + + it('should return 400 when scoredPlayerIds field is missing', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05' }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('scoredPlayerIds field is required and must be an array'); + }); + + it('should return 400 when scoredPlayerIds is not an array', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05', scoredPlayerIds: 'not-an-array' }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('scoredPlayerIds field is required and must be an array'); + }); + + it('should return 400 when date format is invalid', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-1-5', scoredPlayerIds: ['player1'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Invalid date format. Expected YYYY-MM-DD'); + }); + + it('should return 400 when player IDs contain empty strings', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05', scoredPlayerIds: ['player1', '', 'player2'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('All player IDs must be non-empty strings'); + }); + + it('should return 400 when player IDs contain non-strings', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05', scoredPlayerIds: ['player1', 123, 'player2'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('All player IDs must be non-empty strings'); + }); + + it('should accept valid request body', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue({ scoredCount: 5, unscoredCount: 20 }); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05', scoredPlayerIds: ['player1', 'player2'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + }); + }); + + describe('Successful requests', () => { + it('should backfill scored status for valid request', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue({ scoredCount: 5, unscoredCount: 20 }); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ + date: '2026-01-15', + scoredPlayerIds: ['player1', 'player2', 'player3', 'player4', 'player5'], + }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-01-15'); + expect(body.scoredPlayerIds).toEqual(['player1', 'player2', 'player3', 'player4', 'player5']); + expect(body.scoredCount).toBe(5); + expect(body.unscoredCount).toBe(20); + expect(body.message).toBe('Updated 5 player(s) to scored=true and 20 player(s) to scored=false for date 2026-01-15'); + }); + + it('should handle empty scoredPlayerIds array', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue({ scoredCount: 0, unscoredCount: 25 }); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ + date: '2026-02-01', + scoredPlayerIds: [], + }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.date).toBe('2026-02-01'); + expect(body.scoredPlayerIds).toEqual([]); + expect(body.scoredCount).toBe(0); + expect(body.unscoredCount).toBe(25); + }); + + it('should include correct Content-Type header', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue({ scoredCount: 3, unscoredCount: 10 }); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05', scoredPlayerIds: ['p1', 'p2', 'p3'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + }); + + it('should handle zero updates when no players match', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue({ scoredCount: 0, unscoredCount: 0 }); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ + date: '2026-12-31', + scoredPlayerIds: ['nonexistent1', 'nonexistent2'], + }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.scoredCount).toBe(0); + expect(body.unscoredCount).toBe(0); + }); + }); + + describe('CORS headers', () => { + it('should include CORS headers in response', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue({ scoredCount: 2, unscoredCount: 8 }); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-20', scoredPlayerIds: ['p1', 'p2'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,DELETE,OPTIONS'); + }); + + it('should include CORS headers in error response', async () => { + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-1-5', scoredPlayerIds: ['p1'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + }); + + describe('Error handling', () => { + it('should handle MongoDB errors', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Connection failed')); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05', scoredPlayerIds: ['p1', 'p2'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Failed to backfill scored status'); + expect(body.details).toBe('Connection failed'); + }); + + it('should handle unknown errors', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue('Unknown error'); + + const request = new Request('https://api.example.com/backfill-scored', { + method: 'POST', + body: JSON.stringify({ date: '2026-01-05', scoredPlayerIds: ['p1'] }), + }); + const response = await backfillScoredHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Failed to backfill scored status'); + expect(body.details).toBe('Unknown error'); + }); + }); +}); diff --git a/src/tests/get_unscored_dates.test.ts b/src/tests/get_unscored_dates.test.ts new file mode 100644 index 0000000..07e52c7 --- /dev/null +++ b/src/tests/get_unscored_dates.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getUnscoredDates } from '../handlers/get_unscored_dates'; +import type { Env } from '../env'; +import * as mongodb from '../shared/mongodb'; + +// Mock the MongoDB utilities +vi.mock('../shared/mongodb', () => ({ + withMongoClient: vi.fn(), + getPlayersCollection: vi.fn(), +})); + +const getCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', +}); + +const mockEnv: Env = { + API_AUTH_TOKEN: 'test-token', + SHARED_SECRET: 'test-secret', + MONGODB_URI: 'mongodb://localhost:27017', + MONGODB_DATABASE: 'test-db', +}; + +interface ErrorResponse { + error: string; +} + +interface SuccessResponse { + dates: string[]; +} + +describe('getUnscoredDates', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Successful requests', () => { + it('should return unscored dates', async () => { + const mockDates = ['2026-01-05', '2026-01-06', '2026-01-07']; + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockDates); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.dates).toEqual(mockDates); + expect(body.dates).toHaveLength(3); + }); + + it('should return empty array when all dates are scored', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.dates).toEqual([]); + }); + + it('should include correct Content-Type header', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + }); + }); + + describe('CORS headers', () => { + it('should include CORS headers with default origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,DELETE,OPTIONS'); + }); + + it('should include CORS headers with specific origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const customGetCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', + }); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates( + request, + mockEnv, + 'https://smartscore.nathanprobert.ca', + customGetCorsHeaders + ); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://smartscore.nathanprobert.ca'); + }); + + it('should include CORS headers in error responses', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,DELETE,OPTIONS'); + }); + }); + + describe('Error handling', () => { + it('should return 500 when database operation fails', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Failed to fetch unscored dates from database'); + }); + + it('should return 500 when withMongoClient throws error', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Connection timeout')); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + }); + + it('should log error to console', async () => { + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const mockError = new Error('Database error'); + + vi.mocked(mongodb.withMongoClient).mockRejectedValue(mockError); + + const request = new Request('https://api.example.com/unscored-dates'); + await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(consoleErrorSpy).toHaveBeenCalledWith('MongoDB error:', mockError); + + consoleErrorSpy.mockRestore(); + }); + }); + + describe('Edge cases', () => { + it('should handle single unscored date', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(['2026-01-05']); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.dates).toHaveLength(1); + expect(body.dates[0]).toBe('2026-01-05'); + }); + + it('should handle large number of unscored dates', async () => { + const largeDateList = Array.from({ length: 100 }, (_, i) => { + const date = new Date(2026, 0, i + 1); + return date.toISOString().split('T')[0]; + }); + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(largeDateList); + + const request = new Request('https://api.example.com/unscored-dates'); + const response = await getUnscoredDates(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.dates).toHaveLength(100); + }); + }); +}); From 35eb1616f84715e5241e5bb08046fe0262958140 Mon Sep 17 00:00:00 2001 From: Nathan Probert Date: Fri, 6 Feb 2026 00:07:38 -0500 Subject: [PATCH 6/7] Add upload players functionality and related tests --- package.json | 1 + scripts/upload-players.ts | 144 +++++++++++ src/handlers/index.ts | 3 +- src/handlers/upload_players.ts | 211 ++++++++++++++++ src/router.ts | 12 +- src/tests/router.test.ts | 15 ++ src/tests/upload_players.test.ts | 401 +++++++++++++++++++++++++++++++ 7 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 scripts/upload-players.ts create mode 100644 src/handlers/upload_players.ts create mode 100644 src/tests/upload_players.test.ts diff --git a/package.json b/package.json index b8f049c..619eacc 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "script": "tsx", "get-players": "tsx scripts/get-players.ts", "delete-game": "tsx scripts/delete-game.ts", + "upload-players": "tsx scripts/upload-players.ts", "copy-collection": "tsx scripts/copy-collection.ts", "health-check": "tsx scripts/health-check.ts", "test-hello": "tsx scripts/test-hello.ts" diff --git a/scripts/upload-players.ts b/scripts/upload-players.ts new file mode 100644 index 0000000..69aa2a5 --- /dev/null +++ b/scripts/upload-players.ts @@ -0,0 +1,144 @@ +/** + * Script to upload a batch of players + * Usage: npm run script scripts/upload-players.ts -- --date=2026-02-06 --env=dev + * + * The player data is hardcoded in this script. + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + date: string; + env: "dev" | "prod" | "local"; + token?: string; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + date: process.env.DATE || new Date().toISOString().split("T")[0], + env: (process.env.API_ENV as "dev" | "prod" | "local") || "local", + }; + + args.forEach((arg) => { + if (arg.startsWith("--date=")) { + parsed.date = arg.split("=")[1]; + } else if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } else if (arg.startsWith("--token=")) { + parsed.token = arg.split("=")[1]; + } + }); + + // Also support environment variables + if (process.env.AUTH_TOKEN && !parsed.token) { + parsed.token = process.env.AUTH_TOKEN; + } + + return parsed as Args; +} + +// Hardcoded example player data +const EXAMPLE_PLAYERS = [ + { + name: "Zach Benson", + id: 8484145, + gpg: 0.16667, + hgpg: 0.14894, + five_gpg: 0.2, + hppg: 0.02128, + team_name: "Buffalo", + tgpg: 3.39285, + otga: 2.90909, + otshga: 0.45455, + home: true, + stat: 0.18891, + injury_status: "INJURED", + injury_desc: "Day-To-Day", + tims: 0, + }, + { + name: "Connor McDavid", + id: 8477934, + gpg: 0.6, + hgpg: 0.57895, + five_gpg: 0.7, + hppg: 0.12281, + team_name: "Edmonton", + tgpg: 3.2, + otga: 3.1, + otshga: 0.52, + home: false, + stat: 0.65, + injury_status: "HEALTHY", + injury_desc: "", + tims: 0, + } +]; + +async function uploadPlayers(date: string, env: string, token?: string) { + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const url = `${baseUrl}/players`; + + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else if (process.env.API_AUTH_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.API_AUTH_TOKEN}`; + } else if (CONFIG.authToken) { + headers["Authorization"] = `Bearer ${CONFIG.authToken}`; + } + + console.log(`\nπŸ“€ Uploading ${EXAMPLE_PLAYERS.length} player(s)...`); + console.log(`οΏ½ Date: ${date}`); + console.log(`οΏ½πŸ“‘ URL: ${url}`); + console.log(`🌍 Environment: ${env}\n`); + + // Show players being uploaded + console.log("Players to upload:"); + EXAMPLE_PLAYERS.forEach((player, i) => { + console.log(` ${i + 1}. ${player.name} (ID: ${player.id}) - ${player.team_name}`); + }); + console.log(); + + // Add date to each player + const playersWithDate = EXAMPLE_PLAYERS.map(player => ({ + ...player, + date, + })); + + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ players: playersWithDate }), + }); + + console.log(`Status: ${response.status} ${response.statusText}`); + + const data = await response.json(); + + if (response.ok) { + console.log("\nβœ… Success!\n"); + console.log(JSON.stringify(data, null, 2)); + console.log(`\nπŸ“Š ${data.insertedCount} player(s) uploaded successfully!`); + console.log("\nπŸ“ Note: The 'stat' field was filtered out before insertion."); + } else { + console.log("\n❌ Error:\n"); + console.log(JSON.stringify(data, null, 2)); + } + } catch (error) { + console.error("\n❌ Request failed:"); + console.error(error); + } +} + +// Main execution +const args = parseArgs(); +uploadPlayers(args.date, args.env, args.token); diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 0ef6c16..9b69c16 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -5,4 +5,5 @@ export { notFound } from "./notFound"; export { getPlayersForDate } from "./get_players_for_date"; export { deleteGameHandler } from "./delete_game"; export { getUnscoredDates } from "./get_unscored_dates"; -export { backfillScoredHandler } from "./backfill_scored"; \ No newline at end of file +export { backfillScoredHandler } from "./backfill_scored"; +export { uploadPlayersHandler } from "./upload_players"; \ No newline at end of file diff --git a/src/handlers/upload_players.ts b/src/handlers/upload_players.ts new file mode 100644 index 0000000..63333eb --- /dev/null +++ b/src/handlers/upload_players.ts @@ -0,0 +1,211 @@ +import { StatusCodes } from "http-status-codes"; +import type { MongoClient } from "mongodb"; +import type { Env } from "../env"; +import { + withMongoClient, + getPlayersCollection, +} from "../shared"; + +/** + * Player data structure from request body + */ +interface PlayerInput { + name: string; + id: number; + gpg: number; + hgpg: number; + five_gpg: number; + hppg: number; + team_name: string; + tgpg: number; + otga: number; + otshga: number; + home: boolean; + stat?: number; // This field will be filtered out + injury_status: string; + injury_desc: string; + tims: number; + [key: string]: unknown; // Allow additional fields +} + +/** + * Request body for upload players endpoint + */ +interface UploadPlayersRequest { + players: PlayerInput[]; +} + +/** + * Fields to filter out before inserting into database + */ +const FILTERED_FIELDS = new Set(["stat"]); + +/** + * Filters out unwanted fields from a player object + */ +function filterPlayerFields(player: PlayerInput): Omit { + const filtered: Record = {}; + for (const key in player) { + if (!FILTERED_FIELDS.has(key)) { + filtered[key] = player[key]; + } + } + return filtered; +} + +/** + * Uploads a batch of players to the database + */ +async function uploadPlayers( + client: MongoClient, + env: Env, + players: PlayerInput[] +): Promise { + const playersCollection = getPlayersCollection(client, env); + + // Filter out unwanted fields from all players + const filteredPlayers = players.map(filterPlayerFields); + + // Insert all players at once + const result = await playersCollection.insertMany(filteredPlayers); + + return result.insertedCount; +} + +/** + * Handler for POST /players endpoint + */ +export async function uploadPlayersHandler( + req: Request, + env: Env, + origin: string | null, + getCorsHeaders: (origin: string | null) => HeadersInit +): Promise { + const corsHeaders = getCorsHeaders(origin); + + // Parse request body + let body: UploadPlayersRequest; + try { + body = await req.json() as UploadPlayersRequest; + } catch { + return new Response( + JSON.stringify({ error: "Invalid JSON in request body" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Validate required fields + if (!body.players || !Array.isArray(body.players)) { + return new Response( + JSON.stringify({ error: "players field is required and must be an array" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + if (body.players.length === 0) { + return new Response( + JSON.stringify({ error: "players array cannot be empty" }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Validate each player has required fields + for (let i = 0; i < body.players.length; i++) { + const player = body.players[i]; + + if (!player || typeof player !== 'object') { + return new Response( + JSON.stringify({ error: `Player at index ${i} must be an object` }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Check for required fields + const requiredFields = ['name', 'id', 'team_name']; + for (const field of requiredFields) { + if (!(field in player) || player[field] === null || player[field] === undefined) { + return new Response( + JSON.stringify({ error: `Player at index ${i} is missing required field: ${field}` }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + } + + // Validate id is a number + if (typeof player.id !== 'number') { + return new Response( + JSON.stringify({ error: `Player at index ${i} has invalid id: must be a number` }), + { + status: StatusCodes.BAD_REQUEST, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + } + + try { + // Upload players using shared MongoDB utilities + const insertedCount = await withMongoClient(env, (client) => + uploadPlayers(client, env, body.players) + ); + + return new Response( + JSON.stringify({ + insertedCount, + message: `Successfully uploaded ${insertedCount} player(s)`, + }), + { + status: StatusCodes.CREATED, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } catch (error) { + console.error("MongoDB error:", error); + return new Response( + JSON.stringify({ error: "Failed to upload players to database" }), + { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } +} diff --git a/src/router.ts b/src/router.ts index 5436a4f..f4a8bec 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ -import { hello, health, notFound, getPlayersForDate, deleteGameHandler, getUnscoredDates, backfillScoredHandler } from "./handlers"; +import { hello, health, notFound, getPlayersForDate, deleteGameHandler, getUnscoredDates, backfillScoredHandler, uploadPlayersHandler } from "./handlers"; import { requireAuth, unauthorized } from "./auth"; import { StatusCodes } from "http-status-codes"; import type { Env } from "./env"; @@ -90,5 +90,15 @@ export async function route(req: Request, env?: Env): Promise { return backfillScoredHandler(req, env, origin, getCorsHeaders); } + if (req.method === "POST" && url.pathname === "/players") { + if (!env) { + return new Response("Server configuration error", { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: getCorsHeaders(origin), + }); + } + return uploadPlayersHandler(req, env, origin, getCorsHeaders); + } + return notFound(origin, getCorsHeaders); } diff --git a/src/tests/router.test.ts b/src/tests/router.test.ts index d8d3b2c..4d1605f 100644 --- a/src/tests/router.test.ts +++ b/src/tests/router.test.ts @@ -91,6 +91,21 @@ describe('Router', () => { expect(response.status).toBe(404); expect(await response.text()).toBe('Not Found'); }); + + it('should route POST /players to uploadPlayersHandler', async () => { + const req = new Request('https://api.example.com/players', { + method: 'POST', + headers: { + Authorization: 'Bearer test-secret-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ players: [] }), + }); + const response = await route(req); + // Should get 500 because env is not provided in router test context + expect(response.status).toBe(500); + expect(await response.text()).toBe('Server configuration error'); + }); }); describe('CORS', () => { diff --git a/src/tests/upload_players.test.ts b/src/tests/upload_players.test.ts new file mode 100644 index 0000000..b99e5ea --- /dev/null +++ b/src/tests/upload_players.test.ts @@ -0,0 +1,401 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { uploadPlayersHandler } from '../handlers/upload_players'; +import type { Env } from '../env'; +import * as mongodb from '../shared/mongodb'; + +// Mock the MongoDB utilities +vi.mock('../shared/mongodb', () => ({ + withMongoClient: vi.fn(), + getPlayersCollection: vi.fn(), +})); + +const getCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,DELETE,OPTIONS', +}); + +const mockEnv: Env = { + API_AUTH_TOKEN: 'test-token', + SHARED_SECRET: 'test-secret', + MONGODB_URI: 'mongodb://localhost:27017', + MONGODB_DATABASE: 'test-db', +}; + +interface ErrorResponse { + error: string; +} + +interface SuccessResponse { + insertedCount: number; + message: string; +} + +const createValidPlayer = () => ({ + name: "Zach Benson", + id: 8484145, + gpg: 0.16666666666666666, + hgpg: 0.14893617021276595, + five_gpg: 0.2, + hppg: 0.02127659574468085, + team_name: "Buffalo", + tgpg: 3.39285, + otga: 2.90909, + otshga: 0.45454545454545453, + home: true, + stat: 0.18890845775604248, + injury_status: "INJURED", + injury_desc: "Day-To-Day", + tims: 0 +}); + +describe('uploadPlayersHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Request body validation', () => { + it('should return 400 when request body is not valid JSON', async () => { + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: 'invalid json', + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Invalid JSON in request body'); + }); + + it('should return 400 when players field is missing', async () => { + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ data: 'test' }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('players field is required and must be an array'); + }); + + it('should return 400 when players is not an array', async () => { + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: 'not-an-array' }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('players field is required and must be an array'); + }); + + it('should return 400 when players array is empty', async () => { + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('players array cannot be empty'); + }); + + it('should return 400 when player is not an object', async () => { + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: ['not-an-object'] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Player at index 0 must be an object'); + }); + + it('should return 400 when player is missing name field', async () => { + const player = createValidPlayer(); + delete (player as Partial).name; + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [player] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Player at index 0 is missing required field: name'); + }); + + it('should return 400 when player is missing id field', async () => { + const player = createValidPlayer(); + delete (player as Partial).id; + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [player] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Player at index 0 is missing required field: id'); + }); + + it('should return 400 when player is missing team_name field', async () => { + const player = createValidPlayer(); + delete (player as Partial).team_name; + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [player] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Player at index 0 is missing required field: team_name'); + }); + + it('should return 400 when player id is not a number', async () => { + const player = { ...createValidPlayer(), id: '8484145' as unknown as number }; + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [player] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Player at index 0 has invalid id: must be a number'); + }); + + it('should return 400 when second player in array is invalid', async () => { + const validPlayer = createValidPlayer(); + const invalidPlayer = { ...createValidPlayer() }; + delete (invalidPlayer as Partial).name; + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [validPlayer, invalidPlayer] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(400); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Player at index 1 is missing required field: name'); + }); + + it('should accept valid request body with single player', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(1); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [createValidPlayer()] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(201); + }); + + it('should accept valid request body with multiple players', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(3); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ + players: [ + createValidPlayer(), + { ...createValidPlayer(), id: 8484146, name: "Player Two" }, + { ...createValidPlayer(), id: 8484147, name: "Player Three" }, + ] + }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(201); + }); + }); + + describe('Successful requests', () => { + it('should upload players and return inserted count', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(2); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ + players: [ + createValidPlayer(), + { ...createValidPlayer(), id: 8484146 } + ] + }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(201); + const body = await response.json() as SuccessResponse; + expect(body.insertedCount).toBe(2); + expect(body.message).toBe('Successfully uploaded 2 player(s)'); + }); + + it('should filter out stat field from players', async () => { + let capturedPlayers: Record[] = []; + + vi.mocked(mongodb.withMongoClient).mockImplementation(async (_env, callback) => { + const mockClient = {} as Parameters[0]; + const mockCollection = { + insertMany: vi.fn(async (players: Record[]) => { + capturedPlayers = players; + return { insertedCount: players.length }; + }) + }; + vi.mocked(mongodb.getPlayersCollection).mockReturnValue(mockCollection as unknown as ReturnType); + return callback(mockClient); + }); + + const playerWithStat = createValidPlayer(); + expect(playerWithStat.stat).toBeDefined(); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [playerWithStat] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(201); + expect(capturedPlayers).toHaveLength(1); + expect(capturedPlayers[0]).not.toHaveProperty('stat'); + expect(capturedPlayers[0]).toHaveProperty('name', 'Zach Benson'); + expect(capturedPlayers[0]).toHaveProperty('id', 8484145); + }); + }); + + describe('CORS headers', () => { + it('should include CORS headers in successful response', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(1); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [createValidPlayer()] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, 'https://example.com', getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + + it('should include CORS headers in error response', async () => { + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: 'invalid json', + }); + const response = await uploadPlayersHandler(request, mockEnv, 'https://example.com', getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + + it('should handle null origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(1); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [createValidPlayer()] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + }); + + describe('Error handling', () => { + it('should return 500 when database operation fails', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [createValidPlayer()] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Failed to upload players to database'); + }); + + it('should include CORS headers in error response', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Database error')); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [createValidPlayer()] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, 'https://example.com', getCorsHeaders); + + expect(response.status).toBe(500); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://example.com'); + }); + }); + + describe('Edge cases', () => { + it('should handle player with null values in optional fields', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(1); + + const player = { + name: "Test Player", + id: 8484145, + team_name: "Test Team", + gpg: null, + hgpg: null, + }; + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [player] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(201); + }); + + it('should handle player with additional unknown fields', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue(1); + + const player = { + ...createValidPlayer(), + customField: 'custom value', + anotherField: 123, + }; + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players: [player] }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(201); + }); + + it('should handle large batch of players', async () => { + const playerCount = 100; + vi.mocked(mongodb.withMongoClient).mockResolvedValue(playerCount); + + const players = Array.from({ length: playerCount }, (_, i) => ({ + ...createValidPlayer(), + id: 8484145 + i, + name: `Player ${i}`, + })); + + const request = new Request('https://api.example.com/players', { + method: 'POST', + body: JSON.stringify({ players }), + }); + const response = await uploadPlayersHandler(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(201); + const body = await response.json() as SuccessResponse; + expect(body.insertedCount).toBe(playerCount); + }); + }); +}); From 9e03c885db8293a5f7b6e57c66d7460208a1f770 Mon Sep 17 00:00:00 2001 From: Nathan Probert Date: Fri, 6 Feb 2026 00:19:01 -0500 Subject: [PATCH 7/7] Add getAllPlayers endpoint and related functionality --- package.json | 1 + scripts/get-all-players.ts | 105 +++++++++++++ src/handlers/get_all_players.ts | 88 +++++++++++ src/handlers/index.ts | 1 + src/router.ts | 12 +- src/tests/get_all_players.test.ts | 239 ++++++++++++++++++++++++++++++ src/tests/router.test.ts | 13 ++ 7 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 scripts/get-all-players.ts create mode 100644 src/handlers/get_all_players.ts create mode 100644 src/tests/get_all_players.test.ts diff --git a/package.json b/package.json index 619eacc..e077127 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "test": "vitest run", "script": "tsx", "get-players": "tsx scripts/get-players.ts", + "get-all-players": "tsx scripts/get-all-players.ts", "delete-game": "tsx scripts/delete-game.ts", "upload-players": "tsx scripts/upload-players.ts", "copy-collection": "tsx scripts/copy-collection.ts", diff --git a/scripts/get-all-players.ts b/scripts/get-all-players.ts new file mode 100644 index 0000000..62f7f3a --- /dev/null +++ b/scripts/get-all-players.ts @@ -0,0 +1,105 @@ +/** + * Script to fetch all players (base64 encoded) + * Usage: npm run script scripts/get-all-players.ts -- --env=dev + */ + +import { CONFIG } from "./config.js"; + +export {}; + +interface Args { + env: "dev" | "prod" | "local"; + token?: string; + decode?: boolean; +} + +function parseArgs(): Args { + // Filter to only include arguments starting with -- + const args = process.argv.slice(2).filter(arg => arg.startsWith("--")); + const parsed: Partial = { + env: (process.env.API_ENV as "dev" | "prod" | "local") || "local", + decode: true, + }; + + args.forEach((arg) => { + if (arg.startsWith("--env=")) { + parsed.env = arg.split("=")[1] as "dev" | "prod" | "local"; + } else if (arg.startsWith("--token=")) { + parsed.token = arg.split("=")[1]; + } else if (arg === "--no-decode") { + parsed.decode = false; + } + }); + + // Also support environment variables + if (process.env.AUTH_TOKEN && !parsed.token) { + parsed.token = process.env.AUTH_TOKEN; + } + + return parsed as Args; +} + +async function getAllPlayers(env: string, token?: string, decode = true) { + const baseUrl = CONFIG.urls[env as keyof typeof CONFIG.urls] || CONFIG.urls.local; + const url = `${baseUrl}/all-players`; + + const headers: HeadersInit = { + "Content-Type": "application/json", + }; + + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } else if (process.env.API_AUTH_TOKEN) { + headers["Authorization"] = `Bearer ${process.env.API_AUTH_TOKEN}`; + } else if (CONFIG.authToken) { + headers["Authorization"] = `Bearer ${CONFIG.authToken}`; + } + + console.log(`\nπŸ” Fetching all players`); + console.log(`πŸ“‘ URL: ${url}\n`); + + try { + const response = await fetch(url, { headers }); + + console.log(`Status: ${response.status} ${response.statusText}`); + + const data = await response.json(); + + if (response.ok) { + console.log("\nβœ… Success!\n"); + + if (decode && data.data) { + // Decode the base64 data + const decodedJson = Buffer.from(data.data, 'base64').toString('utf-8'); + const decodedData = JSON.parse(decodedJson); + + console.log("πŸ“¦ Base64 data size:", data.data.length, "characters"); + console.log("πŸ“Š Total players:", decodedData.players?.length || 0); + + if (decodedData.players?.length > 0) { + console.log("\nπŸ“ First 3 players (preview):\n"); + console.log(JSON.stringify(decodedData.players.slice(0, 3), null, 2)); + + // Show what fields are included/excluded + const firstPlayer = decodedData.players[0]; + console.log("\nπŸ”‘ Available fields:", Object.keys(firstPlayer).join(", ")); + console.log("❌ Excluded fields: _id, id, team_abbr"); + } + } else { + console.log("πŸ“¦ Raw base64 data:\n"); + console.log(data.data?.substring(0, 100) + "..."); + console.log("\nπŸ’‘ Use --decode to see the full data (default behavior)"); + } + } else { + console.log("\n❌ Error:\n"); + console.log(JSON.stringify(data, null, 2)); + } + } catch (error) { + console.error("\n❌ Request failed:"); + console.error(error); + } +} + +// Run the script +const args = parseArgs(); +getAllPlayers(args.env, args.token, args.decode); diff --git a/src/handlers/get_all_players.ts b/src/handlers/get_all_players.ts new file mode 100644 index 0000000..7cc1865 --- /dev/null +++ b/src/handlers/get_all_players.ts @@ -0,0 +1,88 @@ +import { StatusCodes } from "http-status-codes"; +import type { MongoClient } from "mongodb"; +import type { Env } from "../env"; +import { + withMongoClient, + getPlayersCollection, +} from "../shared"; + +// Fields to exclude from the player objects +const EXCLUDED_FIELDS = ["_id", "id", "team_abbr"]; + +interface Player { + [key: string]: unknown; +} + +/** + * Removes excluded fields from a player object + */ +function filterPlayerFields(player: Player): Player { + const filtered: Player = {}; + for (const [key, value] of Object.entries(player)) { + if (!EXCLUDED_FIELDS.includes(key)) { + filtered[key] = value; + } + } + return filtered; +} + +/** + * Fetches all players from the database + */ +async function fetchAllPlayers( + client: MongoClient, + env: Env +): Promise { + const playersCollection = getPlayersCollection(client, env); + const players = await playersCollection.find({}).toArray(); + return players as unknown as Player[]; +} + +/** + * Handler for GET /all-players endpoint + * Returns all players as a base64 encoded JSON string with specified fields excluded + */ +export async function getAllPlayers( + req: Request, + env: Env, + origin: string | null, + getCorsHeaders: (origin: string | null) => HeadersInit +): Promise { + const corsHeaders = getCorsHeaders(origin); + + try { + // Query all players using shared MongoDB utilities + const players = await withMongoClient(env, (client) => + fetchAllPlayers(client, env) + ); + + // Filter out excluded fields from each player + const filteredPlayers = players.map(filterPlayerFields); + + // Convert to JSON string + const jsonString = JSON.stringify({ players: filteredPlayers }); + + // Encode to base64 (handle UTF-8 characters properly) + const base64Encoded = Buffer.from(jsonString, 'utf-8').toString('base64'); + + return new Response(JSON.stringify({ data: base64Encoded }), { + status: StatusCodes.OK, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("MongoDB error:", error); + return new Response( + JSON.stringify({ error: "Failed to fetch players from database" }), + { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } +} diff --git a/src/handlers/index.ts b/src/handlers/index.ts index 9b69c16..ebf0fed 100644 --- a/src/handlers/index.ts +++ b/src/handlers/index.ts @@ -3,6 +3,7 @@ export { health } from "./health"; export { doThing } from "./doThing"; export { notFound } from "./notFound"; export { getPlayersForDate } from "./get_players_for_date"; +export { getAllPlayers } from "./get_all_players"; export { deleteGameHandler } from "./delete_game"; export { getUnscoredDates } from "./get_unscored_dates"; export { backfillScoredHandler } from "./backfill_scored"; diff --git a/src/router.ts b/src/router.ts index f4a8bec..430c9d5 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,5 +1,5 @@ -import { hello, health, notFound, getPlayersForDate, deleteGameHandler, getUnscoredDates, backfillScoredHandler, uploadPlayersHandler } from "./handlers"; +import { hello, health, notFound, getPlayersForDate, getAllPlayers, deleteGameHandler, getUnscoredDates, backfillScoredHandler, uploadPlayersHandler } from "./handlers"; import { requireAuth, unauthorized } from "./auth"; import { StatusCodes } from "http-status-codes"; import type { Env } from "./env"; @@ -60,6 +60,16 @@ export async function route(req: Request, env?: Env): Promise { return getPlayersForDate(req, env, origin, getCorsHeaders); } + if (req.method === "GET" && url.pathname === "/all-players") { + if (!env) { + return new Response("Server configuration error", { + status: StatusCodes.INTERNAL_SERVER_ERROR, + headers: getCorsHeaders(origin), + }); + } + return getAllPlayers(req, env, origin, getCorsHeaders); + } + if (req.method === "DELETE" && url.pathname === "/game") { if (!env) { return new Response("Server configuration error", { diff --git a/src/tests/get_all_players.test.ts b/src/tests/get_all_players.test.ts new file mode 100644 index 0000000..6b53e7a --- /dev/null +++ b/src/tests/get_all_players.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getAllPlayers } from '../handlers/get_all_players'; +import type { Env } from '../env'; +import * as mongodb from '../shared/mongodb'; + +// Mock the MongoDB utilities +vi.mock('../shared/mongodb', () => ({ + withMongoClient: vi.fn(), + getPlayersCollection: vi.fn(), +})); + +const getCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', +}); + +const mockEnv: Env = { + API_AUTH_TOKEN: 'test-token', + SHARED_SECRET: 'test-secret', + MONGODB_URI: 'mongodb://localhost:27017', + MONGODB_DATABASE: 'test-db', + ENVIRONMENT: 'dev', +}; + +interface ErrorResponse { + error: string; +} + +interface SuccessResponse { + data: string; +} + +describe('getAllPlayers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Successful requests', () => { + it('should return all players as base64 encoded data', async () => { + const mockPlayers = [ + { _id: '1', id: 'player1', name: 'Player 1', score: 100, team_abbr: 'LAL', date: '2026-01-05' }, + { _id: '2', id: 'player2', name: 'Player 2', score: 95, team_abbr: 'GSW', date: '2026-01-05' }, + ]; + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockPlayers); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + expect(body.data).toBeDefined(); + + // Decode and verify the data + const decoded = JSON.parse(Buffer.from(body.data, 'base64').toString('utf-8')); + expect(decoded.players).toHaveLength(2); + }); + + it('should exclude _id, id, and team_abbr fields from players', async () => { + const mockPlayers = [ + { _id: '1', id: 'player1', name: 'Player 1', score: 100, team_abbr: 'LAL', date: '2026-01-05' }, + { _id: '2', id: 'player2', name: 'Player 2', score: 95, team_abbr: 'GSW', date: '2026-01-06' }, + ]; + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockPlayers); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + const body = await response.json() as SuccessResponse; + const decoded = JSON.parse(Buffer.from(body.data, 'base64').toString('utf-8')); + + // Check that excluded fields are not present + decoded.players.forEach((player: Record) => { + expect(player).not.toHaveProperty('_id'); + expect(player).not.toHaveProperty('id'); + expect(player).not.toHaveProperty('team_abbr'); + + // Check that other fields are present + expect(player).toHaveProperty('name'); + expect(player).toHaveProperty('score'); + expect(player).toHaveProperty('date'); + }); + }); + + it('should return empty array when no players found', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(200); + + const body = await response.json() as SuccessResponse; + const decoded = JSON.parse(Buffer.from(body.data, 'base64').toString('utf-8')); + expect(decoded.players).toEqual([]); + }); + + it('should include correct Content-Type header', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Content-Type')).toBe('application/json'); + }); + + it('should preserve all non-excluded fields', async () => { + const mockPlayers = [ + { + _id: '1', + id: 'player1', + name: 'Player 1', + score: 100, + team_abbr: 'LAL', + position: 'PG', + height: 6.3, + weight: 185, + custom_field: 'value' + }, + ]; + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockPlayers); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + const body = await response.json() as SuccessResponse; + const decoded = JSON.parse(Buffer.from(body.data, 'base64').toString('utf-8')); + + const player = decoded.players[0]; + expect(player.name).toBe('Player 1'); + expect(player.score).toBe(100); + expect(player.position).toBe('PG'); + expect(player.height).toBe(6.3); + expect(player.weight).toBe(185); + expect(player.custom_field).toBe('value'); + }); + }); + + describe('CORS headers', () => { + it('should include CORS headers with default origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST,OPTIONS'); + }); + + it('should include CORS headers with specific origin', async () => { + vi.mocked(mongodb.withMongoClient).mockResolvedValue([]); + + const specificOrigin = 'https://example.com'; + const customGetCorsHeaders = (origin: string | null) => ({ + 'Access-Control-Allow-Origin': origin || '*', + 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', + }); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, specificOrigin, customGetCorsHeaders); + + expect(response.headers.get('Access-Control-Allow-Origin')).toBe(specificOrigin); + }); + + it('should include CORS headers in error responses', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Database connection failed')); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('*'); + }); + }); + + describe('Error handling', () => { + it('should return 500 when database operation fails', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Database error')); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + const body = await response.json() as ErrorResponse; + expect(body.error).toBe('Failed to fetch players from database'); + }); + + it('should handle database connection timeout', async () => { + vi.mocked(mongodb.withMongoClient).mockRejectedValue(new Error('Connection timeout')); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + expect(response.status).toBe(500); + }); + }); + + describe('Base64 encoding', () => { + it('should return valid base64 encoded string', async () => { + const mockPlayers = [ + { name: 'Player 1', score: 100 }, + ]; + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockPlayers); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + const body = await response.json() as SuccessResponse; + + // Verify it's a valid base64 string by decoding it + expect(() => Buffer.from(body.data, 'base64').toString('utf-8')).not.toThrow(); + + // Verify the decoded content is valid JSON + const decoded = Buffer.from(body.data, 'base64').toString('utf-8'); + expect(() => JSON.parse(decoded)).not.toThrow(); + }); + + it('should handle special characters in player data', async () => { + const mockPlayers = [ + { name: 'Luka DončiΔ‡', team: 'Mavericks', nickname: 'The Don\'t Sleep On Me' }, + ]; + + vi.mocked(mongodb.withMongoClient).mockResolvedValue(mockPlayers); + + const request = new Request('https://api.example.com/all-players'); + const response = await getAllPlayers(request, mockEnv, null, getCorsHeaders); + + const body = await response.json() as SuccessResponse; + const decoded = JSON.parse(Buffer.from(body.data, 'base64').toString('utf-8')); + + expect(decoded.players[0].name).toBe('Luka DončiΔ‡'); + expect(decoded.players[0].nickname).toBe('The Don\'t Sleep On Me'); + }); + }); +}); diff --git a/src/tests/router.test.ts b/src/tests/router.test.ts index 4d1605f..003fe4e 100644 --- a/src/tests/router.test.ts +++ b/src/tests/router.test.ts @@ -106,6 +106,19 @@ describe('Router', () => { expect(response.status).toBe(500); expect(await response.text()).toBe('Server configuration error'); }); + + it('should route GET /all-players to getAllPlayers handler', async () => { + const req = new Request('https://api.example.com/all-players', { + method: 'GET', + headers: { + Authorization: 'Bearer test-secret-token', + }, + }); + const response = await route(req); + // Should get 500 because env is not provided in router test context + expect(response.status).toBe(500); + expect(await response.text()).toBe('Server configuration error'); + }); }); describe('CORS', () => {