From f567263cca5c6765e18c777533bb182fa7aa8145 Mon Sep 17 00:00:00 2001 From: Cornerstone Dev Date: Wed, 25 Feb 2026 14:30:17 +0100 Subject: [PATCH] feat: REST user usage and stats --- jest.config.js | 15 +- package-lock.json | 203 ++++++++++++++++- package.json | 5 +- src/index.ts | 59 ++++- src/middleware/auth.ts | 29 +++ src/repositories/usageEventsRepository.ts | 159 ++++++++++++++ src/tests/usage.test.ts | 254 ++++++++++++++++++++++ src/tests/usageEventsRepository.test.ts | 138 ++++++++++++ src/types/usage.ts | 33 +++ src/validators/usageValidator.ts | 67 ++++++ 10 files changed, 952 insertions(+), 10 deletions(-) create mode 100644 src/middleware/auth.ts create mode 100644 src/repositories/usageEventsRepository.ts create mode 100644 src/tests/usage.test.ts create mode 100644 src/tests/usageEventsRepository.test.ts create mode 100644 src/types/usage.ts create mode 100644 src/validators/usageValidator.ts diff --git a/jest.config.js b/jest.config.js index 779b71c..f37d7a0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,15 @@ /** @type {import('ts-jest').JestConfigWithTsJest} */ -module.exports = { - preset: 'ts-jest', +export default { + preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', - testMatch: ['**/?(*.)+(spec|test).ts'] + testMatch: ['**/?(*.)+(spec|test).ts'], + extensionsToTreatAsEsm: ['.ts'], + globals: { + 'ts-jest': { + useESM: true + } + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1' + } }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6984af8..e907c8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,10 @@ "name": "callora-backend", "version": "0.0.1", "dependencies": { - "express": "^4.18.2" + "@types/jsonwebtoken": "^9.0.10", + "express": "^4.18.2", + "joi": "^18.0.2", + "jsonwebtoken": "^9.0.3" }, "devDependencies": { "@types/express": "^4.17.21", @@ -1187,6 +1190,54 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmmirror.com/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1819,6 +1870,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -1995,6 +2052,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmmirror.com/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -2009,11 +2076,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2149,6 +2221,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -3096,6 +3169,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3555,6 +3634,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5424,6 +5512,24 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/joi": { + "version": "18.0.2", + "resolved": "https://registry.npmmirror.com/joi/-/joi-18.0.2.tgz", + "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5499,6 +5605,55 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5556,6 +5711,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5563,6 +5754,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -6325,7 +6522,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -7185,7 +7381,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { diff --git a/package.json b/package.json index 1dc3f49..d8a2988 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "test": "jest --runInBand" }, "dependencies": { - "express": "^4.18.2" + "@types/jsonwebtoken": "^9.0.10", + "express": "^4.18.2", + "joi": "^18.0.2", + "jsonwebtoken": "^9.0.3" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/src/index.ts b/src/index.ts index c40217b..c545723 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ import express from 'express'; +import { requireAuth, AuthenticatedRequest } from './middleware/auth.js'; +import { validateUsageQuery } from './validators/usageValidator.js'; +import { UsageEventsRepository } from './repositories/usageEventsRepository.js'; const app = express(); const PORT = process.env.PORT ?? 3000; @@ -13,8 +16,60 @@ app.get('/api/apis', (_req, res) => { res.json({ apis: [] }); }); -app.get('/api/usage', (_req, res) => { - res.json({ calls: 0, period: 'current' }); +/** + * GET /api/usage + * + * Returns usage events and aggregated statistics for the authenticated user. + * + * Query Parameters: + * - from: ISO date string (optional) - Start date for usage period + * - to: ISO date string (optional) - End date for usage period + * - limit: integer (optional, max 1000) - Maximum number of events to return + * + * Default period: Last 30 days if from/to not provided + * + * Authentication: Requires valid JWT token in Authorization header (Bearer ) + * + * Returns: + * - events: Array of usage events for the user + * - stats: Aggregated statistics including total spent, total calls, and breakdown by API + */ +app.get('/api/usage', requireAuth, validateUsageQuery, async (req: AuthenticatedRequest, res) => { + try { + const usageRepo = new UsageEventsRepository(); + + const now = new Date(); + const defaultFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days ago + + const fromDate = req.query.from ? new Date(req.query.from as string) : defaultFrom; + const toDate = req.query.to ? new Date(req.query.to as string) : now; + const limit = req.query.limit ? parseInt(req.query.limit as string) : undefined; + + if (!req.user) { + return res.status(401).json({ error: 'User authentication required' }); + } + + const { events, stats } = await usageRepo.getUsageByWalletAddress( + req.user.walletAddress, + fromDate, + toDate, + limit + ); + + res.json({ + events, + stats: { + ...stats, + period: { + from: stats.period.from.toISOString(), + to: stats.period.to.toISOString() + } + } + }); + } catch (error) { + console.error('Error fetching usage data:', error); + res.status(500).json({ error: 'Internal server error' }); + } }); if (process.env.NODE_ENV !== 'test') { diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..f3c6fb2 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,29 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +export interface AuthenticatedRequest extends Request { + user?: { + walletAddress: string; + userId: string; + }; +} + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required. Please provide a valid JWT token.' }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, JWT_SECRET) as { walletAddress: string; userId: string }; + req.user = decoded; + next(); + } catch (error) { + return res.status(401).json({ error: 'Invalid or expired token.' }); + } +}; diff --git a/src/repositories/usageEventsRepository.ts b/src/repositories/usageEventsRepository.ts new file mode 100644 index 0000000..7c0ec38 --- /dev/null +++ b/src/repositories/usageEventsRepository.ts @@ -0,0 +1,159 @@ +import { UsageEvent, UsageStats } from '../types/usage.js'; + +export class UsageEventsRepository { + private events: UsageEvent[] = []; + + constructor() { + this.seedMockData(); + } + + private seedMockData() { + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + this.events = [ + { + id: '1', + userId: 'user1', + walletAddress: '0x1234567890123456789012345678901234567890', + apiEndpoint: '/api/v1/chat', + method: 'POST', + timestamp: new Date(now.getTime() - 2 * 60 * 60 * 1000), + cost: 0.002, + statusCode: 200, + responseTime: 150 + }, + { + id: '2', + userId: 'user1', + walletAddress: '0x1234567890123456789012345678901234567890', + apiEndpoint: '/api/v1/analyze', + method: 'POST', + timestamp: new Date(now.getTime() - 24 * 60 * 60 * 1000), + cost: 0.005, + statusCode: 200, + responseTime: 320 + }, + { + id: '3', + userId: 'user1', + walletAddress: '0x1234567890123456789012345678901234567890', + apiEndpoint: '/api/v1/chat', + method: 'POST', + timestamp: new Date(now.getTime() - 48 * 60 * 60 * 1000), + cost: 0.002, + statusCode: 200, + responseTime: 180 + }, + { + id: '4', + userId: 'user2', + walletAddress: '0x9876543210987654321098765432109876543210', + apiEndpoint: '/api/v1/chat', + method: 'POST', + timestamp: new Date(now.getTime() - 3 * 60 * 60 * 1000), + cost: 0.002, + statusCode: 200, + responseTime: 120 + } + ]; + } + + async getUsageByUserId( + userId: string, + fromDate: Date, + toDate: Date, + limit?: number + ): Promise<{ events: UsageEvent[]; stats: UsageStats }> { + let filteredEvents = this.events.filter(event => + event.userId === userId && + event.timestamp >= fromDate && + event.timestamp <= toDate + ); + + if (limit && limit > 0) { + filteredEvents = filteredEvents.slice(0, limit); + } + + const totalSpent = filteredEvents.reduce((sum, event) => sum + event.cost, 0); + const totalCalls = filteredEvents.length; + + const breakdown: { [key: string]: { calls: number; cost: number; avgResponseTime: number } } = {}; + + filteredEvents.forEach(event => { + if (!breakdown[event.apiEndpoint]) { + breakdown[event.apiEndpoint] = { + calls: 0, + cost: 0, + avgResponseTime: 0 + }; + } + + const api = breakdown[event.apiEndpoint]; + api.calls += 1; + api.cost += event.cost; + api.avgResponseTime = (api.avgResponseTime * (api.calls - 1) + event.responseTime) / api.calls; + }); + + const stats: UsageStats = { + totalSpent, + totalCalls, + period: { + from: fromDate, + to: toDate + }, + breakdown + }; + + return { events: filteredEvents, stats }; + } + + async getUsageByWalletAddress( + walletAddress: string, + fromDate: Date, + toDate: Date, + limit?: number + ): Promise<{ events: UsageEvent[]; stats: UsageStats }> { + let filteredEvents = this.events.filter(event => + event.walletAddress === walletAddress && + event.timestamp >= fromDate && + event.timestamp <= toDate + ); + + if (limit && limit > 0) { + filteredEvents = filteredEvents.slice(0, limit); + } + + const totalSpent = filteredEvents.reduce((sum, event) => sum + event.cost, 0); + const totalCalls = filteredEvents.length; + + const breakdown: { [key: string]: { calls: number; cost: number; avgResponseTime: number } } = {}; + + filteredEvents.forEach(event => { + if (!breakdown[event.apiEndpoint]) { + breakdown[event.apiEndpoint] = { + calls: 0, + cost: 0, + avgResponseTime: 0 + }; + } + + const api = breakdown[event.apiEndpoint]; + api.calls += 1; + api.cost += event.cost; + api.avgResponseTime = (api.avgResponseTime * (api.calls - 1) + event.responseTime) / api.calls; + }); + + const stats: UsageStats = { + totalSpent, + totalCalls, + period: { + from: fromDate, + to: toDate + }, + breakdown + }; + + return { events: filteredEvents, stats }; + } +} diff --git a/src/tests/usage.test.ts b/src/tests/usage.test.ts new file mode 100644 index 0000000..c452ae3 --- /dev/null +++ b/src/tests/usage.test.ts @@ -0,0 +1,254 @@ +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import app from '../index.js'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; + +describe('Usage API', () => { + let validToken: string; + let expiredToken: string; + let invalidToken: string; + + beforeEach(() => { + validToken = jwt.sign( + { walletAddress: '0x1234567890123456789012345678901234567890', userId: 'user1' }, + JWT_SECRET, + { expiresIn: '1h' } + ); + + expiredToken = jwt.sign( + { walletAddress: '0x1234567890123456789012345678901234567890', userId: 'user1' }, + JWT_SECRET, + { expiresIn: '-1h' } + ); + + invalidToken = 'invalid.jwt.token'; + }); + + describe('GET /api/usage', () => { + it('should return 401 without authentication', async () => { + const response = await request(app) + .get('/api/usage') + .expect(401); + + expect(response.body.error).toBe('Authentication required. Please provide a valid JWT token.'); + }); + + it('should return 401 with invalid token', async () => { + const response = await request(app) + .get('/api/usage') + .set('Authorization', `Bearer ${invalidToken}`) + .expect(401); + + expect(response.body.error).toBe('Invalid or expired token.'); + }); + + it('should return 401 with expired token', async () => { + const response = await request(app) + .get('/api/usage') + .set('Authorization', `Bearer ${expiredToken}`) + .expect(401); + + expect(response.body.error).toBe('Invalid or expired token.'); + }); + + it('should return usage data with valid authentication', async () => { + const response = await request(app) + .get('/api/usage') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toHaveProperty('events'); + expect(response.body).toHaveProperty('stats'); + expect(response.body.stats).toHaveProperty('totalSpent'); + expect(response.body.stats).toHaveProperty('totalCalls'); + expect(response.body.stats).toHaveProperty('period'); + expect(response.body.stats).toHaveProperty('breakdown'); + expect(Array.isArray(response.body.events)).toBe(true); + }); + + it('should validate query parameters - invalid from date', async () => { + const response = await request(app) + .get('/api/usage?from=invalid-date') + .set('Authorization', `Bearer ${validToken}`) + .expect(400); + + expect(response.body.error).toBe('Invalid query parameters'); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'from', + message: expect.stringContaining('ISO date string') + }) + ]) + ); + }); + + it('should validate query parameters - invalid to date', async () => { + const response = await request(app) + .get('/api/usage?to=invalid-date') + .set('Authorization', `Bearer ${validToken}`) + .expect(400); + + expect(response.body.error).toBe('Invalid query parameters'); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'to', + message: expect.stringContaining('ISO date string') + }) + ]) + ); + }); + + it('should validate query parameters - from date after to date', async () => { + const response = await request(app) + .get('/api/usage?from=2024-01-02T00:00:00.000Z&to=2024-01-01T00:00:00.000Z') + .set('Authorization', `Bearer ${validToken}`) + .expect(400); + + expect(response.body.error).toBe('Invalid query parameters'); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'from', + message: 'From date must be before to date' + }) + ]) + ); + }); + + it('should validate query parameters - date range too large', async () => { + const response = await request(app) + .get('/api/usage?from=2020-01-01T00:00:00.000Z&to=2024-01-01T00:00:00.000Z') + .set('Authorization', `Bearer ${validToken}`) + .expect(400); + + expect(response.body.error).toBe('Invalid query parameters'); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'from', + message: 'Date range cannot exceed 1 year' + }) + ]) + ); + }); + + it('should validate query parameters - invalid limit', async () => { + const response = await request(app) + .get('/api/usage?limit=invalid') + .set('Authorization', `Bearer ${validToken}`) + .expect(400); + + expect(response.body.error).toBe('Invalid query parameters'); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'limit', + message: 'Limit must be a number' + }) + ]) + ); + }); + + it('should validate query parameters - limit below minimum', async () => { + const response = await request(app) + .get('/api/usage?limit=0') + .set('Authorization', `Bearer ${validToken}`) + .expect(400); + + expect(response.body.error).toBe('Invalid query parameters'); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'limit', + message: 'Limit must be at least 1' + }) + ]) + ); + }); + + it('should validate query parameters - limit above maximum', async () => { + const response = await request(app) + .get('/api/usage?limit=1001') + .set('Authorization', `Bearer ${validToken}`) + .expect(400); + + expect(response.body.error).toBe('Invalid query parameters'); + expect(response.body.details).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'limit', + message: 'Limit cannot exceed 1000' + }) + ]) + ); + }); + + it('should accept valid query parameters', async () => { + const fromDate = new Date(); + fromDate.setDate(fromDate.getDate() - 7); + const toDate = new Date(); + + const response = await request(app) + .get(`/api/usage?from=${fromDate.toISOString()}&to=${toDate.toISOString()}&limit=10`) + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toHaveProperty('events'); + expect(response.body).toHaveProperty('stats'); + expect(response.body.stats.period.from).toBe(fromDate.toISOString()); + expect(response.body.stats.period.to).toBe(toDate.toISOString()); + }); + + it('should return correct usage breakdown by API', async () => { + const response = await request(app) + .get('/api/usage') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body.stats.breakdown).toBeDefined(); + expect(typeof response.body.stats.breakdown).toBe('object'); + + if (Object.keys(response.body.stats.breakdown).length > 0) { + const apiBreakdown = Object.values(response.body.stats.breakdown)[0] as { + calls: number; + cost: number; + avgResponseTime: number; + }; + expect(apiBreakdown).toHaveProperty('calls'); + expect(apiBreakdown).toHaveProperty('cost'); + expect(apiBreakdown).toHaveProperty('avgResponseTime'); + expect(typeof apiBreakdown.calls).toBe('number'); + expect(typeof apiBreakdown.cost).toBe('number'); + expect(typeof apiBreakdown.avgResponseTime).toBe('number'); + } + }); + + it('should limit events when limit parameter is provided', async () => { + const response = await request(app) + .get('/api/usage?limit=1') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + expect(response.body.events.length).toBeLessThanOrEqual(1); + }); + + it('should use default 30-day period when no dates provided', async () => { + const response = await request(app) + .get('/api/usage') + .set('Authorization', `Bearer ${validToken}`) + .expect(200); + + const now = new Date(); + const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const periodFrom = new Date(response.body.stats.period.from); + const periodTo = new Date(response.body.stats.period.to); + + expect(periodFrom.getTime()).toBeCloseTo(thirtyDaysAgo.getTime(), -4); // Allow some tolerance + expect(periodTo.getTime()).toBeCloseTo(now.getTime(), -4); // Allow some tolerance + }); + }); +}); diff --git a/src/tests/usageEventsRepository.test.ts b/src/tests/usageEventsRepository.test.ts new file mode 100644 index 0000000..0528a03 --- /dev/null +++ b/src/tests/usageEventsRepository.test.ts @@ -0,0 +1,138 @@ +import { UsageEventsRepository } from '../repositories/usageEventsRepository.js'; + +describe('UsageEventsRepository', () => { + let repository: UsageEventsRepository; + + beforeEach(() => { + repository = new UsageEventsRepository(); + }); + + describe('getUsageByUserId', () => { + it('should return usage events for a valid user', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + + const result = await repository.getUsageByUserId('user1', fromDate, toDate); + + expect(result).toHaveProperty('events'); + expect(result).toHaveProperty('stats'); + expect(Array.isArray(result.events)).toBe(true); + expect(result.stats).toHaveProperty('totalSpent'); + expect(result.stats).toHaveProperty('totalCalls'); + expect(result.stats).toHaveProperty('period'); + expect(result.stats).toHaveProperty('breakdown'); + }); + + it('should return empty results for non-existent user', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + + const result = await repository.getUsageByUserId('nonexistent', fromDate, toDate); + + expect(result.events).toHaveLength(0); + expect(result.stats.totalSpent).toBe(0); + expect(result.stats.totalCalls).toBe(0); + }); + + it('should respect limit parameter', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + + const resultWithoutLimit = await repository.getUsageByUserId('user1', fromDate, toDate); + const resultWithLimit = await repository.getUsageByUserId('user1', fromDate, toDate, 1); + + expect(resultWithLimit.events.length).toBeLessThanOrEqual(1); + expect(resultWithLimit.events.length).toBeLessThanOrEqual(resultWithoutLimit.events.length); + }); + + it('should calculate correct statistics', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + + const result = await repository.getUsageByUserId('user1', fromDate, toDate); + + const expectedTotalSpent = result.events.reduce((sum, event) => sum + event.cost, 0); + const expectedTotalCalls = result.events.length; + + expect(result.stats.totalSpent).toBe(expectedTotalSpent); + expect(result.stats.totalCalls).toBe(expectedTotalCalls); + }); + + it('should provide breakdown by API endpoint', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + + const result = await repository.getUsageByUserId('user1', fromDate, toDate); + + expect(typeof result.stats.breakdown).toBe('object'); + + if (result.events.length > 0 && result.stats.breakdown) { + const firstEvent = result.events[0]; + expect(result.stats.breakdown[firstEvent.apiEndpoint]).toBeDefined(); + + const apiBreakdown = result.stats.breakdown[firstEvent.apiEndpoint]; + expect(apiBreakdown).toHaveProperty('calls'); + expect(apiBreakdown).toHaveProperty('cost'); + expect(apiBreakdown).toHaveProperty('avgResponseTime'); + } + }); + }); + + describe('getUsageByWalletAddress', () => { + it('should return usage events for a valid wallet address', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + const walletAddress = '0x1234567890123456789012345678901234567890'; + + const result = await repository.getUsageByWalletAddress(walletAddress, fromDate, toDate); + + expect(result).toHaveProperty('events'); + expect(result).toHaveProperty('stats'); + expect(Array.isArray(result.events)).toBe(true); + expect(result.stats).toHaveProperty('totalSpent'); + expect(result.stats).toHaveProperty('totalCalls'); + expect(result.stats).toHaveProperty('period'); + expect(result.stats).toHaveProperty('breakdown'); + }); + + it('should return empty results for non-existent wallet address', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + const walletAddress = '0x0000000000000000000000000000000000000000'; + + const result = await repository.getUsageByWalletAddress(walletAddress, fromDate, toDate); + + expect(result.events).toHaveLength(0); + expect(result.stats.totalSpent).toBe(0); + expect(result.stats.totalCalls).toBe(0); + }); + + it('should filter by date range correctly', async () => { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const walletAddress = '0x1234567890123456789012345678901234567890'; + + const result = await repository.getUsageByWalletAddress(walletAddress, oneHourAgo, now); + + result.events.forEach(event => { + expect(event.timestamp.getTime()).toBeGreaterThanOrEqual(oneHourAgo.getTime()); + expect(event.timestamp.getTime()).toBeLessThanOrEqual(now.getTime()); + }); + }); + + it('should calculate correct average response times', async () => { + const fromDate = new Date('2024-01-01T00:00:00.000Z'); + const toDate = new Date('2024-12-31T23:59:59.999Z'); + const walletAddress = '0x1234567890123456789012345678901234567890'; + + const result = await repository.getUsageByWalletAddress(walletAddress, fromDate, toDate); + + if (result.stats.breakdown) { + Object.values(result.stats.breakdown).forEach(apiBreakdown => { + expect(apiBreakdown.avgResponseTime).toBeGreaterThan(0); + expect(typeof apiBreakdown.avgResponseTime).toBe('number'); + }); + } + }); + }); +}); diff --git a/src/types/usage.ts b/src/types/usage.ts new file mode 100644 index 0000000..8c7c841 --- /dev/null +++ b/src/types/usage.ts @@ -0,0 +1,33 @@ +export interface UsageEvent { + id: string; + userId: string; + walletAddress: string; + apiEndpoint: string; + method: string; + timestamp: Date; + cost: number; + statusCode: number; + responseTime: number; +} + +export interface UsageStats { + totalSpent: number; + totalCalls: number; + period: { + from: Date; + to: Date; + }; + breakdown?: { + [apiEndpoint: string]: { + calls: number; + cost: number; + avgResponseTime: number; + }; + }; +} + +export interface UsageQueryParams { + from?: string; + to?: string; + limit?: string; +} diff --git a/src/validators/usageValidator.ts b/src/validators/usageValidator.ts new file mode 100644 index 0000000..2375ac9 --- /dev/null +++ b/src/validators/usageValidator.ts @@ -0,0 +1,67 @@ +import Joi from 'joi'; +import { Request, Response, NextFunction } from 'express'; + +export const usageQuerySchema = Joi.object({ + from: Joi.string().isoDate().optional().messages({ + 'string.isoDate': 'From date must be a valid ISO date string (YYYY-MM-DDTHH:mm:ss.sssZ)' + }), + to: Joi.string().isoDate().optional().messages({ + 'string.isoDate': 'To date must be a valid ISO date string (YYYY-MM-DDTHH:mm:ss.sssZ)' + }), + limit: Joi.number().integer().min(1).max(1000).optional().messages({ + 'number.base': 'Limit must be a number', + 'number.integer': 'Limit must be an integer', + 'number.min': 'Limit must be at least 1', + 'number.max': 'Limit cannot exceed 1000' + }) +}).custom((value, helpers) => { + if (value.from && value.to) { + const fromDate = new Date(value.from); + const toDate = new Date(value.to); + + if (fromDate >= toDate) { + return helpers.error('custom.dateRange', { path: ['from'] }); + } + + const maxRange = 365 * 24 * 60 * 60 * 1000; // 1 year in milliseconds + if (toDate.getTime() - fromDate.getTime() > maxRange) { + return helpers.error('custom.dateRangeTooLarge', { path: ['from'] }); + } + } + + return value; +}).messages({ + 'custom.dateRange': 'From date must be before to date', + 'custom.dateRangeTooLarge': 'Date range cannot exceed 1 year' +}); + +export const validateUsageQuery = (req: Request, res: Response, next: NextFunction) => { + const { error, value } = usageQuerySchema.validate(req.query); + + if (error) { + const details = error.details.map(detail => { + let field = detail.path.join('.'); + let message = detail.message; + + // Handle custom validation errors + if (detail.type === 'custom.dateRange' || detail.type === 'custom.dateRangeTooLarge') { + field = 'from'; + if (detail.type === 'custom.dateRange') { + message = 'From date must be before to date'; + } else { + message = 'Date range cannot exceed 1 year'; + } + } + + return { field, message }; + }); + + return res.status(400).json({ + error: 'Invalid query parameters', + details + }); + } + + req.query = value; + next(); +};